430 Commits

Author SHA1 Message Date
librelad
647b19cf4a restore: ask the repository about a snapshot by id, not by app tag
storageSnapshotSourcePath resolved a snapshot's source path with

    engineSnapshotsJson "$idx" "$snapshot_id"

but that function's second parameter is an app TAG filter. So it ran
`restic snapshots --tag app=<snapshot-id>`, matched nothing, and returned 1 —
every time, for every snapshot, since the file was written.

Nothing broke loudly, because both callers have a fallback:

  * storageRestoreAppTo fell through to "restoring in place", reinstating the
    exact cross-root bug the file exists to fix — restoring onto a host whose
    containers root differs from the source's matched no include path and
    restored nothing, silently
  * the first-run preflight never read a manifest, so every app reported size
    "?" and its fit and location checks passed unconditionally. Thirteen green
    ticks that had checked nothing.

Add engineSnapshotPaths: restic answers it with a positional snapshot id, kopia
by filtering its list. borg has no adapter on purpose — it rebuilds its listing
from archive metadata that carries no paths — so a missing adapter is a quiet
"no" and those callers keep their in-place fallback.

Add scripts/dev/lp-preflight-test, which pins the cases that must say NO: an
app too big for the disk, one this version no longer ships, one whose storage
location is gone, and a resolver that reaches for the app-tag filter again.
Verified against both historical bugs — reintroducing either fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:15:15 +01:00
librelad
f8c9e87643 backup: create a location's config as the manager, not the container user
backupLocationEnsureDir and the config write both went through runFileOp /
runFileWrite, which run as the container user. Backup location configs live
under the system tree, which is owned by the manager — so the mkdir was denied,
the write then failed with "No such file or directory", and locationAdd still
printed "Location N added".

The result was a location that existed in name only: every later command that
sourced its config found nothing. It surfaced in the first-run restore path,
where the installer adds the location it is about to read from and then fails
with "Backup location 2 has no config".

Use runInstallOp/runInstallWrite, which run as the manager and can write there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:15:15 +01:00
librelad
1381b052ae fix(app): make export/import actually work — found by a real round trip
Ran export -> uninstall -> import on trivy against the live install. It
worked end to end (1.3G app, marker file byte-identical afterwards,
container running, database status correct, tree owned by the container
user) but only after three real bugs, none of which syntax checks or
isolated tests would have caught.

Export wrote the tarball as the CONTAINER user, because tar has to read
app data holding sub-UIDs the manager cannot. That meant the container
user also had to be able to create the destination file, which fails for
any normal destination. Now tar writes to stdout and the caller's shell
creates the file: reading uses the privileges that need it, writing uses
the caller's. Import had the mirror-image bug — tar extracted as the
container user and so could not READ a manager-owned .lpapp; the caller
now opens it and tar reads stdin.

Export also failed at tar time with no hint that the destination was the
problem, so it checks the directory exists and is writable up front.

The third one was quiet and worse. The manifest is pretty-printed, so it
reads `"size_bytes": 1324973614` — with a space that a `"key":[0-9]*`
pattern does not match. Both size_bytes and storage.location came back
empty everywhere they were read, which turned "will it fit" and "does
that location still exist" into checks that always passed. That is the
failure mode preflight exists to prevent, hiding inside preflight itself.
Fixed in app_portable.sh and restore_preflight.sh.

Verified afterwards with crafted manifests: an app claiming 8 TB is now
refused on an 800 GB disk ("Needs 8192G, 806G free"), and one naming a
location this machine lacks warns and names the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:33:52 +01:00
librelad
aa44e0b542 feat(setup): Import step — bring apps in from .lpapp files by path
Adds the wizard step for importing existing apps, so the common case is
answerable in the WebUI rather than only from a terminal.

Path-based, not upload, and that is the design rather than a shortcut. A
.lpapp is a plain tarball and the file is already on the server, so
nothing secret crosses into the browser — which is exactly why this can
live in the WebUI when the encrypted-repository restore cannot (§4.1).
Accepts a single file or a folder of them.

Check first, then accept: the step enqueues `app import-check --publish`,
polls the document it writes, and renders one row per file with its
verdict — ready, a warning (its old storage location is gone, so it will
land on the default), or a refusal (already installed, no longer shipped,
will not fit). Refused rows are shown greyed with the reason rather than
hidden, and cannot be selected.

setupApplyConfig re-runs appImport's own checks rather than trusting the
payload: the machine can change between the check and the apply, and the
list arrives from a browser.

The backend route shell-quotes the path — it reaches a command line and
is user input.

Verified: the step renders as "Step 6 of 7", and the underlying check was
proven against real .lpapp files (correct app name from the tar, size
from the manifest, warning for a missing storage location, refusals for
an already-installed app and a non-export).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:10:13 +01:00
librelad
da0136149b feat(app): import-check — inspect an export without importing it
`libreportal app import-check <file-or-directory>` reports what an import
would do, as one JSON object per file, without touching anything. It runs
the same checks appImport makes — app still shipped, not already
installed, fits on the target, storage location still exists — so a UI can
show them and ask for acceptance before acting.

This is what makes a path-based import safe to drive from the WebUI when
the repository restore is not. A .lpapp is a plain tarball, not encrypted,
so there is no password to collect and nothing secret crosses from the
browser to the host — the blocker recorded in first-run-restore.md §4.1
simply does not apply.

Accepts a single file or a directory of them, so "point at this folder"
works as well as "point at this file".

Verified against real .lpapp files built for the purpose: app name read
from the tar's top-level directory rather than the filename, size read
from the manifest, a location the machine does not have downgraded to a
warning naming the fallback, and refusals for an already-installed app
and a file that is not an export at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:04:32 +01:00
librelad
ef60cce98a feat(app): portable .lpapp export and import — phase 4
`libreportal app export <app>` writes one app to a single file;
`libreportal app import <file>` installs it here. This is the thing the
original request described as "upload or navigate to the backup file" — a
restic repository is not a file, but the want behind the phrasing is real.

The format is deliberately boring: gzipped tar of the app directory with
its .libreportal-manifest.json at the root. That manifest already records
size, images, volumes, databases and storage location, so import reuses
the phase-3 checks for free — refusing an app this version no longer
ships, or one that will not fit, before unpacking anything.

Export stops the app first. A tar of a running Postgres is a corrupt
Postgres, and a file that looks fine until you restore it is worse than a
refusal. tar runs as the owning user with --numeric-owner so container
sub-UIDs survive the round trip instead of being remapped through this
machine's /etc/passwd.

Import re-runs the normal install pipeline after unpacking, because the
compose still carries the SOURCE machine's ports, IPs and domains — that
pipeline is what re-allocates them here, and migrateUrlRewrite fixes the
host-bound CFG_* fields.

Documented throughout as a courier format, not a backup: no history, no
retention, no encryption. Importing under a different name is refused
outright rather than half-working — the CFG_<APP>_* namespace and compose
identities would all need rewriting, and `instance create` already
answers "a second copy".

Fixes a bug this surfaced: _appDirIntended did an indirect expansion on
CFG_<SLUG>_STORAGE without checking <SLUG> can be a variable name, so a
hyphenated or mistyped app name emitted "invalid variable name" and then
reported the misleading "storage location is not mounted" for an app that
simply did not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:08:41 +01:00
librelad
6c89e430e1 feat(restore): preflight report — phase 3
`restore preflight <loc_idx> <host>` reads every app's manifest out of its
own snapshot (engineDumpFile pulls a single file without restoring) and
checks it against this machine before anything is written:

  * an app this version no longer ships is skipped — restoring one
    produces a directory that can never start, and looks like success
    until someone opens it
  * an app that will not fit is skipped individually, because filling the
    disk part-way through takes the apps that already landed with it
  * a manifest naming a storage location this machine lacks falls back to
    the default, and says which app moved where

The installer's restore path runs it and asks once before continuing.

Two bugs found by running it against the live repository rather than
reading it:

The CLI dispatcher calls handlers with NO arguments, so `shift 4; "$@"`
inside one operates on an empty list. `restore first-run bulk` has always
had this — a bulk restore silently received zero apps. Fixed at the entry
point: start.sh now captures LP_CLI_ARGS from "$@", and both call sites
use it.

And the wrapper fills unset argv slots with the literal string "empty"
(${5:-empty} … ${9:-empty}), so a trailing slot arrives as a five-
character app name rather than a blank. Filtering on -n alone let five
phantom apps through and reported each as "no longer shipped". Both call
sites now drop the sentinel. That also caps any explicit list at five, so
preflight discovers the host's apps itself when given none.

Verified against the live repository: 13 apps discovered and checked.
Sizes read "?" there because those snapshots predate manifests carrying
size_bytes — the intended graceful fallback, not a refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:05:49 +01:00
librelad
15ca10530d feat(setup): Backups step — phase 1 of first-run restore
Nothing prompted anyone to configure backups, so the people most likely
to need a restore were the least likely to have one. The wizard now asks,
once, with the drives it already scanned as the options.

Three messages, because the honest answer differs by choice:

  declined      nothing is protected until you set it up
  same drive    still covers deletion, a bad update and ransomware — not
                this disk failing, since the data and its only copy go
                together
  another drive the repository is encrypted; write the password down
                somewhere other than this machine

That last one matters more than it reads. An encrypted repository cannot
be opened with anything stored inside itself, and the location password
lives in the system config, which is inside the backup. On a rebuilt
machine the user must supply it by hand — so the wizard says so up front
rather than letting them discover it during a restore.

The password is deliberately NOT echoed by setupApplyConfig: task output
is logged, and a secret in a log is a secret you have to treat as leaked.
It is shown on the Backup page, which is what the wizard tells the user.

locationAdd creates a location disabled, so the applier enables it and
runs engineInitLocation — an un-initialised destination silently backs up
nothing, which is the worst possible way to have "configured backups".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:11:36 +01:00
librelad
af707be949 feat(system): libreportal-relocate — move an installed LibrePortal's roots
Root-run command to move the system and/or containers root to another
disk after install, re-baking everything that carries those paths: the
nine root-owned helpers, the CLI wrapper, the systemd unit and the
WebUI's own compose bind-mounts.

Deliberately NOT in the manager's scoped sudoers, and symlinked into PATH
like the uninstaller. Moving a root re-bakes the very helpers the sudoers
allowlist trusts, so a helper that did it from a caller-supplied path
would hand the manager the entire trust boundary those helpers exist to
defend. A human with real root runs this; the WebUI can only print the
command, which is what the Storage step now does.

Copy-verify-then-leave, never move: the source tree is not removed at all
— the command tells you to delete it once you have confirmed the WebUI
works. An interrupted run therefore leaves a working install behind
rather than half of one, and the pre-relocation copies of every
root-owned file are kept under $lp_lib_dir/.relocate-<timestamp>/.

Admission mirrors libreportal-storage: absolute, no "..", not a protected
system path, not already in use, must be an empty directory, roots must
not nest, and space checked with 10% headroom.

One bug worth recording, caught on the first test run against a live
install: _validate_target was called inside $(...), and `die` runs `exit`
— which inside a command substitution kills only the subshell. Every
refusal silently became "proceed" and the relocation ran. No damage (the
copy steps were guarded on a now-empty variable, so the re-bake wrote
identical values and only the service bounced), but it is exactly the
difference between a refusal and an unintended relocation. It now sets a
global and returns, so `die` exits the script it is meant to.

footprint_version -> 7 for the new root-owned executable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:59:34 +01:00
librelad
49c463a996 feat(storage): a default location, so one choice places every future app
Per-app placement worked but had no default: a box with a big second disk
meant setting CFG_<APP>_STORAGE on every app individually. CFG_STORAGE_DEFAULT
fixes that, and the wizard asks for it in one line.

CFG_<APP>_STORAGE now has three states rather than two, and the third is
the point:

  <name>    this app goes there, whatever the default says
  primary   this app goes on the install-time root, explicitly
  default   no opinion — follow CFG_STORAGE_DEFAULT

Templates ship "default", so the setting reaches every app without
touching 37 configs, while an app that was deliberately placed keeps its
placement. "primary" is new, and needed: without it there was no way to
say "keep this one on the system disk" once the global default moved.

A default naming a location that has since been removed falls back to the
primary root rather than refusing — a disk that got unregistered must not
make apps un-installable.

The wizard asks only once a second drive is ticked; with nothing ticked
there is one possible answer and a control would be furniture. It sets a
default, not a placement, and the value stored is the location NAME, so it
survives the disk being remounted elsewhere.

scripts/dev/lp-storage-default-test covers all three states plus the
removed-location fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 03:31:13 +01:00
librelad
e101764085 feat(setup): drop the filesystem type, show capacity as free-of-total + a bar
The card said "911.9G · 808.4G free · ext4". The filesystem type is a
Details row, not something you choose a drive on, so it goes.

On percentage vs size: which one matters depends on the question. This
step asks "will my data fit?", and absolute free space is what decides
that — a 4 GB disk that is 89% free is still useless for a media library.
Percentage answers "is this filling up?", a health signal rather than a
placement one. So the text carries the magnitude ("808.4G free of 911.9G")
and a thin bar carries the proportion, which is what the eye reads
fastest, with no second number competing with the first.

The bar fills with FREE space, not used. Filling by usage made a healthy
7%-full disk render as an almost-empty track that read as a broken widget
— and it pointed the opposite way to the text beside it. Filled = room to
spare, draining = filling up, matching the words. It turns amber below
25% free and red below 10%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:38:14 +01:00
librelad
d8c9f2486b feat(setup): show the system disk, and slim the drive cards to one line
Three changes to the Storage step.

The system disk is now a first-class entry — pinned first, ticked, and
locked, since apps fall back to it and it therefore cannot be deselected.
Its Details work like any other drive's, which is the whole point on a
single-disk box: the step now answers "where does my data actually go?"
instead of being skipped and answering nothing. The step is consequently
unconditional; the note changes to explain that no other drives were
found rather than the step vanishing.

The system entry is excluded from the submitted payload — it is already
the primary root, and asking the helper to register it would (correctly)
be refused for nesting.

Cards are one line again. Listing every warning under each drive pushed
them to three lines and made the step tall for no gain: the badge already
carries severity and Details carries the explanation. The note now says
to open Details for the reason rather than claiming it is on the card.

Badge colours were dark-on-light, which against the wizard's mid-blue
glass read as muddy grey — the "needs care" pill in particular. Switched
to light-on-dark, legible without shouting over the drive name.

Verified with lp-shot in both states: system disk alone, and system disk
plus a second drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 02:19:40 +01:00
librelad
7eb6d36d55 feat(storage): readable drive cards, a details modal, and an fstab offer
The Storage step was a technical dump: every check's full sentence
concatenated onto the card, so the fstab line the user is meant to act on
was buried in prose nobody reads.

The card now shows plain facts and at most two short flags — "Low on
space · Won't be mounted after a reboot" — with everything else behind a
Details button. The modal carries the technical spec (device, UUID, mount
options, removable), every check with its full explanation, and the
fstab offer.

That needed the shell to stop joining checks into one string: the
generator emits a record per check, plus the fstab line as its own field,
so neither the card nor the modal has to parse anything back out of the
other.

The screenshot caught a bug this restructure introduced: summaries keyed
on check id alone, so a PASSING check printed the failure wording next to
a green tick — "This drive's format can't store file ownership" above
"Filesystem: ext4". Now severity-aware.

On writing /etc/fstab — §1 ruled it out and §6.3 now records why that
reverses. The warning is useless to the audience this is for: "add this
line to fstab" assumes SSH, root, an editor, and knowing what fstab is,
and the likely outcome is a reboot where nothing starts. What makes it
defensible is nofail + x-systemd.device-timeout, which mean a missing
device can never block boot — without that pair it would stay a non-goal,
because the failure being risked (an unbootable machine) is worse than
the one being fixed.

Enforced in the root helper: UUID never /dev/sdX, append inside a marked
block, refuse a target or UUID already described, refuse the root
filesystem, require a live mount, timestamped backup, and
`findmnt --verify` before the file is installed — a file that doesn't
parse never reaches /etc. Opt-in only.

Verified against a real filesystem: entry added and verifies, the
persistence warning then disappears on the next scan, and duplicate /
root-fs / non-mountpoint / relative are each refused with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:44:46 +01:00
librelad
f5238f21ca feat(setup): wire storage data into regen, split candidate warnings
webuiGenerateStorageCandidates now runs as part of webuiSystemUpdate, so
frontend/data/storage.json exists without anyone remembering to generate
it — the wizard reads it to decide whether its Storage step appears, and
the Disks view reads the same file, so the two can never disagree.

Warnings arrive from the shell joined with "; ". Rendering that verbatim
produced one run-on paragraph that buried the fstab line the user is
supposed to copy, so the card splits them back onto separate lines.

Verified on the live install with lp-shot: with one filesystem the wizard
shows "Step 1 of 4" and the Storage step is correctly absent; with a
second filesystem attached it becomes "Step 4 of 5" with the drive
carrying a "needs care" badge and both warnings legible. That also
exercises the visible-step mapping in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:14:56 +01:00
librelad
cea653f67b feat(setup): Storage step in the first-run wizard
Phase 3 of docs/roadmap/storage-locations.md.

The step appears only when the candidate scan finds a filesystem
LibrePortal isn't already using, so the single-disk case — which is most
boxes — is completely unchanged. It sits before Recommended because a
location has to exist before an app can be placed on it.

Supporting two conditional steps meant the wizard could no longer treat
'position in the DOM' and 'step index' as the same number: Metrics was
advanced-only and got away with 'length minus one', but a step hidden in
the MIDDLE leaves a gap. Navigation, progress, validation and submit now
all run off _visibleSteps(), and section matching is by data-step rather
than DOM position.

Unusable candidates render greyed WITH the reason rather than being
filtered out — 'why isn't my drive listed?' is a support burden, and
'exFAT can't store file ownership' is actionable. The step is skipped
only when nothing usable was found at all.

What the wizard sends is a request, not an instruction: setupApplyConfig
feeds each path through storageAdd, so the fitness checks and the root
helper's admission rules both re-run regardless of what arrived in the
payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:45:10 +01:00
librelad
8fad6c6a4d fix(restore): take the source path from the snapshot, not local config
Restore built its restic include filter from THIS host's containers root:

    engineRestoreSnapshot "$idx" "$id" "/" "$containers_dir$app"

restic reproduces a snapshot's absolute paths, so that only works when
both sides agree byte-for-byte. LibrePortal has shipped configurable
roots for a while, so restoring a snapshot taken on a host installed with
--containers-dir=/mnt/ssd/apps onto a default host matched no include
path and restored NOTHING — with no error, because an include filter that
matches nothing is not a failure. Storage locations turn that from a rare
cross-host case into an ordinary one.

storageSnapshotSourcePath asks the repository where the app actually
lived. storageRestoreAppTo restores in place when that agrees with where
the app belongs here, and stages-then-moves when it does not — which is
also what makes "restore this app onto a different disk" possible at all.
Both restore_app_start.sh and resticRestoreAppLatest go through it, and
both fall back to the old behaviour when a snapshot does not report its
paths, so older snapshots restore exactly as before.

The move into place runs as root (app-adopt) for the same reason app-move
does: a restored tree carries container sub-UIDs the manager cannot
recreate. Staging is constrained to the restore/migrate area and the
destination is validated against the root-owned registry, so neither end
is taken on trust from the caller.

The manifest now records where an app lived — location name, path and fs
uuid. The name is what travels, since a path means nothing on the other
host; the rest is for diagnostics and for answering "is this the same
disk?" during a migrate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:39:35 +01:00
librelad
26e98698d8 feat(storage): per-app placement, app move, and the READONLY marker
Phase 2 and 4 of docs/roadmap/storage-locations.md. Apps can now be
placed on a location and moved between them.

CFG_<APP>_STORAGE lands in all 37 app templates, holding a location NAME
rather than a path: names survive a migrate to a host with different
disks, paths do not. The 11 infrastructure apps that other apps reach by
literal path (traefik, prometheus, grafana, adguard, gluetun, crowdsec,
headscale, dashy, pihole, unbound, wireguard) are pinned. libreportal
itself never gets the key — it is pinned structurally by webuiDir.

Pinning needed no second config key. "Pinned" is not a fact about a value,
it is a statement about whether the field may be edited, so it goes in the
comment beside **ADVANCED** and **DEV** as **READONLY**, and the field
factory renders those disabled. That marker earns its keep beyond this
feature: derived fields already warned in prose that editing them does
nothing (crowdsec.config:72) next to a perfectly editable input.

storage_app_config.sh keeps the comment honest — it carries the resolved
path for hand-recovery and regenerates the dropdown from the registry, but
only writes when something actually changed, since the app .config is
user-editable and lives in the container-owned tree.

app move stops the app (a live copy of a running Postgres is a corrupt
copy), snapshots it, copies, verifies, and only then removes the source.
The copy runs in libreportal-ownership because it must: app data holds
rootless sub-UID files the manager can neither read nor recreate.
Verified against two real ext4 filesystems that a cross-device move
preserves uid 231141 and the payload, and that the source survives every
refusal path — unregistered destination, the WebUI app, a traversal in the
app name, and an occupied destination.

Task titles registered in both tables, with a specific rule so a move
renders as "Nextcloud - Move to bigdisk" rather than the generic fallback
dropping the destination. lp-task-names could not be run to confirm — it
borrows the WebUI container's node and no containers are running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:37:35 +01:00
librelad
0de7f40f5d feat(storage): registry helper, fitness checks, CLI and the Disks view
Phase 1 of docs/roadmap/storage-locations.md — locations can now exist.
Nothing places an app on one yet; that is phase 2.

libreportal-storage is the only writer of the root-owned registry, and
its admission rules are what make adding a location safe: absolute and
canonical (a symlinked path is refused), outside the protected system
set, non-nesting with any known root in either direction, and EMPTY — or
already carrying our marker, which is the adopt case for a drive that
already holds app data. Root only ever chowns an empty directory, so
acceptance cannot hand away anything that existed. The parent must also
not be manager-writable, which is what closes the validate-then-chown
race; /mnt and /srv qualify, a path inside the manager's home does not.

The fitness checks answer a different question — "will app data actually
work here" — and grade rather than refuse. Only checks 1-5 (filesystem
type, mount options, ownership, sub-UID range, write/read-back) can block.
Reboot persistence and removability warn, because both describe supported
setups and start-up is already gated by the marker test.

The ownership probe had to move into the root helper. For a candidate the
directory is not ours yet — a fresh /mnt/disk is root-owned 0755 — so an
unprivileged probe could only ever report "cannot create a directory
here", which says nothing about the filesystem. Verified against a real
loopback ext4: it now reports ownership, sub-UID and read-back cleanly.

The Disks view unions the registry with attached hardware, registry
first. Verified on the live box that pulling a drive leaves its row in
place as not-attached, naming the app stranded on it, rather than the row
silently disappearing at exactly the moment someone needs it.

Also registers storage_scripts with both loaders, adds the CLI category
(auto-dispatched by cli_initialize.sh), and bumps footprint_version to 6
for the new root helper and the widened sudoers allowlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:30:46 +01:00
librelad
ac4c11b5e9 fix(storage): move the app->location index out of configs/
Follow-up to 928e244, which stopped configs/ subdirectories being sourced
without a .category marker. That closed the hole; this removes the thing
that fell into it.

storageIndexFile pointed at configs/storage/app_locations. The file's
requirements are only "manager-owned" and "not on a removable disk" —
configs/ satisfies both, which is why I put it there, and it was still
wrong: that tree carries a third property the file violates. sourceScanFiles
SOURCES what it finds under configs/, and sourcing means executing.

The index is a TSV of "<slug><TAB><root>", which bash reads as a command
and its argument. Harmless while no slug matched a real executable. The
row for the app named `libreportal` armed it, because that IS the CLI on
PATH: sourcing ran `libreportal /libreportal-containers`, which re-entered
the scan, which sourced the file again — one process pair per level until
the host OOMed and took the desktop session with it.

It now lives at $system_dir/storage/app_locations, with a one-shot
migration so an install that already has an index keeps knowing where its
apps live rather than silently forgetting. libreportal-ownership
reconciles the new directory, and scan_files.sh gained a note that
configs/storage/ carries no .category on purpose.

scripts/dev/lp-configs-guard-test covers both ends: the index never lands
in configs/, a legacy one migrates, and a file of the exact detonating
shape placed in an unmarked configs/ subdirectory is not executed while a
marked category still loads.

Also wires sourceStorageLocations into the config scan beside
sourceBackupLocations — per-location configs sit at depth 3, below the
generic scan, and need their own walker like the backup ones do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:25:07 +01:00
librelad
928e244696 fix(config): stop sourcing files in unmarked configs/ subdirectories
sourceScanFiles sourced every file two levels deep under configs/, and
sourcing means executing. A directory used there as ordinary storage
therefore turned its contents into a script.

storageIndexSet caches an app -> root TSV at configs/storage/app_locations,
with no .category marker alongside it. Every line is `<slug><TAB><path>`,
which bash reads as a command and its argument. That stayed invisible while
no slug matched a real executable — and became a fork bomb the moment the
index recorded the app named `libreportal`, because that IS the CLI on PATH:
sourcing ran `libreportal /libreportal-containers`, which re-entered the same
scan, which sourced the file again, one process pair per level until the host
died of OOM. Every CLI invocation on the box detonated it, the task
processor's own poll included, so the machine black-screened out of memory
minutes after each boot.

Files in a SUBDIRECTORY are now sourced only when that directory carries
.category — the contract commandReloadConfigs already enforces in the CLI
wrapper, and one every real config category (webui, general, security,
backup, network) already satisfies. Files directly in configs/ are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:03:02 +01:00
librelad
8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00
librelad
2d24a764a8 refactor(storage): route elevation tests and the WebUI tree through paths.sh
Two mechanical sweeps, no behaviour change on a single-root install.

The 14 `[[ "$p" == "$containers_dir"* ]]` prefix tests that decide
manager-vs-container-user elevation become pathIsContainerData, so a file
on a second storage root is no longer misclassified as manager-owned —
which would have written it with the wrong owner and failed later, far
from the cause. The 65 references to the WebUI's own tree become
webuiDir(), which is pinned to the primary root by design.

Two traps found while doing it:

run_privileged.sh is sourced directly by init.sh without paths.sh, so it
needs a fallback. Defining one named pathIsContainerData was wrong:
generate_function_manifest.sh indexes top-level definitions, and the
resulting autoload stub would have shadowed the real multi-root
implementation with the primary-only fallback — silently classifying
every file on a second disk as manager-owned, which is exactly the bug
this sweep exists to prevent. Renamed to _runCfgIsContainerPath, which
delegates when the real one is loaded.

setup_lock.sh built its path in a top-level assignment, so it was
evaluated at source time and needed the file flagged eager. Made it a
function instead: the path resolves on call, and the file drops off
LP_EAGER_FILES entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:04:19 +01:00
librelad
db04f8f000 feat(storage): path primitives for multiple container roots
Phase 0 of docs/roadmap/storage-locations.md — the resolver layer. No
behaviour change yet: with no registry present, every function here
returns exactly what the old single-root code did, which is what makes
the ~200-site sweep that follows safe to land incrementally.

  primaryRoot / webuiDir     the install-time root, and the one tree that
                             never moves
  storageRoots               every registered root, primary first
  storageRootAvailable       marker present == drive mounted
  pathIsContainerData        replaces the `== "$containers_dir"*` idiom
                             that picks manager vs container-user elevation
  appDir / appDirSlash       THE resolver, memoised
  storageLocationPath/Name   name <-> path, via the root-owned registry
  storageIndexGet/Set        app -> location cache

appDir resolves discovery-first: whichever root actually holds
<slug>/<slug>.config wins, so a hand-move or half-finished migration
self-heals rather than corrupting.

The index exists because of a bug the unit test caught immediately.
Discovery cannot see an unmounted disk, so an installed app on an
unplugged drive looked identical to a brand-new app — and the fallback
handed back the PRIMARY root. Docker would then have created the bind
mounts there and booted the app empty on the wrong disk, which is the
precise failure the availability design exists to prevent. The index is
manager-owned (deliberately not on the removable disk: it must be
readable exactly when that disk is absent), consulted only when the scan
comes up empty, and rewritten by every successful scan so the disk stays
authoritative whenever it is actually present.

Availability is gated in appDir alone rather than at each caller: every
site reaches it by construction. It returns non-zero AND prints an
unusable sentinel path, so the many callers that will never check $?
still fail loudly on something harmless.

scripts/dev/lp-storage-test covers all of it against a throwaway tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:01:19 +01:00
librelad
472a63466b feat(instance): repair pass for clones made before the tool/auth renames
Fixing _instanceRewriteTools does nothing for an instance already on disk, and
a clone from the old code is broken in ways that never announce themselves:
every Tools action answers "App '<slug>' has no tool '<id>'" because
dockerAppRunTool wants app<Ucfirst><Pascal>; `authPersistCfg <type>` writes the
instance's new admin credential into the BASE app's config; and the clone
defines the base app's adapter and tool names while its bodies exec against the
instance's container, so the loader keeps whichever it sourced last and the
base app's user tools can end up administering the instance — decided by
nothing but find(1) order. Seen on a live install: the generated manifest
resolved [appBookstackListUsers] to bookstack_test's copy.

  libreportal instance repair [slug] [--dry-run]

Rewrites the template dir only — no container is touched, nothing reinstalled,
so it does not route through the task system the way create/remove do.

Idempotent by construction. Two of the three renames match their own output
(appMattermost_teest… still starts with appMattermost), and a clone from the
old code is only PARTLY wrong — its suffix hooks were always correct and end at
the slug with no trailing underscore, which the infix rule would otherwise read
as type + id + () and append the id twice
(appSetupComposeTags_nextcloud_family_family). Three sentinels park the
already-correct spellings before the rewrite and restore them after, so a
healthy instance is a no-op and an interrupted run can just be re-run.

Verified against fixtures built with the old rule set for all five
multi-instance apps that ship tools: after repair each tree is byte-identical
to a fresh clone from the fixed cloner, a second pass reports "already
correct", and --dry-run leaves checksums untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 03:01:24 +01:00
librelad
f82237da36 fix(instance): make an instance's own functions reachable
Four defects that all reduce to "a function an instance defines is invisible
to the code that dispatches it". Reported as `mattermost_teest has no upgrade
verifier`, for an app whose verifier was on disk the whole time.

* generate_function_manifest.sh shipped 0664. lpRegenArrays invokes it as an
  executable, so it died rc=126 on every call and `|| true` swallowed it — the
  manifest was never rebuilt on any live system, only laid down at deploy.
  Its sibling generate_arrays.sh is 0775, which is why the files_*.sh arrays
  looked current while the manifest was byte-identical to the shipped copy.

* lpRegenArrays now runs both generators through bash rather than depending on
  the exec bit, reports a manifest failure instead of hiding it, and treats a
  new containers/<app> dir as stale — the one event on a live box that adds
  functions was the one the scripts/-only mtime check could not see.

* updaterHasVerifier consults the disk before answering no. The CLI runs
  LP_LAZY=1, where the container scan is skipped and every function must come
  from the build-time manifest, so an app created after the build reads as
  having no verifier. GATE 1 then refuses an upgrade that is fully verifiable,
  and updaterUpgradeAuto's `|| continue` drops the app in silence for good.
  Self-healing regardless of manifest staleness, which matters because a
  self-update restores the shipped manifest and drops instance entries again.

* _instanceRewriteTools gains three renames. authAdapter_<type>_<method>() was
  caught by neither the prefix rule (no word boundary before _<type>) nor the
  suffix rule (needs () right after the type), so the clone defined the base
  app's adapter name while pointing at its own container — every instance user
  tool answered "does not implement", and which definition survived came down
  to find(1) order. Bare-app arguments to authAdapterCall/authPersistCfg went
  unrewritten too, so an instance's password reset wrote the credential into
  the base app's config. And dockerAppRunTool wants app<Ucfirst><Pascal>, which
  no rule produced, so every tool on every instance was unreachable. The infix
  rename runs before the suffix rename: the reverse order appends the id half
  twice (appSetupComposeTags_nextcloud_work_work).

Verified on a live install: the upgrade ladder now plans mattermost_teest
11.9 -> 11.10, and `regen arrays --force` indexes the instance hooks.

Also carries in-flight instance-removal regen work from a concurrent session
on the same worktree (_lpRegenOrphanedApp, instanceRemove's WebUI refresh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:51:18 +01:00
librelad
66cdb5be0d fix(backup): report the real reason a live capture fails
Two discoveries from exercising the capture path live:

- docker exec prints its 'executable file not found' OCI error to STDOUT
  (a docker quirk), i.e. into the tar pipe — so the exit code (126/127)
  is the only trustworthy no-tar signal, and the host-side 'not a tar
  archive' noise is a symptom, not the cause. Detect on the code and say
  plainly that the image has no tar.
- The two pipe halves shared one stderr file through an O_TRUNC fd and
  an O_APPEND fd, racing and overwriting each other's lines — one file
  per half, concatenated after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 01:04:18 +01:00
librelad
facf764c4b feat(webui): bulk selection on app tasks, updates overview, and app backups
The /tasks page's right-side tick + dynamic Select all / Clear All ⇄
Delete Selected layout now covers the other three management surfaces:

- App detail → Tasks tab: filter bar gains the Clear All button and
  master tick; Clear All there scopes to that app's tasks only. The
  selection set is resolved through window.tasksManager everywhere —
  TasksManager is constructed in several places, and ticks previously
  landed on one instance while Delete Selected read another's empty set.
- Apps overview → Updates: the header's Update all button now morphs to
  Update Selected (N) + Clear in place as rows are ticked, replacing the
  separate selection bar between toolbar and list.
- App detail → Backups: each snapshot row gains Delete + a right-side
  tick; a toolbar atop the list morphs Delete All ⇄ Delete Selected (N).
  The whole selection rides in ONE task (delete <app> 1:a,2:b,…) since
  the backup surfaces hold one task per subject at a time.
- CLI: backup app delete accepts comma-separated <idx>:<snap> pairs, and
  both delete and delete_all now regenerate the WebUI backup JSON so
  deleted snapshots leave the screen instead of lingering until the next
  backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:59:19 +01:00
librelad
dd68c04fec fix(updater): stop showing ghost rows for uninstalled apps
Matrix was uninstalled and the Updates tab kept listing it as up to
date. Not an instance problem — updates.json and cves.json are
scan-time snapshots on a 30-minute cadence, and nothing rewrote them at
uninstall, so any removed app haunted every updater surface until the
next scan happened to run. The backend was never wrong: the DB, the
apps data and the app's own page all said uninstalled within seconds.

Fixed at both ends. Uninstall now deletes the app's rows from both
generated files, surgically — a full rescan re-runs CVE checks against
every image and has no place inside an uninstall. And the updater's
merge drops any row whose app window.apps does not list as installed,
which covers every other way the snapshot can go stale (a crashed
uninstall, a hand-edited file, the next bug). The filter only applies
when the installed list has actually loaded, preserving the page's
degrade-gracefully contract when it has not.

The stale Matrix rows on this install were purged the same surgical
way; the tab now shows 14 rows with the merge still intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:50:11 +01:00
librelad
0e98988fcb fix(boot): reconcile apps at startup — unless-stopped loses a shutdown race
Prometheus kept being found stopped after boots, always Exited(0),
always alone. The journal settles it: both stops sit seconds before a
host shutdown boundary — container stopped 05:45:22, boot ended
05:45:30; stopped 04:41:59, boot ended 04:42:05. This is a laptop-class
host that gets shut down, and under ROOTLESS docker the containers are
ordinary processes in the user session, torn down by systemd in
parallel with dockerd's own exit.

That parallelism is the race. An app that handles SIGTERM promptly
exits while dockerd is still alive to record "stopped" — and
unless-stopped then means what it says: not restarted at the next
boot. Apps that exit slower, or die only when dockerd does, are
recorded as running and come back. Prometheus loses reliably because it
is the best-behaved process on the box ("See you next time!"), but
which app loses is a scheduling accident — changing Prometheus's
restart policy would treat the sample, not the race.

So an @reboot crontab entry now waits for the rootless daemon (up to
five minutes, then gives up rather than hang) and `compose up -d`s
every installed app via the existing dockerComposeUpAllApps. Idempotent:
running apps see no diff, stopped ones start, ordering is compose's
problem. Registered through crontabRefresh like the other entries, and
installed on this box.

The accepted trade, stated rather than hidden: an app deliberately
stopped before a reboot comes back after it. On a self-hosting box "the
fleet is up after boot" is the promise unless-stopped was already trying
to make; a stop that must survive reboots is what uninstall is for.

Verified by direct execution: daemon answered immediately, all
installed apps reconciled, running containers untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:49:51 +01:00
librelad
a86c142e74 fix(backup): honest failures, rootless docker in capture paths, speedtest key capture
- backup_files.sh / backup_db.sh: every docker exec/run in the capture,
  sidecar-discovery, rehydrate and DB-import paths now goes through
  runFileOp — bare docker can't reach the rootless daemon socket, which
  made live capture fail (and silently bounce containers) on every
  rootless install, and would have broken DB restores the same way.
- capture/rehydrate stderr is kept and printed on failure instead of
  being discarded, with a clear message when the image has no tar.
- backup_app_start.sh: when no location produced a complete snapshot the
  backup now returns 1 — the task is marked failed instead of logging a
  nonexistent/incomplete backup as a success and skipping verification.
- restic engine: on restic exit 3 the orphan incomplete snapshot is
  called out explicitly so nobody restores it believing it is whole.
- speedtest: capture /config through the container (root-owned TLS key
  and logrotate state are unreadable from the host), which also flips
  its auto strategy to live — no more container stop per backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:31:46 +01:00
librelad
785df3dcd8 fix(tasks): name upgrade tasks, humanise dead instance slugs, keep icons
Three visible faults on the Tasks page, one shared root.

Upgrade tasks rendered as their raw command — "libreportal updater
upgrade rocketchat 8.7.1" beside properly named neighbours. The title
table had rows for updater check/apply/apply-all/rollback and none for
upgrade, because the upgrade command is assembled in task-actions.js
rather than task-commands.js — and lp-task-names, the guard built to
catch exactly this, only read task-commands.js. It certified 16 commands
and reported that as the whole surface; the surface was 29. The guard
now reads both dispatch sites (JS ${expr} interpolations become sample
placeholders; commented-out prose mentioning commands in backticks is
skipped, or it reports fictional commands), and all 29 pass.

A removed instance's tasks outlive it, and its slug rendered as a tech
identifier: "Bookstack_uitest - Remove Instance". getAppDisplayName
cannot help — it capitalises as its own fallback, so unknown is
indistinguishable from known-and-plain. The formatter now does the same
membership test the helper uses internally: slug absent from
window.apps, prefix before the underscore present -> render the way
live instances are shown, "Bookstack · uitest".

Same story for the icon: bookstack_uitest.svg is deleted with the
instance, and onerror="display:none" left a bare gap in the row. Now a
fallback chain — the TYPE's icon (which survives), then the LibrePortal
logo. Verified live: the dead instance's rows show bookstack.svg with
the fallback marker set, everything else keeps its own icon.

Verified in a real browser session — full render, zero console errors.
The 'add' verb also joins the app-action map so "Add Application" is
deliberate wording rather than the blind "<Verb> Application" compose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:57:30 +01:00
librelad
035efa948b copy(webui): drop 24 tooltips that only restate their own label
Swept the field mappings after the Updates one. Removed where the tooltip
carried no information the label did not already give:

  PORT_1..20   "Port N for this application"          label: "Port N"
  PORTS        "Port configuration for the           label: "Port Configuration"
                application"
  CATEGORY     "The category this application         label: "Category"
                belongs to"
  THEME        "Visual theme for the application"     label: "Theme"
  ...PRIVATE_KEY "WireGuard private key"              label: "WireGuard Private Key"

Deliberately kept several the crude word-overlap check also flagged, because
they earn their place: DOMAIN says the value is a number and why, HEADSCALE and
COMPOSE_FILE carry a requirement and a warning, VPN_TYPE says it depends on the
chosen provider, and DESCRIPTION/LONG_DESCRIPTION distinguish brief from
detailed — which is the only thing separating that pair on screen.

config-form.js already guards on the field having a tooltip, so a field without
one renders no help icon rather than an empty bubble. Confirmed on Bookstack's
config page: 25 icons left, none with an empty or "undefined" title, and no
stray "undefined" in the body text. Regenerated the served JSON too — 145 fields
before and after, 135 tooltips down to 111, and no field changed in any other way.

Worth a look separately: PORT_N holds the full pipe-delimited port descriptor,
not a port number, so "Port 1 for this application" was mildly misleading as
well as redundant. A tooltip explaining that format would be an improvement
rather than a deletion.
2026-08-20 23:33:09 +01:00
librelad
caead9e100 copy(webui): drop the redundant half of the Updates tooltip
"Install new image builds automatically, or only when you press Update" spelled
out both options, which the select's own labels already do directly below it —
"Automatic (recommended)" and "Manual — I'll press Update". The tooltip now says
only what the setting is for.

The generated apps-field-mappings.json carries this string, so the live install
was regenerated rather than left showing the old copy.
2026-08-20 23:00:46 +01:00
librelad
4aaf68c101 fix(source): regenerate files_source.sh, which had gone stale
files_validation.sh exists in the tree but was missing from the generated
source-array, so eager loading never sourced it. Found while diffing the repo
against the live install, where the array had been regenerated in place and
carried the entry the committed one lacked.

Regenerated with generate_arrays.sh rather than copied back from the install, so
the committed array is what the generator actually produces.
2026-08-20 22:15:20 +01:00
librelad
60a2fc3b0b Add lp-task-names, a guard against unnamed task commands
Task titles come from one table whose final fallback returns the raw command
string, so a dispatched command with no matching row does not error — it just
renders as "libreportal instance remove bookstack_work" beside properly named
neighbours. That silence is why this kept being fixed and kept coming back.

The guard reads BOTH files as source — the command templates from
task-commands.js and the pattern table from tasks-format.js — so it fails on a
command added without a name rather than leaving it to be noticed in the UI.

Two checks, both from source rather than guessed from rendered text:

  1. Nothing falls through: a title equal to its command, or still starting with
     "libreportal ", means the raw fallback was reached.

  2. Every `libreportal app <verb>` verb has an actionMap entry. Without one the
     generic branch composes "<Verb> Application", which is how "Up Application"
     and "Down Application" shipped.

The second check reads the actionMap keys instead of pattern-matching the title,
which a first attempt did and which was wrong: "Reload Application" is both a
correct hand-written label and what the generic branch emits, so the rendered
text cannot distinguish them and the heuristic failed a title that was fine.

Verified by breaking it deliberately in both directions — adding a command with
no pattern, and deleting an actionMap verb. Each is caught, named, and pointed at
the file to edit; both files were restored byte-identical afterwards.

Lives in scripts/dev, which .gitattributes marks export-ignore, so it never ships
in a release tarball. Needs a node and borrows the running container's when the
host has none, the same constraint lp-shot works around for chromium.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:38:51 +01:00
librelad
9c8f0782e1 feat(updater): build dates for off-Hub images, from the config blob
The unmaintained warning runs on one field — when upstream last rebuilt
the image — and off-Hub apps had no value for it. Hub answers in a single
call; the OCI API does not expose it at all, so an app on ghcr.io, quay.io
or lscr.io simply could not be assessed for staleness, which is the one
signal a user cannot work out for themselves.

It is in the image, just further down: manifest -> (if a multi-arch
index) a platform manifest -> config blob, whose "created" is the build
time. Three requests instead of Hub's one, once per registry window, and
only for the apps Hub cannot answer for — which is why Hub keeps its
cheap path rather than being routed through this.

Index and single-arch manifests are distinguished explicitly rather than
by position: in an index the first digest is a CHILD manifest, in an
image manifest it is the config itself, so reading "the first digest"
would silently fetch the wrong blob for one of the two shapes.

Live: stoat 2026-08-08, bookstack 2026-08-17, speedtest 2026-08-16,
invidious 2026-08-05 — all previously null. Hub unchanged, navidrome
still answered by the single-call path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:11:29 +01:00
librelad
0a6ea95b08 Scope app container operations by compose project, not name substring
`docker ps -f name=<app>` is a SUBSTRING match, and instance slugs are
<type>_<id> — so the base app's name is a prefix of every instance of it.
`name=bookstack` also selected bookstack_home, bookstack_test and their -db
containers, which meant start, stop, restart and remove all silently operated on
every instance of an app instead of the one named.

Worst of the four is remove: `libreportal app remove bookstack` ran `docker rm`
against its instances' containers too. Multi-instance made this reachable — the
naming scheme it introduced is exactly what turns the base name into a prefix.

Each app and instance is already its own compose project, named for its
directory, so the project label addresses exactly the containers belonging to
that app. app_install.sh's own post-install check already used this label; the
lifecycle operations did not.

Found while tracing the IP allocation problem: bookstack_work had vanished, and
checking how uninstall selects containers turned this up. To be clear about
attribution — this bug does NOT explain that disappearance. The log shows an
explicit uninstall of bookstack_work, including its own install folder and log,
which container-level over-matching cannot do. I could not attribute that
removal to a specific command and am not going to guess; the instance has been
recreated.

Verified: with the fix, `libreportal app stop bookstack` stops bookstack and
bookstack-db and leaves bookstack_home and bookstack_test running. Before it,
all six went down. All four Bookstack apps and Stoat serve 200 afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:41:41 +01:00
librelad
d09b21eec1 feat(updater): probe any OCI registry, not just Docker Hub
Version discovery spoke only hub.docker.com, and every other registry
got a shrug: updaterTagExists returned "no" and updaterRegistryTags
returned nothing. Five apps live off Hub — stoat and wireguard on
ghcr.io, bookstack and speedtest on lscr.io, invidious on quay.io — and
for all of them the updater reported "up to date" having never asked.
That is the same dishonesty as a scan that never ran: an absence of
evidence rendered as a clean bill of health.

There was never a barrier, only unwritten code. The standard
Distribution API needs one extra step: request, read the
WWW-Authenticate challenge, fetch a token from the realm it names,
retry. ghcr.io, quay.io and lscr.io all answer anonymously for public
images — lscr.io by pointing its realm at ghcr.io, quay.io by not
challenging at all.

Docker Hub deliberately keeps its own path. hub.docker.com returns tags
NEWEST-first, so the 100 it pages are the 100 that matter, and it draws
on a different budget from the pull limit — registry-1.docker.io
manifest reads count against the anonymous 100/hour that the updater
needs for actual pulls, and a ladder probes a tag per rung.

Tag LISTING off Hub is a weaker signal and the comment says so: /v2/
tags/list is lexical, not newest-first, and large repos cap the page, so
the newest release can legitimately be absent. Probing backfills it,
which is why the probe fallback added earlier matters more off Hub than
on it.

Verified against all four registries: existence probing correct on eight
cases including true negatives; stoat climbs v0.15.0 -> v0.15.1 through
ghcr.io, and correctly reports nothing above v0.15.1 — the same answer
as before, but now because it looked. Hub unregressed: matrix still
resolves v1.158.0 -> v1.159.0 and nextcloud still ladders 31 -> 32 33 34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:38:56 +01:00
librelad
770492b7c7 Allocate IPs per missing service, not all-or-nothing
Not a flake. IP allocation lived in the else-branch of "did the database return
any rows for this app", so it ran only when the app held ZERO rows. An app with
even one row skipped the loop entirely, and a service without a row never got an
IP and never would. Its IP_TAG_<n> stayed unfilled, the literal IP_DATA_<n>
reached the compose, and docker refused the app with

  invalid IPv4 address: ParseAddr("IP_DATA_3")

which surfaced as "no container started (image pull failed?)". Nothing repaired
it: reinstalling re-ran the same skip, so the app stayed broken until someone
uninstalled it and wiped the rows.

Partial state is not exotic — an app that GAINS a service in a later version hits
this on its very next install, because the old services still hold rows. That is
the case worth worrying about; Stoat only got there by being installed and
uninstalled repeatedly.

Reproduced deterministically by deleting one row from a healthy 16-service Stoat:
the install reported "No IP allocated for service: stoat-rabbit" as a NOTICE,
then "Success: Updated 15 IP tag system", then failed at compose. After the fix
the same broken state self-heals — "Allocated IP: stoat/stoat-rabbit" — with no
uninstall.

Three more bugs in the same path, all found while tracing it:

- ipFindAvailable tested pool membership with a substring match against the
  newline-joined list of allocated IPs, so .4 read as taken whenever .46 or .147
  existed. Demonstrated: with 3 addresses allocated it excluded 5. Harmless at
  low occupancy, but it silently shrinks the pool as it fills and would report
  exhaustion early. Now an exact whole-line match.

- ipFindAvailable set available_ip="" on an exhausted pool and carried on to
  index the empty array, where RANDOM % 0 is a division-by-zero that would bury
  the real message. ipAllocation did the same and still ran its INSERT, writing
  a row with an empty resource_value — which then satisfied "this service has an
  allocation" forever after, making the service unrepairable. Both now return.

- first_allocated_ip was only assigned inside the allocate branch, so on every
  reinstall (where rows already exist) it came out empty and the trusted-domains
  list shipped with a hole. Now taken from the mapping.

An unfilled tag is also an error rather than a notice now: the compose is
unshippable at that point, and reporting "Success: updated 15 IP tags" is how
this reached the user as a confusing pull failure several steps later. The
install backstop no longer guesses "(image pull failed?)" either — that guess
was written for one cause and misdirects for every other.

Verified: clean install allocates all 16 with no unfilled tags, a deliberately
broken row self-heals, and no duplicate IPs exist across any app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:35:19 +01:00
librelad
aa4f1f1d9c fix(config): reconcile restores port columns a truncated row dropped
The port editor used to serialise ten of the twelve columns, so saving
any port on an app silently discarded that app's Traefik subdomain and
the router fell back to the app-name default. Navidrome lost "music" and
Speedtest lost "speedtest" exactly that way, and nothing reported it —
the app kept working, on the wrong hostname.

The writer is fixed, but an install already carrying the damage would
keep it forever: reconcile preserves the user's value, and a truncated
row IS the user's value as far as it can tell. It now tops such a row up
from the template, appending ONLY the columns the live row does not
reach. Everything the live row states wins — including a deliberately
blanked column — so clearing a subdomain is not undone, and a live row
longer than its template is left alone.

Two faults of my own, caught while testing it end to end:

The notice was printed on stdout. This function returns its result
through a command substitution, so the notice text was captured INTO the
config value and written to navidrome's descriptor. It goes to stderr.

The width in the message was measured after the merge, so it reported
the post-merge count as the "before". Captured up front instead.

Verified against a live install: truncating navidrome's row to 9 columns
and running `config check` restores it to 11 with "music" intact, a
second run changes nothing, and no port on the box is left Traefik-
managed without a subdomain. Unit-checked that it declines to act on a
complete row, a non-port key, a row longer than its template, and never
overwrites a live value; quoting style is preserved either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:32:17 +01:00
librelad
45ee27a0d6 refactor(ports): nine columns is the floor; refuse the legacy layouts
The parser accepted five shapes. Three of them (9, 10, 11/12) differ only
by trailing columns that have sane defaults, and those are worth keeping:
39 of the catalogue's descriptors stop at nine because they are
non-Traefik ports — DNS, SMTP, WireGuard UDP — with no subdomain to
state. A short row there is a complete row.

The other two were different animals. The 8-column legacy layout has no
login column and the 7-column one has no parent either, so they SHIFT
every position rather than omitting a tail: whenever the length was
misread, each field after the shift silently took its neighbour's value —
a port's access type reading from its protocol, and so on. That is the
same class of fault the word-splitting bug in this file just caused, and
it is invisible when it happens.

Nothing needs them. All 74 descriptors in the catalogue carry nine or
more, as does every one on this install. So they are refused now, with a
notice naming the offending key: a skipped port is visible, a mis-parsed
one is not.

Checked that skipping a row cannot misalign the parallel arrays —
port_config_data and port_config_vars are appended before the branch, but
neither is ever indexed alongside the others; the former is only tested
for emptiness.

Verified across every shape: 9, 10, 11 and 12 parse with the right
defaults, a label containing spaces survives intact next to an empty
trailing column, and both legacy layouts are refused rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:19:03 +01:00
librelad
a1290b47a3 fix(ports,config): stop losing columns in the port descriptor
Four faults, all in the same 12-column format, all silent.

The bash parser split with `local parts=(${value//|/ })` — replacing
pipes with spaces and word-splitting. That broke the format two ways at
once: a label containing a space became several fields, and an EMPTY
column collapsed rather than being kept, shifting everything after it.
Stoat's LiveKit row parsed as label "LiveKit", url_path "voice/video",
subdomain "(TCP", recommended "fallback)". Rocket.Chat's subdomain only
landed correctly because the extra label word and the collapsed empty
column happened to cancel out. The column COUNT was wrong too, so the
9/8/7-col compatibility branches were chosen from an inflated number.
Now an IFS read, which keeps empties and never word-splits.

The port editor had two serialisers and they disagreed. buildPortConfig
writes all twelve columns; updateIndividualPortFields wrote ten, dropping
subdomain and recommended — so saving ANY port on an app silently
discarded that app's Traefik subdomain. That is how Stoat's live config
came to differ from its template, which still had "stoat".

Both readers gated the subdomain on twelve columns, but subdomain IS
column eleven — so the canonical 11-column descriptor every web app
ships never surfaced one. The bash side reads it from nine.

Lastly, findMatchingCFGKey could not see a generated-value slot suffix.
Passwords LibrePortal generates are stored as CFG_<APP>_<NAME>_<n>, and
ADMIN_PASSWORD_1 neither equals ADMIN_PASSWORD nor ends with
"_ADMIN_PASSWORD", so a generic mapping matched an app's admin EMAIL and
missed its admin PASSWORD entirely: the field simply never rendered
unless someone had hand-written a per-app mapping. Now resolved as a
last resort, after every exact and whole-word match has failed, lowest
slot first. Plus a generic ADMIN_USERNAME mapping, since ADMIN_USER is a
different field name and correctly does not match it.

Audited all 74 port descriptors across the catalogue: none are
malformed. 39 sit at 9 columns, which is a documented, supported shape
(url_path/subdomain empty, recommended defaulting to the webui flag) and
they are all non-Traefik ports — DNS, SMTP, WireGuard UDP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:48:08 +01:00
librelad
93ec260298 Provision a Stoat owner account, add create/reset user tools
Stoat shipped with no account and no way to make one from LibrePortal. It is
first-come-first-served, with invite_only=false, no captcha and no email
verification, so every install left a window between the API answering and
someone signing up in which anyone who could reach the port could take the
instance. The installer now claims the configured account as soon as the API
responds, and prints the credentials instead of "go and register".

Provisioning goes over HTTP, not Mongo: an account needs a login AND a
completed onboarding (accounts holds one, users the other) and passwords go
through Stoat's argon2 layer. Failure is deliberately non-fatal — it leaves the
instance exactly as it was before this existed, which must not fail an
otherwise good install of sixteen containers.

Both obvious config defaults are rejected by Stoat, which is only visible as a
failed install, so both are chosen against its rules: example.com comes back
DisallowedContactSupport (reserved domain) hence admin@stoat.local, and "admin"
comes back InvalidUsername (reserved) hence "administrator".

Two of the three missing adapter operations are now implemented:

- createUser: create, log in, complete onboarding. Without the last step an
  account can sign in and then sits on a pick-a-username screen forever.

- setPassword: previously excluded because hand-rolling argon2 risks writing a
  hash nothing can verify, locking the holder out with no error at the time.
  That objection is answered by refusing to hash at all — authifier already
  owns a reset flow, so this writes only its password_reset token to Mongo and
  lets PATCH /auth/account/reset_password do the hashing with the same code
  that verifies. Verified: reset by username and by email, new password logs
  in, token consumed.

setAdmin is still NOT implemented, and the header now says so with evidence
rather than assertion. Stoat has no instance-level admin flag: the user
document holds only _id/username/discriminator and GET /users/@me adds only
relationship and online. Permissions are per-server bitfields on server_members.
A "make admin" button would invent a concept the app does not have.

Also fixed two things found while testing:

- post_start returned early when the public URL needed no settling, which
  skipped everything after it — so provisioning would have been silently
  missed on exactly the domain-backed installs that guessed the URL right.

- _stoatBaseUrl advertised $public_ip_v4, the WAN address from an external
  resolver, in URLs compiled into the web client. Same fix as the APP_URL
  processor: prefer $local_ip_v4, since LibrePortal never forwards ports.

Verified end to end on a clean install: the owner account is created and
onboarded, the generated password logs in, both new tools run through
`libreportal app tool`, and a created account survives a password reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:37:32 +01:00
librelad
8a997e14dd fix(updater): move lock-step services together, and find the anchor at all
updaterSetAnchorVersion located the anchor by looking for a service
literally named "<app>-service". That is a convention, not a rule:
matrix names its anchor service matrix-synapse and stoat names its api.
For those apps nothing matched, so the rewrite changed no lines and the
upgrade aborted at step 2 with "could not set version" — after having
already taken a snapshot. Dry runs never showed it because they return
before that step. It now finds the anchor by its bare <APP>_VERSION_TAG
sentinel, the same way updaterPrimaryImage does.

It also moved only the anchor. Some apps are one product shipped as many
images: stoat is nine stoatchat services released together, all on
v0.15.1, expecting matching versions of each other. Stepping the anchor
alone would have put api on v0.16 while events stayed on v0.15.1 — the
exact mismatch that once justified keeping the app off automatic
updates.

The lock-step set is DERIVED from the compose rather than configured,
because the compose already states it: a service moves with the anchor
when it carries a version sentinel, sits on the SAME tag, and shares the
anchor's registry namespace. Both tests are load-bearing and each
rejects a real case — livekit-server is same-namespace but on its own
cadence, for-web is pinned to a commit hash, element-web is a different
namespace entirely, and mongo has no namespace at all. Verified against
copies of five composes: stoat moves all eight sibling services and
nothing else; matrix, rocketchat and nextcloud move exactly one image.

Every sentinel that moved gets its CFG_*_VERSION key set, not just the
anchor's, or the next config-driven regeneration would quietly pull the
locked-step services back to the old version.

One trap worth naming: quotes were stripped with sed 's/["\047]//g',
but \047 is an octal escape awk honours and sed does not — in a sed
bracket expression it is the literal characters \ 0 4 7, so it deleted
every 0, 4 and 7 it saw and v0.15.1 arrived as v.15.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:00:06 +01:00
librelad
64ff5f508b feat(updater): verifiers for Matrix, Mattermost and Rocket.Chat
GATE 1 refuses to ladder an app that cannot prove a rung landed, and
only mastodon, nextcloud and stalwart could. None of those are installed
here, so the stepped upgrade — button or automatic — was unreachable for
every app on the box.

Three fixes.

_updaterPrimaryContainer assumed the container is "<app>-service". It is
a convention, not a rule: matrix names its anchor service matrix-synapse
and stoat names its api (container stoat-api). The verifier therefore
inspected a container that does not exist, saw no state, and could only
time out — on exactly the stateful apps that most need verifying. It now
reads the anchor service's container_name from the compose, buffering
per service block because container_name may sit either side of the
image line.

Added updaterVerifyHttpVersion: poll the app over its PUBLISHED port
from the host, pull the version from a JSON field or a response header,
and require agreement three polls running. Probed from the host rather
than `docker exec … curl` because half these images ship no curl at all
(mattermost is one), so exec-based probing is a coin flip on the
vendor's base image. Version comparison matches only the components both
sides state, since tags and self-reported builds rarely share precision:
v1.158.0 vs 1.158.0, 11.9 vs 11.9.1, 8.7.0 vs 8.7 all agree; 11.9 vs
11.10 does not.

Each app hook is then three facts. Verified live: all three confirm at
the version they are actually on, and all three REFUSE a version they
are not — which is the property that makes stepping them safe.

updaterUpgradeAuto now skips apps with no verifier instead of queueing a
task that GATE 1 will reject, which would otherwise mean a failure
notification every day for an app that was never eligible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:04:46 +01:00
librelad
c3494f7d19 Make CFG_SEARXNG_THEME actually apply
The hook substituted `simple_style: auto`, a line that only exists in SearXNG's
full bundled settings.yml. The file generated here is the minimal
`use_default_settings: true` form with no ui: block at all, so the sed matched
nothing and the theme setting had never taken effect on any install.

It could not have worked even with the right pattern: the entrypoint chowns
settings.yml to searxng:searxng (uid 977) mode 644 on first start, so the
host-side docker user cannot write to it. The edit now runs inside the
container via docker exec, targeting the real key path
ui.theme_args.simple_style.

Three shapes are handled so the hook stays correct on repeat installs and
alongside hand edits: substitute in place when simple_style already exists,
nest theme_args inside an existing ui: block rather than appending a second one
(a duplicate YAML key SearXNG refuses to load), and otherwise append the whole
block. All three were exercised against the running container and produce valid
YAML with exactly one ui: block. awk rather than `sed a\` for the nesting case,
since busybox sed does not expand \n in appended text.

The value is validated against auto|light|dark|black before being written.
SearXNG checks it at startup and exits on anything else, so an unrecognised
CFG_SEARXNG_THEME would have taken the app down instead of merely looking
wrong; it is now reported and the default left alone.

Verified end to end on a base install and a --local instance: both come up,
serve 200, and report Dark as the selected style on /preferences, each with its
own settings.yml and secret_key. The instance's cloned hook correctly reads
CFG_SEARXNG_PROBE_THEME and targets its own container, since the container name
is built from $app_name. Both test installs were removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:58:08 +01:00
librelad
e9fceeab99 Enable multi-instance on vaultwarden and searxng
Both now clear every guard: services are <type>-prefixed, and their host ports
became random in the previous commit.

Enabling them surfaced a real bug that would have made vaultwarden instances
fail to start, found by dry-running the clone path before trusting the flag.

Eight apps define an app-specific compose-tags hook named with the app as a
SUFFIX — appSetupComposeTags_vaultwarden — and docker_config_setup_data.sh
dispatches it as appSetupComposeTags_${app_name}. The tools rewrite only
renamed the <type>_ PREFIX form, so a clone kept the base name: it defined a
function nobody calls (colliding with the base app's), its ADMIN_TOKEN and
SIGNUPS_ALLOWED tags were never filled, and the pre-start guard would have
refused to launch the instance. Now renamed, anchored on the () of a definition
so only real function names are touched.

The same hooks pass tag NAMES as strings ("VAULTWARDEN_ADMIN_TOKEN_1_TAG"),
invisible to the lowercase renames, while the cloned compose had already moved
to <SLUG>_..._TAG. Those are rewritten too, mirroring compose rule 4. Verified:
the tags the cloned hook sets now match the cloned compose exactly.

Also affects matrix, nextcloud, speedtest, pihole, gluetun and wireguard, which
ship the same hook shape — latent for those, since none are enabled.

WebUI: the instance bar on app details rendered nothing at all for apps without
instance support, which reads as "this build has no instance feature" and sends
people hunting for a setting that isn't missing. It now states the reason where
the pills would be, and names the blocking ports when it can — the port rows
are in the config the frontend already holds, so it mirrors
_instanceCheckPortsInstanceable (skipping disabled and random rows). The other
blocker lives in the compose, which the frontend never sees, so that case is
left unexplained rather than guessed at.

Bookstack's rewritten compose and tool tree remain byte-identical to the
running instances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:35:33 +01:00
librelad
39dad00455 Give vaultwarden and searxng random host ports
Both pinned an arbitrary host port — vaultwarden 8201:80, searxng 8083:8080 —
which was the only thing blocking them from being instanced. Neither number is
meaningful the way pihole's 53 or stalwart's 25 are, so both become
random:<internal> and portAllocate assigns each install (and each future
instance) its own. The ports appeared nowhere else: no hook, no compose, no
docs. Neither app is installed on the maintainer's box, so nothing to migrate.

Both now clear every instance guard. Of the eight apps the port guard caught,
that leaves six, all genuinely one-per-host.

Also made compose rewrite rules 2 and 3 skip commented lines, for the same
reason rule 1 already does. Spotted while verifying the above: vaultwarden
parks an optional exporter behind #, and rule 2 rewrote the container_name
inside that dead block while the service key above it kept the old name,
leaving it internally inconsistent. Harmless — rule 2 is anchored on
container_name: so it could never reach the image line — but there is no reason
to touch a commented block at all. Bookstack's rewritten identities remain
byte-identical to the running instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:25:50 +01:00
librelad
ef02b48966 Refuse instancing an app that pins a fixed host port
Audit of the per-app install hooks for singleton assumptions. The naming work
so far made identities unique, but a second copy still has to bind its own
ports, and `8201:80` is the same 8201 for every instance — the second container
simply fails at compose-up. `random:<internal>` is what makes an app
instanceable, since portAllocate then hands each instance its own host port.

Seven apps are caught: pihole (53 tcp+udp), stalwart (25/465/587/993), unbound
(5335 tcp+udp), traefik (443), searxng (8083), vaultwarden (8201), stoat
(7881). The message distinguishes the two cases, because they need opposite
fixes: an arbitrary pin like vaultwarden's 8201 should just become random,
while a DNS server on 53 or a mail server on 25 is genuinely one-per-host and
should never be instanced.

Runs before anything is cloned — this is a property of the app, not of the
instance. Bookstack is unaffected (all its ports are already random).

The rest of the hook audit found nothing further to fix:

- No hook writes to another app's config or deployed directory. The three that
  reference ${containers_dir}traefik / headscale only test [[ -d ]] to detect
  whether those are installed.
- Only two hooks read a foreign CFG_ namespace, and both are system-wide
  settings (CFG_DOCKER_INSTALL_TYPE, CFG_ENABLE_VIDEO), not another app's.
- No app declares a fixed container IP; all come from IP_TAG allocation.
- Host-level writes are limited to wireguard's sysctl IPv4-forwarding drop-in
  (global and idempotent) and its /etc/wireguard/params conflict probe. Traefik
  writes only under $containers_dir$app_name. Stalwart's /etc/stalwart path is
  inside its container.

Not mechanically checkable, so left as maintainer judgement: gluetun is a
network provider other apps join via network_mode container:gluetun-service,
and it plus wireguard hold NET_ADMIN and /dev/net/tun. Both are one-per-host
for reasons no guard can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 05:36:46 +01:00
librelad
64344bc5dc feat(updater): step apps to the next version automatically, one rung a day
Two halves: the ladder could not climb the commonest versioning scheme,
and nothing ever climbed it on its own.

The ladder stepped by bumping a tag's LAST numeric component, so
v1.158.0 went v1.158.1, v1.158.2, … and never arrived at v1.159.0. It
then failed closed, refusing to build a path. Synapse publishes
v1.159.0 and no v1.158.1 at all, so Matrix could not be laddered by the
button either — three-part semver minor bumps were simply unreachable.
updaterNextRung now considers a bump of every component, keeps the
candidates that exist upstream and takes the smallest: the next release
by definition, whether it lands in the patch position or crosses into a
new major. Shape discipline is unchanged, so 31-fpm-alpine still never
becomes 31-apache, and each rung is still probed, so none can be
skipped. updaterTagBumpAt moves here from the scan, its natural home,
which also breaks a source cycle.

updaterUpgradeAuto then climbs at most ONE rung per app per calendar
day, inside the install window, for apps set to auto. One rung because a
ladder run unattended can be several migrations deep before anyone
looks, and "restore the snapshot from a minute ago" stops comforting
once four have stacked; one a day so there is time to notice. It crosses
a major if that is genuinely the next release — refusing would strand an
app on the last version of its line forever — but one step at a time,
never as a leap. Two stamps: the target rung (a failure is not retried
until something newer ships) and the day.

Every rung goes through updaterUpgradeApp unchanged, so GATE 1 still
refuses any app without a real verifier, and the per-rung contract is
identical to the button: snapshot fail-closed, set version, pull, up,
verify, restore that rung and stop on any failure. History now records
the trigger instead of hardcoding "manual", including on the rollback
paths. CFG_UPDATER_LADDER_AUTO gates the whole thing separately from
CFG_UPDATER_AUTO, because "keep my apps patched" and "move my apps
between versions unattended" are different appetites for risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 04:55:51 +01:00