Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions
4749911994 GitHub bot : README updated 2026-07-28 17:30:51 +00:00
github-actions
6ea3ef774d GitHub bot: sanitize (spaces + LF endings) & chmod [nobuild] 2026-07-28 15:07:44 +00:00
Alexandre
da50d9b178 fix(claude_desktop): persist sign-in by opting into Electron safeStorage (#2922)
* fix(claude_desktop): persist sign-in by opting into Electron safeStorage

Claude Desktop asked the user to sign in again on every start. The v1.35 fix
was inert: --password-store=basic did reach the process (confirmed on a live
install's /proc/<pid>/cmdline), but the app still logged "safeStorage not
available, tokens will not persist" on every launch.

Electron refuses its built-in basic_text backend unless the application calls
safeStorage.setUsePlainTextEncryption(true) before the ready event, and Claude
Desktop never calls it - the symbol is present in the shipped Electron binary
but absent from resources/app.asar. So isEncryptionAvailable() stayed false and
the auth token was never persisted. Verified against a standalone Electron of
the same generation: without the opt-in it is false; with it, true, and a
separate later process decrypts a blob written by an earlier one.

There is no equivalent command-line switch, and NODE_OPTIONS=--require is
ignored by packaged Electron apps (verified against the real binary), so the
opt-in is injected into the app's main bundle inside app.asar. gnome-keyring
stays out of the image: its first-boot password prompt blocks the app from
launching at all.

The patcher fails closed, rebuilds the archive preserving unpacked/symlink
entries, recomputes the changed entry's SHA-256 integrity record, and fully
re-validates the result from disk before renaming it into place. It re-runs on
every boot after 81-claude_update.sh, since an apt upgrade ships a fresh
unpatched app.asar, and is marker-guarded so an unchanged app is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(claude_desktop): harden the safeStorage hook against review findings

- Sweep stale .app.asar.addon-tmp.* from the shell hook. `timeout` kills the
  patcher outright, so a run that hits the 120s cap never executes its own
  cleanup; the live archive stays unpatched, so every later boot would retry
  under a new pid and strand another archive-sized file.
- End the hook with an explicit `exit 0`. The logging `while` loop's status
  became the script's status, so an empty last line could exit non-zero and
  fail cont-init - the opposite of the documented "never block startup".

Both raised in review on #2922.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:06:44 +02:00
Alexandre
dc1044e453 fix(webtop,webtop_kde,claude_desktop): give Selkies XDG_RUNTIME_DIR and the right websocket port (#2921)
* fix(webtop,webtop_kde,claude_desktop): give Selkies XDG_RUNTIME_DIR and the right WS port

Selkies panics with `RuntimeDirNotSet` right after its data websocket server
comes up, and binds that server on 8081 while nginx proxies 8082.

Upstream relies on s6-rc ordering: init-selkies-config publishes
XDG_RUNTIME_DIR and CUSTOM_WS_PORT into the s6 envdir, and svc-selkies is
started afterwards. ha_entrypoint.sh replaces s6-overlay and launches every
s6-rc.d run script in parallel with no dependency graph, so a longrun can
snapshot the envdir (with-contenv reads it once, at exec) before the oneshot
has written to it. Port 8081 in the report is the proof: that is selkies' own
default, not the 8082 init-selkies-config writes near the end of its run.

Only the webtop images carry PIXELFLUX_WAYLAND=true, which is why the missing
runtime dir reaches a Wayland socket bind there and not on claude_desktop.

20-folders.sh now exports both variables inside each run script, where no
start ordering can lose them, and 90-ingress.sh derives the nginx CWS
substitution from the same value.

Also correct the base image's $HOME/.XDG override where that write happens
rather than appending a correction to init-selkies-config: the oneshot
tolerance block appends `exit 0`, so on every boot after the first the
appended correction sat past it and never ran.

81-microsoft_edge.sh (webtop only), addressing the open review comments on
PR #2920:
- apt-get/dpkg failures no longer abort cont-init; each is guarded, warns and
  exits 0, and apt acquisition is bounded so a stalled mirror cannot hang
  start-up
- quote ${EDGE_VERSION+x}
- gate the wrapper swap on /helpers/microsoft-edge-stable still existing, so a
  second run cannot move the installed wrapper aside with nothing to replace it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(claude_desktop): inject the run-script exports after the data_location rewrite

Two findings from an independent review of the previous commit.

The `s|$DEFAULT_LOCATION|$LOCATION|g` pass over the s6-rc.d run scripts is a
blind textual substitution, and it ran after the export injection. A
data_location *under* the image default -- /config/data_kde/foo on an image
whose default is /config/data_kde -- therefore rewrote the freshly injected
`export HOME=/config/data_kde/foo` into `.../foo/foo`. Injecting after the
rewrite instead of before removes the double substitution.

Quote the injected values so a location containing whitespace cannot produce a
broken run script. XDG_CACHE_HOME stays unquoted: the loop greps for it as its
idempotence marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(webtop,webtop_kde,claude_desktop): address review feedback on the Selkies env fix

Refresh the injected run-script exports on every boot (codex, coderabbit).
They were guarded by the XDG_CACHE_HOME sentinel, so they were written once and
then survived in the writable layer. Raising PUID left every service exporting a
/run/user/<old-uid> the remapped abc user cannot use, and clearing a custom
CUSTOM_WS_PORT left Selkies on the old port while 90-ingress.sh moved nginx back
to 8082 -- with the envdir written at the same boot disagreeing with both. The
exports now sit in a marked managed block that is stripped and rewritten each
boot, mirroring how the ~/.bashrc block in the same script already works. The
sweep also removes the bare exports earlier versions wrote, so an upgraded
container cannot end up with two sets. No upstream run script in these images
sets any of the five, so it only ever removes our own.

Validate CUSTOM_WS_PORT once, where it enters (coderabbit). It is interpolated
into generated shell and into a sed replacement, so a non-numeric or
out-of-range value could corrupt a run script or the nginx config. 90-ingress.sh
repeats the check rather than trusting the envdir, so a malformed value cannot
reach the nginx config if 20-folders.sh did not get that far.

Download Edge to an mktemp path instead of a fixed /tmp/edge.deb (coderabbit).
This runs as root against a world-writable tmpfs, where a predictable name can
be pre-created as a symlink to redirect the download or swap what is installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:05:07 +02:00
Alexandre
bfe91cbeac refactor(webtop,webtop_kde,claude_desktop): share the Selkies startup scripts (#2920)
* refactor(webtop,webtop_kde,claude_desktop): share the Selkies startup scripts

All three add-ons are built on the LinuxServer Selkies base image and had
independently drifted copies of the same startup scripts. claude_desktop's
copies carry a set of fixes the two webtops never received, so make
claude_desktop the single source and symlink the shared scripts from
webtop_kde/rootfs (which webtop/rootfs already symlinks in full).

Shared by symlink: 20-folders.sh, 21-gpu_permissions.sh, 80-configuration.sh,
90-ingress.sh and the six etc/nginx/includes files.

Kept add-on specific: everything Claude-only stays in claude_desktop
(81/82/83/84 tool installs, 85-openbox_autostart.sh, defaults/, usr/local/bin,
svc-headroom), and everything webtop-only stays in webtop_kde (90-ssl.sh,
helpers/microsoft-edge-stable, and the new 81-microsoft_edge.sh).

To make the shared scripts add-on agnostic:
- 20-folders.sh derives its default data location from the home directory the
  Dockerfile baked into the abc user instead of hardcoding /data/data. That
  yields /data/data on claude_desktop and /config/data_kde on both webtops,
  matching each add-on's previous behaviour exactly.
- The permission_mode: bypass root guard is skipped on add-ons that do not
  declare that option.
- 80-configuration.sh falls back to pip when the image does not ship uv.
- The Microsoft Edge install moves out of 80-configuration.sh into a
  webtop-only 81-microsoft_edge.sh, which also absorbs the ownership fixup
  that used to run in 20-folders.sh before Edge was installed and so never
  matched anything.

CI: the builder's symlink-resolution step made a single pass over a
pre-computed file list, so resolving webtop/rootfs (a directory symlink)
could copy the symlinks inside it verbatim, leaving links that escape the
webtop build context. Verified on this tree: the old loop leaves 10 dangling
symlinks under webtop/. Extract it to .github/scripts/resolve_symlinks.sh,
repeat until a pass finds nothing, and run it in the PR check too, which had
no resolution step at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: guard two startup aborts found in review

Both are crash paths in code added by this PR, not hardening:

- 20-folders.sh: getent exits 2 when the user does not exist, and under
  bashio's `set -o pipefail` plus the script's `set -e` that aborts at the
  assignment, so the "could not read abc's home" fallback below it was
  unreachable. Same trap already documented in 21-gpu_permissions.sh.
  Verified: without the guard the shell exits 2; with it the fallback runs.

- 81-microsoft_edge.sh: the ownership fixup lost the `-f` guard the original
  had in 20-folders.sh. Without nullglob an unmatched /usr/bin/microsoft-edge*
  reaches chown as a literal and `set -e` kills container startup. Now a
  nullglob array with a warning when empty.

Also make resolve_symlinks.sh fail on a broken symlink instead of deleting it.
Dropping it silently yields an image that builds clean and misbehaves at
runtime; a red build is easier to diagnose. Verified both paths: the repo as-is
resolves to 0 symlinks and exit 0, and an injected broken link exits 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address review comments on data-location default and Edge downloads

Two findings from CodeRabbit, both correctness rather than hardening:

- 20-folders.sh rewrites abc's home in /etc/passwd further down, so re-reading
  it on the next boot returned the *previously selected* location as the image
  default. On a restart that reuses the container's writable layer, clearing
  data_location would strand the user on their old custom path instead of
  restoring the built-in one. Cache the value in /etc/.addon_image_home, which
  shares the writable layer's lifetime with the edit it compensates for: a
  rebuilt or recreated container starts from a pristine /etc/passwd and
  regenerates it. Simulated all three cases (first boot, reused container with
  a rewritten passwd, recreated container) under `set -e` + `set -o pipefail`.

- 81-microsoft_edge.sh: both curl calls were unbounded, so a stalled
  packages.microsoft.com would hang cont-init.d and with it the whole add-on.
  Add --fail/--connect-timeout/--max-time and skip the install with a logged
  error when version discovery or the download fails. The desktop is useful
  without Edge; an add-on wedged before Selkies starts is not.

Not addressed, deliberately: escaping $LOCATION/$DEFAULT_LOCATION for sed, and
validating symlink targets in resolve_symlinks.sh. Both are hardening against
inputs that are not reachable in normal use, both predate this PR, and the
maintainer has asked to prioritise usability over that class of change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:23:52 +02:00
Alexandre
5eb7cd2744 fix(seerr): re-encode ingress query strings for the OpenAPI validator (#2917)
* fix(seerr): re-encode ingress query strings for the OpenAPI validator

Searches through ingress fail with a 400 from the Seerr API, which the UI
reports as "500 Internal Server Error". The same searches succeed on the
directly published port 5055.

Supervisor proxies ingress traffic with `params=request.query`
(supervisor/api/ingress.py) - an already-decoded MultiDict - so aiohttp/yarl
re-encodes the query string on the way to the add-on. yarl's safe set is far
wider than the one express-openapi-validator accepts: it emits a space as "+"
and forwards ":", "/", "@", "!", "$", "'", "(", ")", "*" and "," bare, while
the validator tests the raw, still-encoded value against

    RESERVED_CHARS = /[\:\/\?#\[\]@!\$&\'()\*\+,;=]/

and rejects the request. That breaks most real titles - "Monsters, Inc.",
"Ocean's Eleven", "Mission: Impossible", "Mamma Mia!".

An njs handler now re-encodes exactly those characters before proxying. This
is lossless: yarl only emits them bare when they were literal characters of
the value, since anything ambiguous arrives already encoded ("+" as %2B, "&"
as %26, "=" as %3D). "&" and "=" are left alone as the query string's own
separators, and the path is forwarded byte-for-byte.

Verified against a real express-openapi-validator over all 19 characters in
RESERVED_CHARS, and diffed byte-for-byte against the previous config across
representative traffic: only query-string encoding changes.

Fixes #2906
Fixes #2646

* fix(seerr): ship the njs load_module snippet and encode "?" and ";"

Addresses two review findings.

1. The njs module never loaded. .templates/ha_automatic_packages.sh moves the
   rootfs /etc/nginx aside to /etc/nginx2 before installing nginx, then does
   `rm -r /etc/nginx` and restores the saved tree. That deletes the
   load_module snippet nginx-mod-http-js installs, so nginx aborted with
   `unknown directive "js_import"` and the service's finish hook would have
   shut the add-on down - all ingress dead, not just search. The image build
   still passed CI because it never starts nginx. The snippet now ships in the
   rootfs so it survives the swap, under the package's own filename so the two
   can never both be present and double-load the module.

2. "?" arrives bare from yarl and was not encoded. It slipped through testing
   because the validator strips one occurrence with `qs.replace('?', '')`
   before checking, so a single "?" passes by accident and only a second one
   ("Who? What?") returned 400.

NEEDS_ENCODING is now derived from the validator's RESERVED_CHARS minus the
"&" and "=" separators, rather than from the characters yarl happens to emit
bare today, so it stays correct if either side changes its safe set. The added
characters are a no-op for current traffic: yarl already percent-encodes
"# [ ] ;", so Seerr receives them encoded regardless.

Verified by replaying the build-time /etc/nginx2 swap and starting nginx, which
fails without the snippet and serves correctly with it; by sending every
RESERVED_CHAR bare; and by diffing forwarded bytes against master, unchanged at
2/20 with all query-parser edge cases identical.

* style(seerr): satisfy Codacy - double quotes in njs, changelog blank lines

Clears the 11 new Info-level Codacy findings: 9x ESLint 'quotes' in
njs/ingress.js and 2x markdownlint MD022/MD032 on the changelog entry.
No behaviour change; re-verified through the build-time /etc/nginx2 swap.
2026-07-28 09:13:31 +02:00
36 changed files with 847 additions and 399 deletions

45
.github/scripts/resolve_symlinks.sh vendored Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Replace every symlink in the checked-out repository with a real copy of its target.
#
# Add-ons share files by symlinking across add-on directories (e.g. webtop/rootfs ->
# ../webtop_kde/rootfs, and files inside it -> ../../../../claude_desktop/rootfs/...). A
# Docker build context is a single add-on directory, so any symlink that escapes it has to be
# materialised before the build.
#
# The loop repeats because resolving one symlink can create others: copying a directory
# symlink with `cp -a` preserves the symlinks *inside* it, and those copies are not part of
# the file list the current pass is iterating over. Repeating until a pass finds nothing makes
# the result independent of the order `find` happens to return.
set -euo pipefail
for _ in 1 2 3 4 5; do
mapfile -t links < <(find . -type l)
if [ "${#links[@]}" -eq 0 ]; then
exit 0
fi
for link in "${links[@]}"; do
target=$(readlink -f "$link" || true)
if [ -z "$target" ] || [ ! -e "$target" ]; then
# Fail rather than drop it. A broken link here means an add-on is missing a file
# it expects to ship; silently removing it produces an image that builds fine and
# misbehaves at runtime, which is far harder to diagnose than a red build.
echo "::error::Broken symlink: $link -> $(readlink "$link")"
exit 1
fi
rm "$link"
if [ -d "$target" ]; then
mkdir -p "$link"
cp -a "$target/." "$link/"
else
cp "$target" "$link"
fi
done
done
if [ -n "$(find . -type l)" ]; then
echo "::error::Symlinks still present after 5 resolution passes; possible symlink cycle"
find . -type l
exit 1
fi

View File

@@ -108,6 +108,9 @@ jobs:
- name: ↩️ Checkout
uses: actions/checkout@v7.0.1
- name: Resolve symlinks in repository copy
run: bash .github/scripts/resolve_symlinks.sh
- name: Copy templates into addon build context
env:
ADDON: ${{ matrix.addon }}

View File

@@ -142,24 +142,7 @@ jobs:
persist-credentials: false
- name: Resolve symlinks in repository copy
run: |
set -euo pipefail
find . -type l | while read -r link; do
target=$(readlink -f "$link" || true)
if [ -z "$target" ]; then
echo "Skipping broken symlink: $link"
continue
fi
rm "$link"
if [ -d "$target" ]; then
mkdir -p "$link"
cp -a "$target/." "$link/"
else
cp "$target" "$link"
fi
done
run: bash .github/scripts/resolve_symlinks.sh
- name: Copy templates into addon build context
env:

View File

@@ -56,7 +56,7 @@ If you want to do add the repository manually, please follow the procedure highl
### Number of addons
- In the repository : 136
- In the repository : 137
- Installed : 591099
### Top 3
@@ -796,6 +796,14 @@ If you want to do add the repository manually, please follow the procedure highl
![amd64][amd64-badge]
![full_access][full_access-badge]
&#10003; ![image](https://api.iconify.design/mdi/docker.svg) [Portainer Business Edition](portainer_be/) : Manage your Docker environment with ease (Business Edition)
&emsp;&emsp;![Version](https://img.shields.io/badge/dynamic/yaml?label=Version&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Fportainer_be%2Fconfig.yaml)
![Update](https://img.shields.io/badge/dynamic/json?label=Updated&query=%24.last_update&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Fportainer_be%2Fupdater.json)
![aarch64][aarch64-badge]
![amd64][amd64-badge]
![ingress][ingress-badge]
&#10003; [Postgres 15](postgres_15/) : Postgres 15 with VectorChord support
&emsp;&emsp;![Version](https://img.shields.io/badge/dynamic/yaml?label=Version&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Fpostgres_15%2Fconfig.yaml)

View File

@@ -1,3 +1,20 @@
## 1.37 (28-07-2026)
- Fix the recurring "For your security, sign in again to keep using Claude." prompt for real. The v1.35 fix was ineffective: `--password-store=basic` was reaching the process (confirmed on a live install's `/proc/<pid>/cmdline`), yet the app kept logging `safeStorage not available, tokens will not persist` and `Encryption not available, returning empty env vars` on every launch. The missing half is an application-side opt-in — Electron refuses its built-in `basic_text` backend unless the app calls `safeStorage.setUsePlainTextEncryption(true)` before `ready`, and Claude Desktop never calls it (the symbol is present in the Electron binary but absent from `resources/app.asar`). So `isEncryptionAvailable()` stayed `false` and the auth token was never persisted, exactly as when the keyring backend was forced without a keyring daemon. Verified against a standalone Electron of the same generation: with `--password-store=basic` and no opt-in `isEncryptionAvailable()` is `false`; with the opt-in it is `true`, and a *separate later process* decrypts a blob written by an earlier one — which is the restart survival this add-on needs.
- **The patch (`86-claude_safestorage.sh` + `claude-safestorage-patch.js`, new).** A small Node script injects that one opt-in call into the app's main bundle (`.vite/build/index.pre.js`) inside `app.asar`. Insertion is placed *after* the bundle's leading `"use strict";` rather than before it, because a directive prologue only takes effect as the first statement — prepending ahead of it would silently drop the entire main process out of strict mode. The archive is rebuilt rather than edited in place, since an asar's header stores a byte offset and length per file: entries flagged `unpacked` (which live in `app.asar.unpacked/`, not in the archive body) and symlink entries are carried through untouched, so the native-module layout is preserved, and the per-file SHA-256 `integrity` record is recomputed for the one file that changed. The injector fails closed: anything that does not open with an unambiguously terminated `"use strict"` directive is refused rather than patched by guesswork, since `"use strict" + x` is an expression and injecting into it would yield a syntax error. The rebuild is written to a temporary file, `fsync`ed, and then fully re-validated from disk — entry count, every offset and length parsed as an exact in-bounds integer, the last body ending precisely at EOF, and the patched entry's recomputed SHA-256 — before it is renamed over the original and the directory synced; the temporary file is removed on every failure path. So a torn, truncated or short write can never replace a working archive. Verified byte-for-byte against the real archive: same 211 entries, no `unpacked`/symlink flag drift, no trailing slack, every integrity hash valid, and content differing in exactly one file.
- **Why it re-runs every boot.** `81-claude_update.sh` apt-upgrades `claude-desktop` on startup, and a new package ships a fresh, unpatched `app.asar`; a one-shot patch at image build time would therefore be undone by the first update. The script is marker-guarded, so a boot where the app did not change is a no-op, and it is numbered `86-` to land after both the update check and `85-openbox_autostart.sh`, while still completing before any s6 service — i.e. before the desktop launches the app. A failure is logged and swallowed rather than propagated, and the patcher is run under a 120s `timeout`: an unpatched app still runs, it just forgets the sign-in, which is not worth blocking startup over. The hook ends in an explicit `exit 0` so no logging branch can turn a non-fatal patch failure into a failed cont-init, and it sweeps stale temporary archives itself — `timeout` kills the patcher outright, so the run that hits the cap cannot execute its own cleanup, and each stranded file is archive-sized.
- **`gnome-keyring` stays out of the image.** Re-adding it would reintroduce the first-boot keyring password prompt that blocks Claude Desktop from launching at all. This route needs no keyring, no D-Bus Secret Service, no daemon and no password, so no prompt can appear. The trade-off is unchanged from v1.35 and is inherent to the `basic` backend: its key is fixed rather than gated by a keyring, so any process that can read the persistent `$HOME/.config/Claude` profile can recover the stored credentials.
- **One-time step after upgrading.** The previously stored session is already stale, so a single sign-in is still needed once after this update; it then persists across restarts.
## 1.36.4 (28-07-2026)
- Fix Selkies dying with a Rust `RuntimeDirNotSet` unwrap panic just after `Data WebSocket Server listening on port 8081`, and the data websocket then being proxied to the wrong port. Upstream relies on s6-rc ordering: `init-selkies-config` publishes `XDG_RUNTIME_DIR` and `CUSTOM_WS_PORT` into the s6 envdir and `svc-selkies` starts afterwards. The add-on entrypoint replaces s6-overlay and starts every `s6-rc.d` run script in parallel with no dependency graph, so Selkies can snapshot the envdir before that oneshot has written to it -- which is why it bound port 8081 (its own default) instead of the 8082 nginx proxies to, and why its Wayland compositor found no runtime directory to bind a socket in. `20-folders.sh` now exports both variables inside each run script, where no start ordering can lose them, and corrects the base image's `$HOME/.XDG` override where that write happens instead of appending a correction after the `exit 0` that the oneshot-tolerance block adds -- which meant the correction never ran on any boot after the first.
## 1.36.3 (28-07-2026)
- Make the Selkies startup scripts add-on agnostic so `webtop` and `webtop_kde` can share them by symlink instead of carrying their own drifted copies. `20-folders.sh` now derives its default data location from the home directory the Dockerfile baked into the `abc` user (`getent passwd abc`) rather than hardcoding `/data/data`, and the `permission_mode: bypass` root guard is skipped on add-ons that do not declare that option. `80-configuration.sh` falls back to `pip` when the image does not ship `uv`. No behaviour change for Claude Desktop: `getent passwd abc` returns `/data/data`, which is exactly the value that was hardcoded before.
## 1.36.2 (28-07-2026)
- Minor bugs fixed

View File

@@ -105,7 +105,10 @@ RUN if [ ! -f /bin/sh ] && [ -f /usr/bin/sh ]; then ln -s /usr/bin/sh /bin/sh; f
# Install Claude Desktop, Claude Code, Python tooling, and lightweight local validators.
# gnome-keyring is intentionally NOT installed: it prompts for a keyring password on first
# boot, which blocks Claude Desktop from launching. Sign-in persistence instead uses Electron's
# built-in --password-store=basic (rootfs/defaults/autostart) — no daemon, no prompt.
# built-in --password-store=basic (rootfs/defaults/autostart) — no daemon, no prompt — together
# with rootfs/etc/cont-init.d/86-claude_safestorage.sh, which patches app.asar to opt into that
# backend. Electron ignores --password-store=basic without the app-side opt-in, so both are
# required for the sign-in to survive a restart.
# The cowork virtualization stack (qemu-system-x86 + ovmf firmware) lets Claude Code launch
# its sandbox microVM; libseccomp2 and libcap-ng0 are the shared libraries the source-built
# virtiofsd daemon links against at runtime. Docker itself is NOT installed here: this base

View File

@@ -9,11 +9,12 @@ streamed desktop.
v1.24-ish removed `gnome-keyring` again because it prompts for a keyring password on first
boot, which blocked Claude Desktop from ever launching — but the launch flag was left
forcing the now-daemonless libsecret backend, so `safeStorage` silently went unavailable
again (recurring "sign in again", and — new in this round — the Claude app's dispatch tab
showing the desktop as offline until a fresh sign-in was done from a computer). Fixed in
v1.35: switched to `--password-store=basic` (Electron's built-in store, no keyring
involved at all) plus a cont-init script that re-syncs the persistent openbox `autostart`
from the image on every boot, so the fix reaches existing installs, not just fresh ones.
again (recurring "sign in again", and the Claude app's dispatch tab showing the desktop as
offline until a fresh sign-in was done from a computer). v1.35 switched to
`--password-store=basic` plus a cont-init script that re-syncs the persistent openbox
`autostart` from the image on every boot — **but that flag alone does nothing**, and the bug
survived it untouched. Actually fixed in v1.37, which adds the application-side opt-in the
`basic` backend requires; see "Why v1.35 did not work" below.
- **Planned only:** Problem A (in-desktop browser for OAuth) is intentionally not implemented.
The image ships no browser; complete the login with the user-side workaround below.
@@ -102,14 +103,60 @@ Re-adding gnome-keyring would just reintroduce the original launch-blocking prom
a dead end without also solving *that* — hence the shipped fix below avoids keyring
entirely.
### Fix (shipped, v1.35)
1. `claude_desktop/rootfs/defaults/autostart` launches with
`--password-store=basic` instead of `gnome-libsecret`. `basic` is Electron's built-in
fixed-key store: `isEncryptionAvailable()` is always `true`, no daemon, no prompt. Secrets
land under `$HOME/.config/Claude`, and `HOME=/data/data` is persistent add-on storage, so
the session survives restarts. `basic` trades away OS-backed at-rest protection: unlike
### Why v1.35 did not work
v1.35 assumed `--password-store=basic` makes `isEncryptionAvailable()` "always true". It does
not. On a live install the flag was confirmed on the running process:
```
$ tr '\0' ' ' < /proc/2247/cmdline
/usr/lib/claude-desktop/claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=basic
```
and the app still logged, on every launch:
```
[warn] safeStorage not available, tokens will not persist
[warn] Encryption not available, returning empty env vars
[error] Electron safeStorage encryption is not available on this system, cannot store allowlist cache
```
Electron deliberately refuses its own `basic_text` backend unless the **application** opts in
by calling `safeStorage.setUsePlainTextEncryption(true)` before the `ready` event. The symbol
is present in the shipped Electron binary but absent from `resources/app.asar` — Claude Desktop
never calls it. So v1.35 replaced one unavailable backend with another.
Confirmed against a standalone Electron of the same generation, all with
`--password-store=basic`:
| case | `isEncryptionAvailable()` |
| --- | --- |
| no opt-in (= Claude Desktop as shipped) | `false` |
| `setUsePlainTextEncryption(true)` | `true`, `encryptString` works |
| fresh process, decrypting the earlier process's blob | `true`, plaintext recovered |
The third row is the one that matters: it is the restart survival this add-on needs.
### Fix (shipped, v1.37)
1. `claude_desktop/rootfs/defaults/autostart` launches with `--password-store=basic` instead of
`gnome-libsecret` — Electron's built-in fixed-key store: no daemon, no prompt. Secrets land
under `$HOME/.config/Claude`, and `HOME=/data/data` is persistent add-on storage, so the
session survives restarts. `basic` trades away OS-backed at-rest protection: unlike
`gnome-libsecret`, its encryption key isn't gated by a keyring daemon, so any process able
to read the persistent `$HOME/.config/Claude` profile can recover the saved credentials.
1b. `claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh` +
`claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js` (new) supply the opt-in
the flag depends on, by injecting `safeStorage.setUsePlainTextEncryption(true)` into the
app's main bundle inside `app.asar`. The injection goes *after* the bundle's leading
`"use strict";` — a directive prologue only counts as the first statement, so prepending
ahead of it would drop the main process out of strict mode. The archive is rebuilt (asar
headers store per-file offsets, so content cannot simply grow in place), preserving
`unpacked` and symlink entries and recomputing the per-file SHA-256 `integrity` record for
the single changed file; the result is verified and only then renamed over the original.
It re-runs every boot after `81-claude_update.sh`, because an apt upgrade of
`claude-desktop` ships a fresh unpatched `app.asar`; it is marker-guarded, so an unchanged
app is a no-op, and a failure is logged rather than propagated (an unpatched app still
runs, it just forgets the sign-in).
2. A passwordless keyring was considered instead (keeps libsecret encryption-at-rest without
a prompt) and rejected: the keyring DB would live in the same persistent volume as the
ciphertext it's "protecting," so it adds ~no real confidentiality in this single-user
@@ -133,9 +180,12 @@ normally and dispatch stays online regardless of which device connects first aft
## Files this plan touched
- `claude_desktop/rootfs/defaults/autostart` — drop the keyring bootstrap; launch with
`--password-store=basic`.
- `claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh` — new; syncs the persistent
autostart from the image on every boot.
- `claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh` — new in v1.35; syncs the
persistent autostart from the image on every boot.
- `claude_desktop/rootfs/etc/cont-init.d/86-claude_safestorage.sh` and
`claude_desktop/rootfs/usr/local/bin/claude-safestorage-patch.js` — new in v1.37; the
app-side `safeStorage` opt-in that makes `--password-store=basic` actually take effect.
- `claude_desktop/Dockerfile` — corrected stale comment (gnome-keyring is not installed).
- `claude_desktop/CHANGELOG.md` / `config.yaml` — v1.35.
- `claude_desktop/CHANGELOG.md` / `config.yaml` — v1.35, then v1.37.
Problem A (in-desktop browser for OAuth) remains planned-only; not touched by this change.

View File

@@ -122,5 +122,5 @@ slug: claude_desktop
tmpfs: true
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "1.36.2"
version: "1.37"
video: true

View File

@@ -8,9 +8,15 @@
# surfaced as recurring "sign in again" prompts and (because the stale session also fails the
# elevated-access OAuth check) the Claude app's dispatch tab showing this desktop as offline.
#
# --password-store=basic uses Electron's built-in fixed-key store instead: no daemon, no
# prompt, and isEncryptionAvailable() is always true. Secrets land under $HOME/.config/Claude,
# and HOME=/data/data is persistent add-on storage, so the saved sign-in survives restarts.
# --password-store=basic uses Electron's built-in fixed-key store instead: no daemon and no
# prompt. Secrets land under $HOME/.config/Claude, and HOME=/data/data is persistent add-on
# storage, so the saved sign-in survives restarts.
#
# This flag is only half of it. Electron refuses the basic backend unless the application opts
# in via safeStorage.setUsePlainTextEncryption(true), and Claude Desktop never calls it — with
# the flag alone, isEncryptionAvailable() stays false and the sign-in is still lost on every
# restart. /etc/cont-init.d/86-claude_safestorage.sh injects that opt-in into app.asar before
# this runs; do not drop one without the other.
# 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 instead.

View File

@@ -3,6 +3,33 @@
# shellcheck disable=SC2046
set -e
# Shared by every Selkies-based add-on in this repo (claude_desktop, webtop, webtop_kde) via a
# symlink; keep it add-on agnostic. The only per-add-on input is the home directory baked into
# the image by the Dockerfile's `usermod --home <dir> abc`, read back below.
# Default data location for this image: whatever the Dockerfile set as abc's home.
#
# Cached in a marker file rather than read from /etc/passwd on every boot, because this script
# rewrites that entry further down to the *selected* location. On a restart that reuses the
# container's writable layer, re-reading /etc/passwd would hand back the previous selection as
# the "image default", so clearing data_location would strand the user on their old custom path
# instead of restoring the built-in one. The marker shares its lifetime with the /etc/passwd
# edit it compensates for: both live in the writable layer, so a rebuilt or recreated container
# starts from a pristine /etc/passwd and regenerates the marker correctly.
#
# The `|| true` is load-bearing: getent exits 2 when the user does not exist, and under bashio's
# `set -o pipefail` plus this script's `set -e` that aborts the script at the assignment, before
# the fallback below can run. Same trap documented in 21-gpu_permissions.sh.
DEFAULT_LOCATION_MARKER="/etc/.addon_image_home"
if [ ! -s "$DEFAULT_LOCATION_MARKER" ]; then
getent passwd abc 2> /dev/null | cut -d: -f6 > "$DEFAULT_LOCATION_MARKER" || true
fi
DEFAULT_LOCATION="$(cat "$DEFAULT_LOCATION_MARKER" 2> /dev/null || true)"
if [[ -z "$DEFAULT_LOCATION" || "$DEFAULT_LOCATION" == "/" ]]; then
DEFAULT_LOCATION="/config/data"
bashio::log.warning "Could not read the abc home directory from /etc/passwd; defaulting to $DEFAULT_LOCATION"
fi
# Align the shared desktop user (abc) with the configured PUID/PGID before any storage is
# chowned and before any service or s6-setuidgid call resolves abc. The base image's
# init-adduser applies the same remap, but it runs after cont-init, so doing it here first is
@@ -11,8 +38,8 @@ PUID="$(if bashio::config.has_value 'PUID'; then bashio::config 'PUID'; else ech
PGID="$(if bashio::config.has_value 'PGID'; then bashio::config 'PGID'; else echo '1000'; fi)"
# Claude Code refuses bypass-permissions mode under an effective root UID, so bypass mode
# always needs a non-root desktop user.
if [ "$(bashio::config 'permission_mode')" = "bypass" ] && [ "$PUID" -eq 0 ]; then
# always needs a non-root desktop user. Add-ons without a permission_mode option skip this.
if bashio::config.has_value 'permission_mode' && [ "$(bashio::config 'permission_mode')" = "bypass" ] && [ "$PUID" -eq 0 ]; then
bashio::log.warning "permission_mode: bypass cannot run Claude Code as root; using UID 1000 instead of the configured PUID 0"
PUID=1000
fi
@@ -37,7 +64,7 @@ fi
LOCATION="$(bashio::config 'data_location')"
if [[ "$LOCATION" = "null" || -z "$LOCATION" ]]; then
LOCATION="/data/data"
LOCATION="$DEFAULT_LOCATION"
else
LOCATIONOK=""
for location in "/share" "/config" "/data" "/mnt"; do
@@ -47,7 +74,7 @@ else
done
if [ -z "$LOCATIONOK" ]; then
LOCATION="/data/data"
LOCATION="$DEFAULT_LOCATION"
bashio::log.fatal "Your data_location value can only be set in /share, /config, /data or /mnt. It will be reset to the default location : $LOCATION"
fi
fi
@@ -65,18 +92,66 @@ XDG_RUNTIME_DIR="/run/user/$PUID"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
for file in /etc/s6-overlay/s6-rc.d/*/run; do
if [ "$(sed -n '1{/bash/p};q' "$file")" ] && ! grep -q '^export XDG_CACHE_HOME=/tmp/cache$' "$file"; then
sed -i "1a export HOME=$LOCATION" "$file"
sed -i "1a export FM_HOME=$LOCATION" "$file"
sed -i "1a export XDG_CACHE_HOME=/tmp/cache" "$file"
fi
done
# Must agree with the CWS substitution in 90-ingress.sh: nginx proxies the Selkies data
# websocket to this port, and Selkies only listens on it if CUSTOM_WS_PORT reaches its process.
# Validated here, once, because the value goes on to be interpolated into generated shell and
# into a sed replacement in 90-ingress.sh, both of which take the normalised value back out of
# the envdir written below.
SELKIES_WS_PORT="${CUSTOM_WS_PORT:-8082}"
if ! [[ "$SELKIES_WS_PORT" =~ ^[0-9]+$ ]] || [ "$SELKIES_WS_PORT" -lt 1 ] || [ "$SELKIES_WS_PORT" -gt 65535 ]; then
bashio::log.warning "CUSTOM_WS_PORT '${CUSTOM_WS_PORT:-}' is not a valid port number; using 8082"
SELKIES_WS_PORT=8082
fi
for folders in /defaults /etc/cont-init.d /etc/services.d /etc/s6-overlay/s6-rc.d; do
if [ -d "$folders" ]; then
find "$folders" -type f -exec sed -i "s|/data/data|$LOCATION|g" {} + &> /dev/null || true
fi
# Upstream relies on s6-rc ordering: init-selkies-config publishes XDG_RUNTIME_DIR and
# CUSTOM_WS_PORT into the s6 envdir, and svc-selkies is started afterwards. The add-on
# entrypoint replaces s6-overlay and launches every s6-rc.d run script in parallel, with no
# dependency graph, so a longrun can snapshot the envdir (with-contenv reads it once, at exec)
# before the oneshot has written to it. Selkies is where that shows: it comes up with
# CUSTOM_WS_PORT unset and binds its data websocket on the 8081 default while nginx proxies
# 8082, and on the PIXELFLUX_WAYLAND images it reaches the compositor with no XDG_RUNTIME_DIR
# and panics with `RuntimeDirNotSet` binding the Wayland socket.
#
# Exporting both inside the run scripts puts them in each process's own environment, where no
# start ordering can lose them, and keeps every service agreeing on one runtime dir -- svc-de
# otherwise waits forever on a Wayland socket under a directory Selkies never used.
# Rewrite the home path baked into the image to the user-chosen one. No-op when data_location
# is left at its default. Runs before the exports below are injected, not after: this is a
# blind textual substitution, so a location *under* the image default (data_location
# /config/data_kde/foo against a /config/data_kde image) would otherwise rewrite the freshly
# injected "export HOME=/config/data_kde/foo" into ".../foo/foo".
if [ "$LOCATION" != "$DEFAULT_LOCATION" ]; then
for folders in /defaults /etc/cont-init.d /etc/services.d /etc/s6-overlay/s6-rc.d; do
if [ -d "$folders" ]; then
find "$folders" -type f -exec sed -i "s|$DEFAULT_LOCATION|$LOCATION|g" {} + &> /dev/null || true
fi
done
fi
# Re-derived on every boot rather than injected once behind a marker, for the same reason the
# ~/.bashrc block below is: the run scripts live in the writable layer and survive a restart, so
# a write-once injection pins whatever PUID and CUSTOM_WS_PORT were in force the first time.
# Raising PUID would leave every service exporting a /run/user/<old-uid> the remapped abc user
# cannot use, and clearing a custom CUSTOM_WS_PORT would leave Selkies on the old port while
# 90-ingress.sh moved nginx back to 8082. Strip whatever a previous boot left -- the marked
# block, or the bare exports earlier versions wrote -- then write the current values. No
# upstream run script in these images sets any of these five, so the bare-line sweep only ever
# removes our own.
ENV_BLOCK_BEGIN="# --- BEGIN ADDON ENV (managed) ---"
ENV_BLOCK_END="# --- END ADDON ENV (managed) ---"
for file in /etc/s6-overlay/s6-rc.d/*/run; do
[ -n "$(sed -n '1{/bash/p};q' "$file")" ] || continue
sed -i "/^${ENV_BLOCK_BEGIN}\$/,/^${ENV_BLOCK_END}\$/d" "$file"
sed -i -E '/^export (HOME|FM_HOME|XDG_CACHE_HOME|XDG_RUNTIME_DIR|CUSTOM_WS_PORT)=/d' "$file"
# Each "1a" lands at line 2 and pushes the previous one down, so this reads bottom-up.
sed -i "1a $ENV_BLOCK_END" "$file"
sed -i "1a export HOME=\"$LOCATION\"" "$file"
sed -i "1a export FM_HOME=\"$LOCATION\"" "$file"
sed -i "1a export XDG_CACHE_HOME=\"/tmp/cache\"" "$file"
sed -i "1a export XDG_RUNTIME_DIR=\"$XDG_RUNTIME_DIR\"" "$file"
sed -i "1a export CUSTOM_WS_PORT=\"$SELKIES_WS_PORT\"" "$file"
sed -i "1a $ENV_BLOCK_BEGIN" "$file"
done
sed -i "s|^\(abc:[^:]*:[^:]*:[^:]*:[^:]*:\)[^:]*|\1$LOCATION|" /etc/passwd
@@ -85,6 +160,7 @@ printf "%s" "$LOCATION" > "$S6_ENVDIR/HOME"
printf "%s" "$LOCATION" > "$S6_ENVDIR/FM_HOME"
printf "%s" "/tmp/cache" > "$S6_ENVDIR/XDG_CACHE_HOME"
printf "%s" "$XDG_RUNTIME_DIR" > "$S6_ENVDIR/XDG_RUNTIME_DIR"
printf "%s" "$SELKIES_WS_PORT" > "$S6_ENVDIR/CUSTOM_WS_PORT"
# Re-derived on every boot rather than gated on a "does it already say $LOCATION" grep: that
# guard only ever recognized the CURRENT $LOCATION, so a user who changed data_location and
# later changed it back left two stale HOME/FM_HOME exports in ~/.bashrc, with the last one
@@ -128,13 +204,16 @@ bashio::log.info "Setting ownership to $PUID:$PGID"
chown -R "${PUID}:${PGID}" "$LOCATION" /tmp/cache "$XDG_RUNTIME_DIR" /data
chmod -R 700 "$LOCATION"
# The base init-selkies-config script overrides XDG_RUNTIME_DIR to $HOME/.XDG, which lands
# on persistent storage and conflicts with the tmpfs runtime dir set above. Re-assert the
# tmpfs value at the end of that oneshot so the app and desktop agree on one valid dir.
# The base init-selkies-config script overrides XDG_RUNTIME_DIR to $HOME/.XDG, which lands on
# persistent storage and conflicts with the tmpfs runtime dir set above. Correct that write
# where it happens rather than re-asserting the value at the end of the oneshot: the tolerance
# block below appends `exit 0`, so on every boot after the first an appended correction sits
# past it and never runs.
SELKIES_CONFIG_RUN="/etc/s6-overlay/s6-rc.d/init-selkies-config/run"
if [ -f "$SELKIES_CONFIG_RUN" ]; then
# Drop the trailing correction earlier versions appended, now applied at the source.
sed -i '/^# XDG_RUNTIME_DIR override reconciled$/,+1d' "$SELKIES_CONFIG_RUN"
printf '\n# XDG_RUNTIME_DIR override reconciled\nprintf "%%s" "%s" > /run/s6/container_environment/XDG_RUNTIME_DIR\n' "$XDG_RUNTIME_DIR" >> "$SELKIES_CONFIG_RUN"
sed -i "s|^.*> */run/s6/container_environment/XDG_RUNTIME_DIR *\$|printf '%s' '$XDG_RUNTIME_DIR' > /run/s6/container_environment/XDG_RUNTIME_DIR|" "$SELKIES_CONFIG_RUN"
fi
# The Selkies desktop init oneshots do best-effort device/permission setup (mknod

View File

@@ -2,7 +2,11 @@
# shellcheck shell=bash
set -e
# The image is Debian-based (apt) and always ships uv, so those are the only installers used.
# Shared by every Selkies-based add-on in this repo (claude_desktop, webtop, webtop_kde) via a
# symlink; keep it add-on agnostic. Every option read here is guarded, so an add-on that does
# not declare a given option simply skips that block.
#
# All three images are Debian/Ubuntu-based, so apt is the only system package manager used.
if bashio::config.has_value 'additional_apps'; then
bashio::log.info "Installing additional apps :"
apt-get update -o Acquire::http::Timeout=10 -o Acquire::https::Timeout=10 &> /dev/null || bashio::log.warning "Unable to update apt package lists"
@@ -13,9 +17,14 @@ if bashio::config.has_value 'additional_apps'; then
fi
if bashio::config.has_value 'additional_pip'; then
if command -v uv &> /dev/null; then
pip_install=(uv pip install --system --break-system-packages)
else
pip_install=(pip install --break-system-packages)
fi
for p in $(bashio::config 'additional_pip' | tr ',' ' '); do
bashio::log.green "... pip: $p"
uv pip install --system --break-system-packages "$p" || bashio::log.fatal "Error: pip package $p failed"
"${pip_install[@]}" "$p" || bashio::log.fatal "Error: pip package $p failed"
done
fi

View File

@@ -0,0 +1,61 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# Claude Desktop is launched with --password-store=basic (see /defaults/autostart) so that no
# system keyring — and therefore no keyring password prompt — is ever needed. Electron does not
# accept that backend unless the application itself calls
# safeStorage.setUsePlainTextEncryption(true), and Claude Desktop never does, so without this
# patch safeStorage.isEncryptionAvailable() stays false: the auth token is never persisted and
# the user is asked to sign in again on every start.
#
# claude-safestorage-patch.js injects that opt-in into the app's main bundle inside app.asar.
# It runs on every boot, and after 81-claude_update.sh, on purpose: an apt upgrade of
# claude-desktop replaces app.asar with a fresh unpatched copy. The patcher is marker-guarded,
# so a boot where the app did not change is a cheap no-op.
#
# A failure here must never block startup — the app still runs fine unpatched, it just forgets
# the sign-in — so the patcher's exit status is reported, not propagated, and it is capped with
# a timeout so a pathological archive cannot stall the boot indefinitely.
ASAR="/usr/lib/claude-desktop/resources/app.asar"
PATCHER="/usr/local/bin/claude-safestorage-patch.js"
if [ ! -f "$ASAR" ]; then
bashio::log.warning "Claude Desktop app.asar not found; skipping the safeStorage patch"
exit 0
fi
if [ ! -f "$PATCHER" ]; then
bashio::log.warning "$PATCHER not found; skipping the safeStorage patch"
exit 0
fi
# The patcher removes its own temporary archive on every failure path, but `timeout` kills it
# outright, so a run that hits the cap leaves one behind. Those are archive-sized, and the live
# archive stays unpatched, so every later boot would retry under a new pid and strand another
# copy until the container runs out of space.
cleanup_tmp() {
find "$(dirname "$ASAR")" -maxdepth 1 -name ".$(basename "$ASAR").addon-tmp.*" -delete 2>/dev/null || true
}
if output=$(timeout 120 node "$PATCHER" "$ASAR" 2>&1); then
bashio::log.info "safeStorage: ${output}"
else
rc=$?
if [ "$rc" -eq 124 ]; then
bashio::log.warning "safeStorage patch timed out after 120s; continuing unpatched."
else
bashio::log.warning "safeStorage patch failed (exit ${rc}); the sign-in will not persist."
fi
while IFS= read -r line; do
if [ -n "$line" ]; then
bashio::log.warning "${line}"
fi
done <<< "${output}"
cleanup_tmp
fi
# Explicitly successful: a failed patch is never fatal, so the status of whatever ran last above
# must not become this script's status and abort the boot.
exit 0

View File

@@ -24,7 +24,15 @@ sed -i '/listen \[::\]/d' "${NGINX_CONFIG}"
# Adapt ports and upstream paths for Home Assistant ingress
sed -i "s|3000|$(bashio::addon.ingress_port)|g" "${NGINX_CONFIG}"
sed -i "s|SUBFOLDER|/|g" "${NGINX_CONFIG}"
sed -i "s|CWS|8082|g" "${NGINX_CONFIG}"
# Same value 20-folders.sh exports to the Selkies services; both must move together or nginx
# proxies the data websocket to a port nothing listens on. That script also normalises it into
# the s6 envdir, which this one picks up through with-contenv; the check is repeated so a
# malformed value cannot reach the nginx config if 20-folders.sh did not get that far.
CWS="${CUSTOM_WS_PORT:-8082}"
if ! [[ "$CWS" =~ ^[0-9]+$ ]] || [ "$CWS" -lt 1 ] || [ "$CWS" -gt 65535 ]; then
CWS=8082
fi
sed -i "s|CWS|${CWS}|g" "${NGINX_CONFIG}"
sed -i "s|REPLACE_HOME|${HOME:-/root}|g" "${NGINX_CONFIG}"
sed -i "s|REPLACE_DOWNLOADS_PATH|${HOME:-/config}|g" "${NGINX_CONFIG}"
sed -i '/proxy_buffering/a proxy_set_header Accept-Encoding "";' "${NGINX_CONFIG}"

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env node
/*
* Enable Electron safeStorage for Claude Desktop without a system keyring.
*
* Claude Desktop persists its auth token with Electron's safeStorage. On Linux that is gated
* on a backend: the libsecret backend needs a running Secret Service (gnome-keyring), which is
* intentionally not installed here because it prompts for a keyring password on first boot and
* blocks the app from launching. The app is therefore launched with --password-store=basic
* (Electron's built-in fixed-key store: no daemon, no prompt).
*
* That alone is not enough. Electron refuses the `basic_text` backend unless the *application*
* explicitly opts in by calling safeStorage.setUsePlainTextEncryption(true) before the app is
* ready, and Claude Desktop never calls it. So isEncryptionAvailable() stays false, the token is
* never persisted, and the user is asked to sign in again on every start. There is no equivalent
* command-line switch, and NODE_OPTIONS=--require is ignored by packaged Electron apps, so the
* opt-in has to be injected into the app's own main bundle.
*
* This script does that inside app.asar. It is idempotent (marker-guarded) and re-applied on
* every boot, because 81-claude_update.sh apt-upgrades claude-desktop and a new package ships a
* fresh, unpatched app.asar.
*
* Failure policy: refuse rather than guess. An unpatched app still runs, it just forgets the
* sign-in; a corrupted app.asar would not start at all. Every unexpected shape is a hard exit
* that leaves the original archive untouched.
*
* asar layout (all little-endian):
* [0] uint32 = 4 size of the next field
* [4] uint32 = headerBufLen size of the header pickle
* [8] uint32 = payloadSize 4 + headerString length, 4-byte aligned
* [12] uint32 = headerStrLen exact JSON length
* [16] utf8 = headerString JSON file tree, padded to a 4-byte boundary
* then file bodies; each node's "offset" is relative to the end of the header.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ASAR = process.argv[2] || '/usr/lib/claude-desktop/resources/app.asar';
const MARKER = 'CLAUDE_ADDON_SAFESTORAGE_PATCH';
const PATCH =
`/*${MARKER}*/try{require("electron").safeStorage.setUsePlainTextEncryption(true);}` +
`catch(e){try{console.error("[claude_desktop addon] safeStorage opt-in failed:",e&&e.message);}catch(_){}}`;
const log = (m) => process.stdout.write(`${m}\n`);
const fail = (m) => {
process.stderr.write(`${m}\n`);
process.exit(1);
};
const alignInt = (i, a) => i + ((a - (i % a)) % a);
const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
function readArchive(file) {
const buf = fs.readFileSync(file);
if (buf.length < 16 || buf.readUInt32LE(0) !== 4) fail(`Not an asar archive: ${file}`);
const headerBufLen = buf.readUInt32LE(4);
const payloadSize = buf.readUInt32LE(8);
const headerStrLen = buf.readUInt32LE(12);
const contentBase = 8 + headerBufLen;
if (headerBufLen !== 4 + payloadSize || payloadSize !== 4 + alignInt(headerStrLen, 4)) {
fail(`Corrupt asar header pickle in ${file}`);
}
if (16 + headerStrLen > buf.length || contentBase > buf.length) {
fail(`Corrupt asar: header extends past end of ${file}`);
}
let header;
try {
header = JSON.parse(buf.toString('utf8', 16, 16 + headerStrLen));
} catch (e) {
fail(`Corrupt asar: header is not valid JSON (${e.message})`);
}
return { buf, header, contentBase };
}
/* Every packed leaf, as [path, node]. Nodes flagged `unpacked` live in app.asar.unpacked/ and
* `link` nodes are symlinks — neither has bytes inside the archive, so both are carried through
* untouched and skipped here. */
function packedLeaves(header) {
const out = [];
(function walk(dir, prefix) {
for (const [name, node] of Object.entries(dir.files)) {
const p = prefix ? `${prefix}/${name}` : name;
if (node.files) walk(node, p);
else if (!node.unpacked && typeof node.link !== 'string') out.push([p, node]);
}
})(header, '');
return out;
}
/* Offsets are decimal strings and can exceed 2^31; reject anything that is not a plain, exact,
* in-bounds extent rather than letting parseInt("12junk") or NaN silently slice the wrong bytes. */
function extentOf(ar, node, where) {
if (!/^\d+$/.test(String(node.offset))) fail(`Bad offset for ${where}: ${node.offset}`);
const off = Number(node.offset);
const size = node.size;
if (!Number.isSafeInteger(off)) fail(`Offset out of safe range for ${where}`);
if (!Number.isSafeInteger(size) || size < 0) fail(`Bad size for ${where}: ${size}`);
const start = ar.contentBase + off;
const end = start + size;
if (end > ar.buf.length) fail(`Extent of ${where} runs past end of archive`);
return { start, end };
}
const bodyOf = (ar, node, where) => {
const { start, end } = extentOf(ar, node, where);
return ar.buf.subarray(start, end);
};
function resolve(header, relPath) {
let node = header;
for (const part of relPath.split('/')) {
if (!node.files || !node.files[part]) return null;
node = node.files[part];
}
return node;
}
/* Matches @electron/asar: whole-file hash plus one hash per blockSize chunk. An empty file has
* an empty block list, not a single block over zero bytes. */
function integrityOf(buf, blockSize) {
const blocks = [];
for (let i = 0; i < buf.length; i += blockSize) {
blocks.push(sha256(buf.subarray(i, Math.min(i + blockSize, buf.length))));
}
return { algorithm: 'SHA256', hash: sha256(buf), blockSize, blocks };
}
/* Insert the opt-in after the bundle's leading "use strict" directive. It must go *after* it: a
* directive prologue only takes effect as the very first statement, so prepending would silently
* drop the whole main process out of strict mode.
*
* Returns null — meaning "refuse to patch" — for anything that is not unambiguously a directive.
* `"use strict" + x` is an expression, not a directive, and injecting into it would produce a
* syntax error, so the directive is only accepted when it is terminated by its own semicolon, a
* line break, or end of input. */
function applyPatch(source) {
const m = /^\s*(['"])use strict\1(;?)/.exec(source);
if (!m) return null;
const rest = source.slice(m[0].length);
const terminated = m[2] === ';' || rest === '' || /^[\r\n]/.test(rest);
if (!terminated) return null;
// Supply the terminator when the directive relied on ASI; without it the injected code would
// continue the string-literal expression instead of following it.
const sep = m[2] === ';' ? '' : ';';
return source.slice(0, m[0].length) + sep + PATCH + rest;
}
function writeAll(fd, buf) {
let off = 0;
while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off);
}
/* Re-read the rebuilt archive from disk and prove it is sound before it replaces a working one.
* A short or truncated write late in the file would otherwise still pass a marker-only check,
* because the main bundle sits near the front. */
function verifyRebuilt(file, mainRel, expectedLeafCount) {
const ar = readArchive(file);
const leaves = packedLeaves(ar.header);
if (leaves.length !== expectedLeafCount) {
fail(`Rebuilt archive has ${leaves.length} packed entries, expected ${expectedLeafCount}`);
}
let maxEnd = ar.contentBase;
for (const [p, node] of leaves) {
const { end } = extentOf(ar, node, p);
if (end > maxEnd) maxEnd = end;
}
if (maxEnd !== ar.buf.length) {
fail(`Rebuilt archive is truncated or has ${ar.buf.length - maxEnd} trailing bytes`);
}
const mainNode = resolve(ar.header, mainRel);
if (!mainNode) fail(`Rebuilt archive lost its main entry ${mainRel}`);
const body = bodyOf(ar, mainNode, mainRel);
if (!body.toString('utf8').includes(MARKER)) fail('Rebuilt archive is missing the patch marker');
if (mainNode.integrity && sha256(body) !== mainNode.integrity.hash) {
fail('Rebuilt archive has a stale integrity hash for the main entry');
}
}
function main() {
if (!fs.existsSync(ASAR)) fail(`Missing ${ASAR}`);
const ar = readArchive(ASAR);
const pkgNode = resolve(ar.header, 'package.json');
if (!pkgNode) fail('app.asar has no package.json');
const pkg = JSON.parse(bodyOf(ar, pkgNode, 'package.json').toString('utf8'));
const mainRel = pkg.main;
if (!mainRel) fail('package.json has no "main" entry');
const mainNode = resolve(ar.header, mainRel);
if (!mainNode) fail(`main entry not found in archive: ${mainRel}`);
if (mainNode.unpacked) fail(`main entry ${mainRel} is unpacked; refusing to patch`);
if (pkg.type === 'module') fail(`main entry ${mainRel} is ESM; this patcher emits CommonJS`);
const original = bodyOf(ar, mainNode, mainRel).toString('utf8');
if (original.includes(MARKER)) {
log(`Already patched: ${mainRel}`);
return;
}
const patchedSource = applyPatch(original);
if (patchedSource === null) {
fail(`${mainRel} does not begin with a recognized "use strict" directive; refusing to patch`);
}
const patched = Buffer.from(patchedSource, 'utf8');
/* Rebuild: copy every packed body in tree order, substituting the patched main entry, and
* reassign offsets as we go. asar headers store a byte offset and length per file, so content
* cannot simply grow in place. */
const leaves = packedLeaves(ar.header);
const chunks = [];
let offset = 0;
let patchedCount = 0;
for (const [p, node] of leaves) {
const isMain = node === mainNode;
const body = isMain ? patched : bodyOf(ar, node, p);
node.offset = String(offset);
node.size = body.length;
if (isMain && node.integrity) {
node.integrity = integrityOf(body, node.integrity.blockSize || 4 * 1024 * 1024);
}
offset += body.length;
chunks.push(body);
if (isMain) patchedCount++;
}
if (patchedCount !== 1) fail(`expected to rewrite exactly 1 main entry, rewrote ${patchedCount}`);
const headerString = JSON.stringify(ar.header);
const strLen = Buffer.byteLength(headerString);
const payloadSize = 4 + alignInt(strLen, 4);
const headerBufLen = 4 + payloadSize;
const prefix = Buffer.alloc(16 + alignInt(strLen, 4));
prefix.writeUInt32LE(4, 0);
prefix.writeUInt32LE(headerBufLen, 4);
prefix.writeUInt32LE(payloadSize, 8);
prefix.writeUInt32LE(strLen, 12);
prefix.write(headerString, 16, 'utf8');
/* Write beside the target and rename, so an interrupted run can never leave a torn app.asar.
* The temp name carries the pid so two runs cannot share it, and it is removed on every
* failure path before the rename. */
const dir = path.dirname(ASAR);
const tmp = path.join(dir, `.${path.basename(ASAR)}.addon-tmp.${process.pid}`);
const mode = fs.statSync(ASAR).mode & 0o7777;
try {
const out = fs.openSync(tmp, 'wx', mode);
try {
writeAll(out, prefix);
for (const c of chunks) writeAll(out, c);
fs.fsyncSync(out); // durable before it becomes the live archive
} finally {
fs.closeSync(out);
}
fs.chmodSync(tmp, mode);
verifyRebuilt(tmp, mainRel, leaves.length);
fs.renameSync(tmp, ASAR);
} catch (e) {
try {
fs.unlinkSync(tmp);
} catch (_) {
/* nothing to clean up */
}
fail(`Rebuild failed, original left untouched: ${e.message}`);
}
/* Sync the directory so the rename itself survives a crash, not just the file's contents. */
try {
const dfd = fs.openSync(dir, 'r');
fs.fsyncSync(dfd);
fs.closeSync(dfd);
} catch (_) {
/* best effort */
}
log(`Patched ${mainRel} in ${ASAR} (safeStorage plain-text opt-in)`);
}
main();

View File

@@ -1,3 +1,7 @@
## 3.3.0.1 (2026-07-28)
- Fixed searches failing through ingress with `500 Internal Server Error` (#2906, #2646). Home Assistant's ingress proxy re-encodes the query string and passes a space as `+`, along with a bare `:` `/` `?` `@` `!` `$` `'` `(` `)` `*` `,` - all of which Seerr's OpenAPI validator rejects as reserved characters. Nginx now re-encodes them before proxying, so titles such as `Monsters, Inc.`, `Ocean's Eleven`, `Mission: Impossible` and `Who? What?` search correctly. Only ingress was affected; the directly published port 5055 always worked.
## 3.3.0 (2026-06-05)
- Update to latest version from seerr-team/seerr (changelog : https://github.com/seerr-team/seerr/releases)

View File

@@ -56,7 +56,7 @@ COPY ha_automodules.sh /ha_automodules.sh
RUN chmod 744 /ha_automodules.sh && /ha_automodules.sh "$MODULES" && rm /ha_automodules.sh
# Manual apps
ENV PACKAGES="nginx"
ENV PACKAGES="nginx nginx-mod-http-js"
# Automatic apps & bashio
COPY ha_autoapps.sh /ha_autoapps.sh

View File

@@ -96,4 +96,4 @@ schema:
slug: seerr
udev: true
url: https://github.com/alexbelgium/hassio-addons/tree/master/seerr
version: "3.3.0"
version: "3.3.0.1"

View File

@@ -0,0 +1,14 @@
# Load the njs module used by njs/ingress.js.
#
# The nginx-mod-http-js package ships this same file, but it cannot be relied
# on: .templates/ha_automatic_packages.sh moves the rootfs /etc/nginx aside to
# /etc/nginx2 before installing nginx, then does `rm -r /etc/nginx` and restores
# the saved tree afterwards. That deletes anything the package placed under
# /etc/nginx, including its own load_module snippet, and nginx would then fail
# to start with "unknown directive js_import".
#
# Shipping it in the rootfs makes it survive that swap. The filename matches the
# package's on purpose: if the package's copy is ever kept instead, it overwrites
# this one rather than adding a second load_module for the same module, which
# nginx rejects as a duplicate.
load_module /usr/lib/nginx/modules/ngx_http_js_module.so;

View File

@@ -49,6 +49,11 @@ http {
'' close;
}
# Repair the query string mangled by Supervisor's ingress proxy before it
# reaches Seerr's OpenAPI validator. See njs/ingress.js for the full story.
js_import ingress from /etc/nginx/njs/ingress.js;
js_set $ingress_uri ingress.uri;
include /etc/nginx/includes/resolver.conf;
include /etc/nginx/includes/upstream.conf;

View File

@@ -0,0 +1,83 @@
/*
* Repair the query string of a Home Assistant ingress request.
*
* Supervisor proxies ingress traffic with `params=request.query`
* (supervisor/api/ingress.py), so aiohttp/yarl re-encodes an already-decoded
* query string on the way to this add-on. yarl's "safe" set is much wider than
* the one Seerr's express-openapi-validator will accept: yarl emits a space as
* "+" and passes ":", "/", "?", "@", "!", "$", "'", "(", ")", "*" and ","
* through bare, while the validator checks the raw, still-encoded value against
*
* RESERVED_CHARS = /[\:\/\?#\[\]@!\$&\'()\*\+,;=]/
*
* and answers 400 "Parameter '<name>' must be url encoded". Every search for a
* title containing a space or punctuation therefore fails - "Monsters, Inc.",
* "Ocean's Eleven", "Mission: Impossible" - which Seerr's UI reports as a
* 500. The same requests succeed on the directly published port 5055, which
* does not pass through Supervisor.
*
* "?" deserves a note: the validator strips one with `qs.replace('?', '')`
* before testing, so a single bare "?" slips through by accident and only a
* second one ("Who? What?") produces the 400. It is encoded here regardless.
*
* Re-encoding those characters here is lossless, because yarl only ever emits
* them bare when they were literal characters of the value: anything the user
* actually typed that is ambiguous comes through already percent-encoded
* (a typed "+" arrives as "%2B", "&" as "%26", "=" as "%3D").
*
* "&" and "=" are deliberately NOT re-encoded: they are the query string's own
* separators, so a bare one is always structural.
*/
/*
* Every character of the validator's RESERVED_CHARS except "&" and "=", which
* are the query string's own separators and are handled above. Deriving the
* set from what the validator rejects - rather than from what yarl currently
* emits bare - keeps this correct if either side changes its safe set.
*/
var NEEDS_ENCODING = /[:\/?#\[\]@!$'()*,;]/g;
function encodePart(part) {
return part
/* yarl encodes a space as "+"; a literal "+" arrives as "%2B". */
.replace(/\+/g, "%20")
.replace(NEEDS_ENCODING, function (c) {
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
});
}
/*
* Returns the request URI with the path untouched byte-for-byte and only the
* query string repaired. Used as the proxy_pass target.
*/
function uri(r) {
var raw = r.variables.request_uri;
var split = raw.indexOf("?");
if (split < 0) {
return raw;
}
var path = raw.substring(0, split);
var args = raw.substring(split + 1);
/* A bare trailing "?" is forwarded as-is, so the URI stays byte-for-byte. */
if (args === "") {
return raw;
}
var repaired = args
.split("&")
.map(function (pair) {
var eq = pair.indexOf("=");
if (eq < 0) {
return encodePart(pair);
}
return encodePart(pair.substring(0, eq)) + "=" + encodePart(pair.substring(eq + 1));
})
.join("&");
return path + "?" + repaired;
}
export default { uri };

View File

@@ -10,9 +10,11 @@ server {
location ^~ / {
set $app '%%ingress_entry%%';
# Forward the raw request URI exactly as received by this nginx.
# This is the safest way to preserve query-string encoding.
proxy_pass http://127.0.0.1:5055$request_uri;
# Forward the request URI with the path byte-for-byte as received, and
# the query string re-encoded so the characters Supervisor's ingress
# proxy passes through bare (a space as "+", plus ":/@!$'()*,") do not
# trip Seerr's OpenAPI validator. See njs/ingress.js for the details.
proxy_pass http://127.0.0.1:5055$ingress_uri;
proxy_http_version 1.1;
proxy_set_header Referer $http_referer;

View File

@@ -1,3 +1,13 @@
## 4.16-r0-ls95-7 (28-07-2026)
- Fix Selkies dying with a Rust `RuntimeDirNotSet` unwrap panic just after `Data WebSocket Server listening on port 8081`, and the data websocket then being proxied to the wrong port. Upstream relies on s6-rc ordering: `init-selkies-config` publishes `XDG_RUNTIME_DIR` and `CUSTOM_WS_PORT` into the s6 envdir and `svc-selkies` starts afterwards. The add-on entrypoint replaces s6-overlay and starts every `s6-rc.d` run script in parallel with no dependency graph, so Selkies can snapshot the envdir before that oneshot has written to it -- which is why it bound port 8081 (its own default) instead of the 8082 nginx proxies to, and why its Wayland compositor found no runtime directory to bind a socket in. `20-folders.sh` now exports both variables inside each run script, where no start ordering can lose them, and corrects the base image's `$HOME/.XDG` override where that write happens instead of appending a correction after the `exit 0` that the oneshot-tolerance block adds -- which meant the correction never ran on any boot after the first.
- Microsoft Edge install: `apt-get` and `dpkg` failures no longer abort container startup -- a transient mirror failure or a bad download now logs a warning and leaves the desktop running without Edge, and apt acquisition is bounded so a stalled mirror cannot hang start-up. The post-install wrapper swap is now gated on the helper still being present, so a second run cannot move the installed wrapper aside with nothing left to replace it.
## 4.16-r0-ls95-6 (28-07-2026)
- Share the Selkies startup scripts with the `claude_desktop` add-on by symlink (`20-folders.sh`, `21-gpu_permissions.sh`, `80-configuration.sh`, `90-ingress.sh` and the nginx includes), so the fixes made there now apply here too. This brings in: GPU render-node permissions granted before the graphical services start (fixes `libEGL warning: failed to open /dev/dri/card0: Permission denied` and the resulting "waiting for stream" hang); the s6 envdir and `XDG_RUNTIME_DIR` created up front; the cache redirected to tmpfs; `/tmp/.X11-unix` pre-created so Xorg can bind its socket as a non-root user; the `init-video` and `init-selkies-config` oneshots made non-fatal so a partially permitted device setup no longer crash-loops the add-on; and an ingress config that keeps the correct (non-SSL) nginx server block. The Microsoft Edge install moves to its own webtop-only `81-microsoft_edge.sh`, which also picks up the ownership fixup that previously ran in `20-folders.sh` before Edge was installed and so never matched anything.
- Added support for configuring extra environment variables via the `env_vars` add-on option alongside config.yaml. See https://github.com/alexbelgium/hassio-addons/wiki/Add-Environment-variables-to-your-Addon-2 for details.
## 4.16-r0-ls95-5 (2026-02-23)

View File

@@ -138,5 +138,5 @@ slug: webtop-kde
tmpfs: true
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: 4.16-r0-ls95-5
version: 4.16-r0-ls95-7
video: true

View File

@@ -1,9 +1,13 @@
## 4.16-r0-ls93.1 (2026-07-28)
- Fix crash loop on start: make the `init-video` and `init-selkies-config` oneshots
tolerant of their best-effort device/permission setup failing in the HA add-on
sandbox, so a non-zero exit no longer crash-loops the container and `svc-selkies`
(closes #2918)
## 4.16-r0-ls93.2 (28-07-2026)
- Fix Selkies dying with a Rust `RuntimeDirNotSet` unwrap panic just after `Data WebSocket Server listening on port 8081`, and the data websocket then being proxied to the wrong port. Upstream relies on s6-rc ordering: `init-selkies-config` publishes `XDG_RUNTIME_DIR` and `CUSTOM_WS_PORT` into the s6 envdir and `svc-selkies` starts afterwards. The add-on entrypoint replaces s6-overlay and starts every `s6-rc.d` run script in parallel with no dependency graph, so Selkies can snapshot the envdir before that oneshot has written to it -- which is why it bound port 8081 (its own default) instead of the 8082 nginx proxies to, and why its Wayland compositor found no runtime directory to bind a socket in. `20-folders.sh` now exports both variables inside each run script, where no start ordering can lose them, and corrects the base image's `$HOME/.XDG` override where that write happens instead of appending a correction after the `exit 0` that the oneshot-tolerance block adds -- which meant the correction never ran on any boot after the first.
- Microsoft Edge install: `apt-get` and `dpkg` failures no longer abort container startup -- a transient mirror failure or a bad download now logs a warning and leaves the desktop running without Edge, and apt acquisition is bounded so a stalled mirror cannot hang start-up. The post-install wrapper swap is now gated on the helper still being present, so a second run cannot move the installed wrapper aside with nothing left to replace it.
## 4.16-r0-ls93.1 (28-07-2026)
- Share the Selkies startup scripts with the `claude_desktop` add-on by symlink (`20-folders.sh`, `21-gpu_permissions.sh`, `80-configuration.sh`, `90-ingress.sh` and the nginx includes), so the fixes made there now apply here too. This brings in: GPU render-node permissions granted before the graphical services start (fixes `libEGL warning: failed to open /dev/dri/card0: Permission denied` and the resulting "waiting for stream" hang); the s6 envdir and `XDG_RUNTIME_DIR` created up front; the cache redirected to tmpfs; `/tmp/.X11-unix` pre-created so Xorg can bind its socket as a non-root user; the `init-video` and `init-selkies-config` oneshots made non-fatal so a partially permitted device setup no longer crash-loops the add-on; and an ingress config that keeps the correct (non-SSL) nginx server block. The Microsoft Edge install moves to its own webtop-only `81-microsoft_edge.sh`, which also picks up the ownership fixup that previously ran in `20-folders.sh` before Edge was installed and so never matched anything.
## 4.16-r0-ls93 (2026-07-21)
- Update to latest version from linuxserver/docker-webtop (changelog : https://github.com/linuxserver/docker-webtop/releases)

View File

@@ -143,5 +143,5 @@ slug: webtop
tmpfs: true
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "4.16-r0-ls93"
version: "4.16-r0-ls93.2"
video: true

View File

@@ -1,96 +0,0 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
# shellcheck disable=SC2046
set -e
# Define user
PUID=$(bashio::config "PUID")
PGID=$(bashio::config "PGID")
# Set user for microsoft edge if available
if [ -f /usr/bin/microsoft-edge-real ]; then
chown "$PUID:$PGID" /usr/bin/microsoft-edge*
chmod +x /usr/bin/microsoft-edge*
fi
# Check data location
LOCATION=$(bashio::config 'data_location')
if [[ "$LOCATION" = "null" || -z "$LOCATION" ]]; then
# Default location
LOCATION="/config/data_kde"
else
# Check if config is located in an acceptable location
LOCATIONOK=""
for location in "/share" "/config" "/data" "/mnt"; do
if [[ "$LOCATION" == "$location"* ]]; then
LOCATIONOK=true
fi
done
if [ -z "$LOCATIONOK" ]; then
LOCATION="/config/data_kde"
bashio::log.fatal "Your data_location value can only be set in /share, /config or /data (internal to addon). It will be reset to the default location : $LOCATION"
fi
fi
# Set data location
bashio::log.info "Setting data location to $LOCATION"
# Correct home locations
for file in /etc/s6-overlay/s6-rc.d/*/run; do
if [ "$(sed -n '1{/bash/p};q' "$file")" ]; then
sed -i "1a export HOME=$LOCATION" "$file"
sed -i "1a export FM_HOME=$LOCATION" "$file"
fi
done
# Correct home location
for folders in /defaults /etc/cont-init.d /etc/services.d /etc/s6-overlay/s6-rc.d; do
if [ -d "$folders" ]; then
sed -i "s|/config/data_kde|$LOCATION|g" $(find "$folders" -type f) &> /dev/null || true
fi
done
# Change user home
sed -i "s|^\(abc:[^:]*:[^:]*:[^:]*:[^:]*:\)[^:]*|\1$LOCATION|" /etc/passwd
#usermod --home "$LOCATION" abc || true
# Add environment variables
if [ -d /var/run/s6/container_environment ]; then printf "%s" "$LOCATION" > /var/run/s6/container_environment/HOME; fi
if [ -d /var/run/s6/container_environment ]; then printf "%s" "$LOCATION" > /var/run/s6/container_environment/FM_HOME; fi
{
printf "%s\n" "export HOME=\"$LOCATION\""
printf "%s\n" "export FM_HOME=\"$LOCATION\""
} >> ~/.bashrc
# Create folder
echo "Creating $LOCATION"
mkdir -p "$LOCATION"
# Create cache
mkdir -p /.cache
chmod 755 /.cache
if [ -d "/config/.cache" ]; then
cp -rf /config/.cache /.cache
rm -r /config/.cache
fi
ln -sf /config/.cache /.cache
# Set ownership
bashio::log.info "Setting ownership to $PUID:$PGID"
chown -R "$PUID":"$PGID" "$LOCATION"
chmod -R 700 "$LOCATION"
# The Selkies desktop init oneshots do best-effort device/permission setup (mknod
# /dev/input/*, chmod /tmp/selkies*, /dev/dri perms) that is only partially permitted in the
# HA add-on sandbox. A non-zero exit from a oneshot fails add-on bringup and crash-loops the
# container, so make these two tolerant and always report success. Longruns (svc-*) are left
# untouched so s6 keeps supervising them with their real exit codes.
for oneshot in init-video init-selkies-config; do
run="/etc/s6-overlay/s6-rc.d/$oneshot/run"
if [ -f "$run" ] && ! grep -q '^set +e$' "$run"; then
sed -i "1a set +e" "$run"
printf '\nexit 0\n' >> "$run"
fi
done

View File

@@ -0,0 +1 @@
../../../../claude_desktop/rootfs/etc/cont-init.d/20-folders.sh

View File

@@ -0,0 +1 @@
../../../../claude_desktop/rootfs/etc/cont-init.d/21-gpu_permissions.sh

View File

@@ -1,72 +0,0 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
# shellcheck disable=SC2015
set -e
# Install specific apps
if bashio::config.has_value 'additional_apps'; then
bashio::log.info "Installing additional apps :"
# hadolint ignore=SC2005
NEWAPPS=$(bashio::config 'additional_apps')
for packagestoinstall in ${NEWAPPS//,/ }; 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" &> /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
done
fi
# Set TZ
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")
# Set keyboard
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
printf "%s\n" "KEYBOARD=\"$KEYBOARD\"" >> ~/.bashrc
fi || true
# Set password
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
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"
fi
# Set password
if bashio::config.true 'install_ms_edge'; then
bashio::log.info "Adding microsoft edge"
# Install edge
apt-get update
echo "**** install edge ****"
apt-get install --no-install-recommends -y ca-certificates
if [ -z ${EDGE_VERSION+x} ]; then
EDGE_VERSION=$(curl -sL https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-stable/ \
| awk -F'(<a href="microsoft-edge-stable_|_amd64.deb\")' '/href=/ {print $2}' | sort --version-sort | tail -1)
fi
curl -o /tmp/edge.deb -L "https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-stable/microsoft-edge-stable_${EDGE_VERSION}_amd64.deb"
dpkg -I /tmp/edge.deb
apt-get install --no-install-recommends -y /tmp/edge.deb
echo "**** edge docker tweaks ****"
if [ -f /usr/bin/microsoft-edge-stable ]; then
mv /usr/bin/microsoft-edge-stable /usr/bin/microsoft-edge-real
else
mv /usr/bin/microsoft-edge /usr/bin/microsoft-edge-real
fi
mv /helpers/microsoft-edge-stable /usr/bin/
fi

View File

@@ -0,0 +1 @@
../../../../claude_desktop/rootfs/etc/cont-init.d/80-configuration.sh

View File

@@ -0,0 +1,86 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# Webtop-only. Lives here rather than in the shared 80-configuration.sh so the Selkies startup
# scripts stay identical across claude_desktop, webtop and webtop_kde. It also carries the
# ownership fixup that used to sit in 20-folders.sh, which ran before this install and so
# never had anything to match.
if ! bashio::config.true 'install_ms_edge'; then
exit 0
fi
# Every step below is bounded and non-fatal. cont-init.d blocks the whole add-on, so an
# unreachable or stalled packages.microsoft.com -- or a Debian mirror having a bad day -- must
# not hang or kill startup: the desktop is useful without Edge, an add-on stuck before Selkies
# starts is not. `set -e` would turn any apt or dpkg hiccup into exactly that, so each command
# is guarded and every failure path warns and exits 0.
EDGE_DEB=""
edge_giveup() {
bashio::log.warning "$1; skipping the Microsoft Edge install"
if [ -n "$EDGE_DEB" ]; then
rm -f "$EDGE_DEB"
fi
exit 0
}
bashio::log.info "Adding microsoft edge"
# -o Acquire::*Timeout bounds the mirror handshake/transfer the same way --max-time bounds curl.
APT_TIMEOUTS=(-o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 -o Acquire::Retries=1)
apt-get "${APT_TIMEOUTS[@]}" update || edge_giveup "apt-get update failed"
apt-get "${APT_TIMEOUTS[@]}" install --no-install-recommends -y ca-certificates \
|| edge_giveup "Installing ca-certificates failed"
EDGE_REPO="https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-stable"
if [ -z "${EDGE_VERSION+x}" ]; then
EDGE_VERSION=$(curl -sL --fail --connect-timeout 15 --max-time 120 "$EDGE_REPO/" \
| awk -F'(<a href="microsoft-edge-stable_|_amd64.deb\")' '/href=/ {print $2}' | sort --version-sort | tail -1 || true)
fi
if [ -z "$EDGE_VERSION" ]; then
edge_giveup "Could not determine the latest Microsoft Edge version"
fi
# mktemp rather than a fixed /tmp/edge.deb: this runs as root, and a predictable name in a
# world-writable tmpfs is something another process could pre-create as a symlink to redirect
# the download or swap what gets installed.
EDGE_DEB="$(mktemp -t microsoft-edge.XXXXXXXXXX.deb)" || edge_giveup "Could not create a temporary file"
curl -o "$EDGE_DEB" -L --fail --connect-timeout 15 --max-time 600 \
"$EDGE_REPO/microsoft-edge-stable_${EDGE_VERSION}_amd64.deb" \
|| edge_giveup "Downloading Microsoft Edge ${EDGE_VERSION} failed"
dpkg -I "$EDGE_DEB" || edge_giveup "The downloaded Microsoft Edge package is not a valid .deb"
apt-get "${APT_TIMEOUTS[@]}" install --no-install-recommends -y "$EDGE_DEB" \
|| edge_giveup "Installing Microsoft Edge ${EDGE_VERSION} failed"
rm -f "$EDGE_DEB"
bashio::log.info "Applying edge docker tweaks"
# Gated on the helper still being in /helpers, which is where the image ships it and where it
# stops being once installed. Without the guard a second run would move the wrapper already
# sitting in /usr/bin aside as "-real" with nothing left to take its place, and Edge would
# stop launching.
if [ -f /helpers/microsoft-edge-stable ]; then
if [ -f /usr/bin/microsoft-edge-stable ]; then
mv /usr/bin/microsoft-edge-stable /usr/bin/microsoft-edge-real
elif [ -f /usr/bin/microsoft-edge ]; then
mv /usr/bin/microsoft-edge /usr/bin/microsoft-edge-real
fi
mv /helpers/microsoft-edge-stable /usr/bin/
fi
# The wrapper and the real binary must be usable by the desktop user, whose identity
# 20-folders.sh has already settled by the time this runs. Guarded against an empty glob:
# without nullglob the literal pattern would reach chown, and `set -e` would then abort
# container startup rather than just skipping a fixup that has nothing to do.
shopt -s nullglob
edge_binaries=(/usr/bin/microsoft-edge*)
shopt -u nullglob
if [ "${#edge_binaries[@]}" -gt 0 ]; then
chown "$(id -u abc):$(id -g abc)" "${edge_binaries[@]}"
chmod +x "${edge_binaries[@]}"
else
bashio::log.warning "Edge install reported success but no /usr/bin/microsoft-edge* binary is present"
fi

View File

@@ -1,28 +0,0 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# nginx Path
NGINX_CONFIG=/etc/nginx/sites-available/ingress.conf
SUBFOLDER="$(bashio::addon.ingress_entry)"
# Copy template
cp /defaults/default.conf "${NGINX_CONFIG}"
# Remove ssl part
awk -v n=4 '/server/{n--}; n > 0' "${NGINX_CONFIG}" > tmpfile
mv tmpfile "${NGINX_CONFIG}"
# Remove ipv6
sed -i '/listen \[::\]/d' "${NGINX_CONFIG}"
# Add ingress parameters
sed -i "s|3000|$(bashio::addon.ingress_port)|g" "${NGINX_CONFIG}"
sed -i "s|CWS|8082|g" "${NGINX_CONFIG}"
sed -i "s|SUBFOLDER|/|g" "${NGINX_CONFIG}"
sed -i '/proxy_buffering/a proxy_set_header Accept-Encoding "";' "${NGINX_CONFIG}"
sed -i '/proxy_buffering/a sub_filter_once off;' "${NGINX_CONFIG}"
sed -i '/proxy_buffering/a sub_filter_types *;' "${NGINX_CONFIG}"
sed -i '/proxy_buffering/a sub_filter "vnc/index.html?autoconnect" "vnc/index.html?path=%%path%%/websockify?autoconnect";' "${NGINX_CONFIG}"
sed -i "s|%%path%%|${SUBFOLDER:1}|g" "${NGINX_CONFIG}"
# Enable ingress
cp "${NGINX_CONFIG}" /etc/nginx/sites-enabled

View File

@@ -0,0 +1 @@
../../../../claude_desktop/rootfs/etc/cont-init.d/90-ingress.sh

View File

@@ -1,96 +0,0 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
font/woff woff;
font/woff2 woff2;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}

View File

@@ -0,0 +1 @@
../../../../../claude_desktop/rootfs/etc/nginx/includes/mime.types

View File

@@ -1,15 +0,0 @@
proxy_http_version 1.1;
proxy_ignore_client_abort off;
proxy_read_timeout 86400s;
proxy_redirect off;
proxy_send_timeout 86400s;
proxy_max_temp_file_size 0;
proxy_set_header Accept-Encoding "";
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -0,0 +1 @@
../../../../../claude_desktop/rootfs/etc/nginx/includes/proxy_params.conf

View File

@@ -1 +0,0 @@
resolver 127.0.0.11 ipv6=off;

View File

@@ -0,0 +1 @@
../../../../../claude_desktop/rootfs/etc/nginx/includes/resolver.conf

View File

@@ -1,6 +0,0 @@
root /dev/null;
server_name $hostname;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header X-Robots-Tag none;

View File

@@ -0,0 +1 @@
../../../../../claude_desktop/rootfs/etc/nginx/includes/server_params.conf

View File

@@ -1,9 +0,0 @@
ssl_protocols TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:DHE-RSA-AES256-SHA;
ssl_ecdh_curve secp384r1;
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;

View File

@@ -0,0 +1 @@
../../../../../claude_desktop/rootfs/etc/nginx/includes/ssl_params.conf

View File

@@ -1,3 +0,0 @@
upstream backend {
server 127.0.0.1:8080;
}

View File

@@ -0,0 +1 @@
../../../../../claude_desktop/rootfs/etc/nginx/includes/upstream.conf