`sign_nip98` read the node's pubkey through `get_nostr_pubkey`, which goes
through `load_or_create_nostr_keys`. On a node with no Nostr identity that
does not fail — it GENERATES a keypair, writes the secret to disk, and signs
with it. So an IndeeHub auth header could quietly create a new node identity
as a side effect, then authenticate as a stranger holding a key nobody has
ever seen. The `.context("node has no Nostr identity")` guarding the call
could never fire, because the call could never fail that way.
`nostr_identity_exists` is the missing distinction: bootstrap may create,
but anything AUTHENTICATING as this node must prove the identity it already
has. sign_nip98 now gates on it and bails loudly.
Caught by `a_nip98_event_names_the_exact_url_and_method`, which asserts
exactly this ("must fail loudly rather than sign something empty") and had
been failing since the file landed in 58c759c1 — invisible because the
earlier runs on this branch filtered to `container::`.
Full bin suite 1381/1381.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewing the rotation against what this dev node actually did to LND today —
25 restarts, most of them automatic — surfaced a race the code did not defend
against. Between "stop LND" and "start LND" the rotation owns a stopped
container whose credential material is being deleted, and two background actors
step in there unasked: the health monitor restarts any container it finds
stopped, and the reconciler starts one whose unit is enabled.
Either brings LND back up mid-deletion. LND re-mints macaroons.db on unlock, so
the deletion loop would race a live process writing that file, or "succeed"
against material that had already been regenerated — and the operator would be
told they had rotated while the old root key was still in service. That is the
one outcome this feature exists to make impossible.
It now holds `app_ops::op_lock("lnd")` for the whole rotation. That is the lock
both actors already consult (`lifecycle_op_in_flight`; the health monitor
reaches it through `lifecycle_op_covers_container`), and it additionally
serialises against the package.start/stop/restart workers, so "Restart" on
Lightning mid-rotation queues instead of interleaving. A rotation requested
while one of those is in flight fails fast with a short explanation rather than
waiting silently behind an operation that may itself take minutes.
Deliberately NOT the `user-stopped` marker `recreate_wallet_destructively` uses
for its own window. That marker is a file on disk: a rotation that died between
marking and clearing would leave Lightning suppressed permanently, fixable only
by finding and editing JSON on the node. A lock guard releases when it drops, on
every path including a panic.
Also mocks the three RPCs in mock-backend.js, so the Settings section can be
driven end-to-end without a node — the dev preview otherwise shows only a load
error. The mock advances one step per poll rather than on a timer, which is
deterministic and makes every intermediate state observable.
Verified: cargo check + fmt clean, 6/6 rotation tests, 12/12 component tests,
mock-rpc-parity unchanged (its 2 failures are the in-flight Reticulum panel, not
this), and the three RPCs driven against the live mock through the full arc —
idle → started → 7 steps → ok with the channel count preserved, plus both
password-rejection paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AIUI has carried 353/356 as a known-open item (W1.7) for long enough that
the three reds were treated as background noise. All three were the tests
being wrong. 357/357 now.
`injects web results into system prompt when enabled` asserted
`body.webSearch === true`. The code deliberately sends false there:
`proxyWebSearch = webSearchEnabled && !clientSearchSucceeded`, so when the
client-side search has already run and injected its results into the system
prompt, asking the proxy to search again would be a second redundant search
on every turn. The assertion predates that change. Fixed, and the other half
of the contract added as its own case — zero client results must still ask
the proxy to search.
The two seed-songs failures ("extracts 10 songs", and the conversation
regression built on the same fixture) were one wrong number:
`expected: { songs: 10 }` against an assistantResponse containing exactly
six `song_ext` entries and ending coherently on Treefingers. Not truncated —
just miscounted. The extractor was returning the right answer the whole
time. Counted from the fixture rather than from intent, so the number now
describes the input instead of contradicting it.
Full branch state after the main merge: AIUI 357/357, neode-ui 972/972
across 114 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e36f36ee re-encoded the chat backdrop to webp (1052K -> 478K) to fix "the
background takes ages to load", but left the JPG in place. ChatPage.vue
references only `bg-intro-3.webp`, so the 1053K JPG has been shipping in
every AIUI bundle, every deploy and every ISO since, referenced by nothing.
Checked before removing, because the same filename IS live elsewhere:
`appgate/mod.rs` serves `bg-intro-3.jpg` as one of four LOGIN_BACKGROUNDS,
but from `/opt/archipelago/web-ui/assets/img` — the neode-ui copy, which is
untouched here. Only AIUI's duplicate goes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: "`files` context request times out". sanitizeFiles makes three
sequential calls into the File Browser app — login, getUsage, listDirectory
— wrapped in a try/catch. A catch only sees a REJECTION. A socket that
connects and then says nothing leaves the promise pending forever, so
handleContextRequest never posts a `context:response` and the AIUI side sits
until its own bridge timeout instead. The File Browser is a plausible source
of exactly that: on this node `/app/filebrowser/api/resources/` does not even
route (404), and its session-cookie path is the subject of a separate open
bug.
The guard goes at handleContextRequest rather than inside sanitizeFiles, so
no category — present or future — can strand the bridge. `files` is merely
the one with three network hops today; sanitizeSystem is also async.
withTimeout resolves rather than rejects, because the caller's one job is to
always answer, and a rejection would just relocate the problem into a catch.
A late null is safe by the protocol's existing shape: the AIUI reader
already treats a response with no usable data as "nothing to show", the same
as an empty category.
Two tests: a never-settling File Browser still produces a
`context:response`, and a healthy category still returns real data rather
than being flattened to null. 27/27 contextBroker, vue-tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges gitea-ai/main (65 commits) into the phase-13 branch (419) so one
build carries both lines — the AIUI/assistant/container work and the
open-source readiness work (licensing, the marketplace DID signature layer,
the registry domain migration, the secrets and infrastructure scrub).
Every Rust file auto-merged. The container fixes from this branch and main's
registry-domain migration and node-name genericisation coexist without
manual intervention.
Conflict resolution — all of them were modify/delete, and all were resolved
in main's favour deliberately:
`.planning/**`, `scripts/deploy-to-target.sh` and `scripts/setup-aiui-server.sh`
were deleted by main's `6ba05996` ("security: remove all infrastructure and
internal process material from the repo") and added to .gitignore there.
Keeping this branch's copies would have re-committed internal process and
infrastructure material into a repo being prepared for publication, silently
undoing that cleanup. Resolved with `git rm --cached`, so every file remains
on disk locally and in this branch's history — it is untracked, not lost.
The remaining .planning files this branch added after the merge base were
untracked the same way, so the result is consistent rather than half-tracked.
Container suite 221/221 on the merged tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13-15's gate requires the DEPLOYED surface to be checked, not only the
source. That half is currently void: archi-dev-box runs a binary dated
2026-08-08 03:20 built from main, not this branch — no `app_uninstall` in
`strings`, and the ownership hooks chown unconditionally with no drift-gate
`stat` calls, so b9e64eb6/db8937f9/ca106c5a/b8869307 are all absent. Every
row of the acceptance table was verified against a binary the node no longer
runs, 417 commits back.
Also corrects row 2. The record captured scope `own` only, which cannot
discharge check 2's "real peer/owned files"; and the 2026-08-06 note saying
peers/owned "exist only in type signatures" is obsolete — 05b459a6 and
9abc1623 made requestArchyAllContent fetch all three scopes from init(),
deduped through one sink, which the operator confirmed on 2026-08-08 is the
intended auto-load-at-init behaviour. Check 2 is code-complete and
verification-pending, not unbuilt.
Records the ordered steps to actually close the phase, and the pre-deploy
orphan-companion evidence snapshot the reaper will consume.
The phase stays OPEN. Nothing here closes 13-15.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
archi-dev-box logged `reconcile failed app_id=btcpay-server error=chown
/var/lib/archipelago/postgres-btcpay failed with status exit status: 1`
while BTCPay was running and healthy and there was nothing to repair:
`find /var/lib/archipelago/postgres-btcpay ! -uid 100998` returns zero
files, and the identical command run by hand exits 0. The chown through
`sudo systemd-run` had simply failed once, and that transient failure
propagated out of the pre-start hook and took the app's entire reconcile
with it.
These hooks exist to repair OLD installs. On a healthy node the repair is
already a no-op, so its failure is not evidence of anything being wrong.
repair_dir_ownership folds the gate, the chown and the verdict into one
place: skip when ownership is already right, chown when it is not, and on a
failed chown RE-PROBE before deciding it matters. If the ownership is
correct anyway — a concurrent repair, or a transient sudo/systemd-run
failure on an already-correct tree — warn and continue. Only a chown that
fails AND leaves the ownership wrong is an error, which is the case the
loud failure was written for: a mis-owned volume the app genuinely cannot
open.
Replaces the three hand-rolled gate+chown+bail blocks in
ensure_btcpay_stack_dirs and the one in ensure_fedimint_dirs.
Container suite 215/215.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writing the first tests for this section found the bug they were written to look
for. `rotate()` started the poll, then the `load()` immediately behind it took a
status snapshot that did not yet carry `running: true` and cancelled the interval
— so the screen froze on the one action that most needs to show progress. The
operator has just invalidated every credential their wallet holds, the rotation
is genuinely running on the node, and the page tells them nothing is happening
until they reload it by hand.
It survived manual review because the backend flips `running` inside the same
critical section that accepts the request, so the happy path usually wins the
race. "Usually wins a race" is not a property to ship on a credential rotation.
Polling now continues for a bounded window after a request the node accepted,
and stops early as soon as `running` is observed. Bounded, so a request that was
accepted but never acted on stops polling rather than hammering the node.
12 component tests cover the states that carry consequences: the channel census
shown before the button is offered, the stale-BTCPay warning, the difference
between "BTCPay has no internal node" (silence — an absence, not a fault) and
"BTCPay's credential is dead" (a warning), the block on rotating while LND is
unreachable, both poll races above, and that an idle tab does not wake the node.
Verified: 12/12 new, 880/880 frontend tests, vue-tsc clean, and the rebuilt
bundle contains the new strings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mint wait and the post-rotation verify shared one 15-minute budget. A
rotation that legitimately spent 14 of those minutes waiting for LND to mint a
fresh macaroon — normal on a loaded node, where opening channel.db/graph.db/
wallet.db alone has been measured at 2m38s — then had 60 seconds to confirm the
node identity and channel census came back, and would report FAILURE on a wallet
that was completely healthy.
That is the most alarming possible way to be wrong about someone's Lightning
node: it names a backup directory and tells them to investigate before retrying,
at the exact moment nothing is actually broken. Each wait now gets its own
budget. Waiting longer costs nothing here — the failure this step exists to catch
(changed identity, missing channels) is not time-sensitive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open-source readiness plan, Phase 2.
The `indeedhub` submodule points at a Gitea repo that is not public and carries
no known licence (the licence audit defers it: "partnership in place; license
the submodule before/at public release"). An outside developer running
`git clone --recursive` today either fails on auth or pulls unlicensed code —
a bad first five minutes with the project either way. It was never checked out
in this tree.
Removing it costs nothing, because nothing builds from it:
- `indeedhub-demo/Dockerfile` states in its own header "No submodule or local
source needed" and clones the public GitHub mirror instead.
- Every other `indeedhub/` reference in the tree is `apps/indeedhub/` — the app
package — which is a different path and untouched. The app itself ships as a
container image from the registry and is unaffected.
Kept `indeedhub-demo/` rather than dropping it as the plan suggested: it is a
working, self-contained demo build with no submodule dependency, which is
exactly the shape the rest of Phase 2 is moving toward.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open-source readiness plan, Phase 2. Removed (~17 MB, 12.8k lines):
- `.githooks/pre-push` — the hook that re-committed the 27 MB companion APK on
every push, which the plan names as the root cause of the 5.5 GB history.
Verified inert first: `core.hooksPath` is unset, so it only ever ran for a dev
who opted in by hand.
- `neode-ui/dev-dist/` — generated vite-plugin-pwa output (a Workbox bundle),
tracked and not ignored. Added to .gitignore so it cannot come back.
- `Android/archipelago-0.3.0-debug.apk.zip` — 16 MB, stale, zero references.
- `RELEASE-NOTES-v1.0.0.md` — superseded by CHANGELOG.md.
- `docs/container-architecture.html` (311 KB) and the two generated archive
HTML artefacts, whose rows are removed from the archive index in the same
commit so the table doesn't point at deleted files.
THREE items the plan lists were verified and deliberately NOT deleted — the
plan is wrong about each, and following it literally would have lost content or
broken a build:
- `neode-ui/docs/GAMEPAD-NAV-MAP.md` is called "a duplicate of
docs/GAMEPAD-NAV.md". It is 660 lines against that file's 159 — four times the
content, not a copy. Needs a human read to decide what to keep.
- `Android/app/debug.keystore` is called "standard practice" to remove. This
repo deliberately commits it: `build.gradle.kts` sets
`storeFile = file("debug.keystore")` and `Android/.gitignore` carries an
explicit `!/app/debug.keystore`, with a comment explaining it exists so every
machine produces the same debug signing identity. Deleting it breaks Android
debug builds.
- The three "move to release assets" binaries are not a pure git operation —
two have live consumers. Detailed in the next message rather than guessed at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Bitcoin Knots disappeared again, plus other apps." Root cause is a pairing,
not a single bug: quadlet renders `podman run ... --replace --rm`, so the
container is deleted the moment it stops, and from_manifest set
Restart=on-failure, which declines to restart after a CLEAN exit. bitcoind
exits 0 on SIGTERM. So any clean stop deleted the container AND left it
deleted — the app vanished from podman and from My Apps until a later
archipelago reconcile tick noticed and recreated it. That is the
"previously-running app has no container after boot — recreating
(desired-state recovery)" line, which fired for bitcoin-knots at 18:53,
19:57 and 20:39 and for electrumx at 19:57 and 20:42 on 2026-08-07.
A crash always self-healed: on-failure restarted the unit and podman run
recreated the container. Only a clean exit stranded it, which is why this
survived so long.
The justification for on-failure was wrong on systemd's own semantics. It
read "clean exits — e.g. operator-issued systemctl stop — stay stopped", but
Restart= is never consulted for a unit stopped via systemctl stop
(systemd.service(5)), and that is exactly how archipelago stops these apps
(prod_orchestrator -> stop_service_with_timeout). Always keeps the
stopped-stays-stopped behaviour and drops the failure mode.
Always also restores the premise of the Quadlet migration — systemd owns
supervision, so an app returns without archipelago alive to notice it left.
Checked before flipping: no manifest declares a one-shot container and there
is no manifest-level restart field, so nothing gets restart-looped.
Propagation to existing nodes is via sync_quadlet_unit's drift re-render,
which rewrites the unit and daemon-reloads WITHOUT restarting the service —
running containers are undisturbed and the new policy governs the next start.
OnFailure is kept as a deliberate opt-in with a note not to wire it back to
backends. Two tests now pin the new default and assert on-failure is absent
from a rendered backend unit.
Container suite 215/215.
NOTE FOR THE OPERATOR: this changes supervision semantics for every app on
the Quadlet canary path. Wants sign-off and a lifecycle-gate run before OTA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rotating LND's macaroons was an SSH-only script, which in practice meant it did
not happen — while a macaroon is a bearer token with no revocation and no expiry,
so anything that ever read one keeps the ability to spend until they are
replaced. Settings → Lightning credentials now does it behind the node password,
shows a step checklist, and refuses to report success unless it has confirmed the
node identity and channel census are unchanged.
Three findings from performing a real rotation on a dev node, each fixed here:
1. BTCPay was left holding a dead credential, silently. Its connection string
carries the macaroon INLINE (LND's datadir is owned by its container subuid,
so btcpay cannot bind-mount the file), and the daemon only regenerates that
secret when LND's TLS cert thumbprint changes — which macaroon rotation does
not touch. Result: btcpay up, LND up, both healthy, every Lightning payment
failing, nothing anywhere saying why.
2. Rewriting the secret is not enough to fix it. `secret_env_hash` makes the
change visible as env drift, but the reconcile loop runs `ExistingOnly` at
boot AND periodically, and there it deliberately leaves running
restart-sensitive apps untouched — observed once per tick for half an hour on
the dev node. So this reuses FED-07's `credential_rotated` carve-out via a new
default-no-op `ContainerOrchestrator::mark_credential_rotated`, on the same
reasoning: restart sensitivity protects apps that are working, and this one is
working only in appearance. The shell script cannot reach an in-process flag,
so it removes the container and lets desired-state recovery rebuild it.
3. LND stayed locked forever on a loaded node. The unlocker is only served after
channel.db/graph.db/wallet.db open, measured at 2m38s on a box running 30
containers; the unlock helper gave up at ~60s. That is not a harmless retry —
reconcile records the post-start hook as failed, restarts LND, and the slow
open begins again, so the wallet never opens and every LND-dependent app stays
broken. The not-ready budget is now ~10 minutes; a genuinely wrong password
still exits on the first pass via `all_rejected`.
Safety properties worth not regressing:
- No macaroon content in any response, error, log line or the polled progress
feed — digests and byte counts only.
- Rotation unlocks via a new `unlock_existing_wallet_no_wipe`, so there is no
code path from "rotate my credentials" to `recreate_wallet_destructively`. A
wallet whose password this node lacks fails the rotation with the wallet intact.
- Channels are compared as active+inactive totals, not `num_active_channels`,
which legitimately dips after any restart while peers reconnect.
- Backup verified by file count before anything is deleted.
Verified: cargo check + fmt clean, 6 new unit tests and the 6 existing
container::lnd tests pass, vue-tsc clean, and the built bundle contains the three
new RPC method names (the frontend build can silently no-op).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
installed_app_ids judged installation on live containers alone. Watched on
archi-dev-box within the hour: lnd read as ABSENT, then as EXISTS again.
Containers on this node come and go — the boot reconciler logs
"previously-running app has no container after boot — recreating" for
bitcoin-knots and electrumx repeatedly — so a momentary gap looked exactly
like a removal, and the reaper would have taken a healthy companion's unit
with it. ORPHAN_GRACE narrows that window but cannot close it: nothing
bounds how long a gap lasts.
An app now counts as installed if its container exists in any state OR its
container name is in the durable last-running snapshot. That snapshot is
what crash_recovery itself calls "installation evidence" and what
reconcile_all_with_mode already trusts to recreate a previously-running app
whose container vanished — the same signal, for the same reason, now shared
rather than reinvented.
Only fedimint is a true orphan on this box: it appears in no adoption list
and has no quadlet unit of its own. lnd is installed and merely flapping,
which is a separate bug.
Container suite 215/215.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both default update mirrors resolve to the SAME host — the primary by name over
HTTPS, the fallback by IP over plain HTTP — while SystemUpdate.vue told the
operator "Servers this node checks for updates. The primary is tried first; if
it's slow or unreachable, the next one in the list is tried automatically."
That promises availability redundancy the pair cannot provide: if the origin is
down, both entries are down. Reported by the operator, who read the list and
correctly concluded the fallback made no sense.
The mechanism is fine and deliberate — it recovers a node whose DNS is broken
or whose clock is wrong, both of which fail TLS while plain HTTP still works,
and it is safe because the manifest carries an Ed25519 signature verified
against the pinned release-root anchor, so transport integrity is not what
protects the update. (That last part only became true once Workstream B pinned
the anchor; before then this fallback would have been a real hole.)
So the bug was the labelling, not the design:
- Backend label "Direct (fallback)" -> "Same server, no DNS/TLS", and the
comment now states plainly that it is the same host, what it recovers, and
that real redundancy needs a different one.
- UI copy now scopes the redundancy sentence to genuine mirrors and adds a
paragraph saying the two built-in entries are one server, what the second
actually recovers, that it does not help if the server is down, why an
unencrypted fetch is acceptable, and how to get real redundancy.
The relabel reaches existing nodes: force_ovh_update_primary rewrites labels for
the two default URLs on every load, while the merge matches on URL and never on
label — without that rewrite path a renamed default would have sat in the code
and never propagated to a single deployed node. Noted inline so it is not
re-broken.
Verified: 40/40 update tests pass (including the mirror load/merge/strip ones),
vue-tsc clean, build green, and the new copy is present in the freshly built
SystemUpdate chunk. Nothing in the tree pinned the old label string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Failed to scroll to index N after 10 attempts" appeared in the console on
every send. It was not a real failure — it was two scroll controllers
arguing.
scrollToBottom() called virtualizer.scrollToIndex(last) AND then assigned
el.scrollTop on the next tick. scrollToIndex runs a retry loop that nudges
scrollTop toward the target row's measured offset and re-checks, up to ten
times, because dynamically-measured rows move the target as they settle.
The manual assignment overwrote each nudge, so the loop never observed
itself converge and always exhausted its attempts.
For "go to the end" the index-settling machinery buys nothing: scrollHeight
already is the bottom, the virtualizer renders whatever window that offset
implies, and it keeps working while a response streams and the last row
grows — the case the manual fallback was added for in the first place.
scrollToMessageIndex still uses scrollToIndex, which is the right tool for
jumping to an arbitrary row.
Console-only change; needs a device check that the chat still pins to the
bottom while streaming.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open-source readiness plan, Phase 1 items 3 and 5.
Item 3 turned out to be far narrower than the plan's "93 files" once each hit
was classified rather than bulk-replaced. Sanitized only genuine operator
identifiers:
- FIPS test fixtures and a pine_ha comment carried real node LAN addresses ->
RFC 5737 TEST-NET-1, the convention already used elsewhere in this repo.
- Real tailnet addresses in fips/endpoints.rs, mock-backend.js and the mesh
test runner -> the base of the CGNAT range, obviously synthetic.
- Incident comments in appgate/mod.rs and apps/fedimint/manifest.yml named a
specific node; the role is what carries the meaning, so the address is gone.
- CHANGELOG.md held five real addresses in published release notes — the most
exposed of the lot.
Deliberately NOT touched, because the plan's item-3 list is over-broad and
following it literally would break working code:
- 192.168.1.1 / .254, 192.168.0.0/16 and 100.64.0.0/10 are generic router
defaults, RFC1918 classification in backup_rpc, and CGNAT range logic in
pine_ha / CompanionIntroOverlay. Not leaked infra.
- `tx1138` is listed as a hostname to scrub but is two live things: the
user-facing default block explorer (`DEFAULT_TX_EXPLORER`) and
`RETIRED_TX1138_HOST`, the migration constant whose entire job is stripping
that retired registry from existing nodes' saved mirror lists. Scrubbing
either breaks a feature. The plan needs this correction.
- Android's `192.168.1.100` strings are UI placeholder text.
Item 5: added *.key, *.pem, id_rsa*, *.sqlite, *.db to .gitignore, with a
negation for core/archipelago/src/appgate/testdata/*.key. Checked those first —
they are documented throwaway TLS fixtures compiled in via include_bytes!, not
node identity — and the negation stops the new rule silently dropping them if
they are ever regenerated. Verified both directions: fixtures not ignored, a
stray key elsewhere caught.
Verified: residual grep for real infra addresses is clean; audit-secrets.sh
still 5/5; app-catalog drift 0 (the fedimint edit is a YAML comment, which does
not survive parsing into the signed catalog); 44/44 fips tests pass with the
rewritten assertion fixtures.
Note: these test runs shared the working tree with another agent's in-flight
LND work, which was present but unstaged and is not part of this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
archi-dev-box was running archy-fedimint-ui and archy-lnd-ui with no
fedimint and no lnd container anywhere on the box. The Fedimint Guardian
UI sat on :8175 serving its "waiting for Bitcoin" page forever with
nothing behind it, which is what the operator reported as "fedimint
guardian installs but does not work" — there was nothing to install, the
UI was already up.
The boot reconciler drove companion provisioning from manifest_ids(),
which is every manifest the node can SEE: the whole apps/ directory plus
the signed-catalog overlay, 56 of them. The app reconciler has drawn this
line since phase 3 (ReconcileMode::ExistingOnly, "merely listing a
catalog manifest never installs an unqualified app"); the companion stage
never got the equivalent guard, so it stood up a UI for every app that
merely had a manifest and then self-healed it forever.
The other half is that reconcile() could only ever ADD. remove_for fires
only on the explicit uninstall RPC, so nothing ever subtracted: an
install that failed after its companion landed, or a container removed
by any other route, left a Restart=always unit alive permanently.
- installed_app_ids() replaces manifest_ids(): app ids whose container
actually exists. Returns Option, because a caller that removes things
on absence must not read "I could not look" as "nothing is installed".
Container presence in ANY state is the whole test — it deliberately
does not inherit the user_stopped/disabled filters, since a stopped app
is still an installed app and treating it otherwise would tear its
companion down and rebuild it on the next start.
- manifest_ids() is deleted rather than left unused. Its contract reads
as "installed" to anyone skimming, which is the whole bug.
- reap_orphans() removes companions whose backend is not installed, after
ORPHAN_GRACE (300s). The grace period is required, not defensive: this
node runs ARCHIPELAGO_USE_QUADLET_BACKENDS=true and a Quadlet app is
briefly containerless while restarting, so reaping on the first absent
tick would cost a healthy companion a teardown plus a possible 900s
image rebuild. A backend that reappears clears its clock.
- Reap failures are logged but kept out of the backoff input. Repair
keeps a companion available; reaping only tidies one away, and a wedged
reap must not back the repair path off to its 1h ceiling.
Every uncertain signal resolves toward not removing: no unit file and a
hung is-active reads as leave-it-alone.
Container suite 215/215.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend verifies DID signatures as of f0c289a4, but the card only rendered
`trust_tier` / `trust_score`, so the verdict reached the frontend and died
there. Adds a badge next to the existing trust pill.
Deliberately a *separate* badge rather than folding into the trust tier: the
score blends relay count, provenance and policy compliance, while this answers
one narrow question — did the author prove control of the key their `author.did`
names. Merging them would hide the distinction that the signature layer exists
to draw.
- `valid` → green "signed" with a lock glyph
- `missing` → neutral grey "unsigned" (an unsigned publisher is unproven, not
hostile, so it reads as absence rather than alarm)
- `invalid` → red "bad signature". Discovery drops these before they reach the
cache, so it should be unreachable; rendered anyway so the UI
fails visibly rather than silently if that ever changes.
Two fail-safe details:
- The mapping defaults a missing field to `{status:'missing'}` rather than
leaving it undefined. A node on an older backend returns no field at all, and
"we couldn't check" must never render as "signed".
- The `invalid` arm is typed in the RPC client for the same reason: an
unhandled status falls through to "unsigned", not to the green badge.
The tooltip carries the meaning the two-word badge can't. "Signed" is easy to
misread as "safe", so it says what was actually proven — who published it — and
explicitly that this is not a statement about the app being safe.
Verified: vue-tsc clean, build green, and the new strings are present in the
freshly built Marketplace chunk (the build can silently no-op).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/marketplace-protocol.md described a full authorship-verification chain and
was marked "shipped end-to-end". It wasn't: `signatures.manifest_hash` and
`signatures.did_signature` existed only as two struct fields that nothing read.
The authenticity actually delivered was the Nostr event's NIP-01 Schnorr
signature — which proves who *relayed* an event, not who *authored* the manifest
inside it. Anyone could republish someone else's manifest under their own DID.
Implemented:
- `canonical_signing_bytes` / `manifest_digest` — the signed preimage is the
manifest as canonical JSON (recursively sorted keys, no whitespace) with
`signatures` omitted, SHA-256'd. Canonicalisation is load-bearing, not
cosmetic: `container.env` is a HashMap with per-process random iteration
order, and `serde_json::Map` is only sorted while the `preserve_order` feature
stays off — a feature any crate in the graph can enable for everyone via
feature unification. Either would make the digest vary between runs, so
signatures would fail *intermittently*, which is far worse to diagnose than
failing cleanly.
- `sign_manifest` / `verify_manifest_signature` — Ed25519 over the 32 raw digest
bytes, verified against the key `author.did` encodes (reusing the existing
`identity::pubkey_bytes_from_did_key`).
- `publish` signs before broadcasting, fills `author.did` when empty, and
**refuses** to publish under a DID this node cannot sign as — otherwise we'd
spray manifests across every relay that every verifier then rejects.
- `discover` verifies before caching. A `missing` signature is a normal
unsigned publisher: listed, but earning no identity trust. An `invalid` one is
tampered or forged, so it is **dropped entirely** and logged — it fails closed
rather than appearing behind a warning badge a user can click through.
Trust scoring now requires proof for both identity-derived factors:
- The 30-point identity factor was `did.starts_with("did:")`. An unsigned
manifest with a plausible DID string and a pinned image scored 65 —
"Community" — on no cryptography at all. It now scores 35, "Unverified".
- **The 20-point federation factor is gated too**, which the original spec did
not say. An unverified `author.did` is just a string the publisher chose, so
without this an attacker could copy the DID of a peer the user federates with
and be rewarded for impersonating the party they trust most.
`marketplace.verify` now returns the signature verdict separately from the
advisory policy issues — `valid` has always meant "passes the advisory security
checks", so conflating it with authenticity would have been its own trap.
Tests (22 pass), weighted to the adversarial cases: tampering; tampering that
also rewrites `manifest_hash` while reusing the stolen signature; signing with
key A while claiming B's DID; undecodable did:keys including the old
`z6MkTest123` fixture that used to score 30/30; malformed base64 and
wrong-length signatures; digest stability across map insertion order; the digest
ignoring the `signatures` block; the federation-impersonation case; and a legacy
cache without the new field loading as `missing` rather than defaulting trusted.
Protocol doc rewritten so the preimage rules are normative — a third-party
implementation that canonicalises differently produces signatures we reject, so
"sorted keys, no whitespace, signatures omitted, sign the raw digest" now has to
be stated exactly rather than sketched.
Not included: surfacing the verdict in Marketplace.vue, which reads only
trust_score/trust_tier today. The field reaches the frontend; where the badge
goes is a UI call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`zbase32 0.1.2` is LGPL-3.0+ — the only hard copyleft dependency in the whole
Rust graph and the last remaining blocker for the MIT release
(docs/LICENSE-COMPLIANCE-AUDIT.md §2). Statically linking LGPL code into a Rust
binary obliges us to ship relinkable objects, which is impractical for a node
image.
The audit offered two routes: the MIT `z32` crate, or an original
implementation. Took the latter — z-base-32 is an alphabet substitution over a
bit stream, so ~60 lines removes the blocker while adding *zero* new
dependencies rather than trading one supply-chain entry for another.
**Byte-compatibility was the requirement, not a nice-to-have.** A `did:dht`
identifier IS this encoding of an Ed25519 public key, so any drift would
silently rotate every node's DID and orphan its already-published DHT records.
So the semantics were not guessed: I read the vendored zbase32-0.1.2 source to
extract exactly what `encode_full_bytes` and `decode_full_bytes_str` do —
including that decode truncates to the next lower byte boundary, which is why a
52-character string round-trips to 32 bytes while discarding 4 padding bits.
A model implementation was then validated against three independent sources
before any Rust was written, all five vectors agreeing:
encode(b"testdata", 64) -> qt1zg7drcf4gn (crate doctest)
encode_full_bytes("Just an…") -> jj4zg7bycfzn… (crate doctest)
decode_full_bytes("qb1ze3m1") -> b"peter" (crate doctest)
encode([f0,bf,c7]) -> 6n9hq (Zimmermann spec)
encode([d4,7a,04]) -> 4t7ye (Zimmermann spec)
The module pins all of those plus four known 32-byte keys, a 0..40-byte
round-trip sweep, a 52-char/round-trip check over 64 keys, rejection of the
characters z-base-32 deliberately omits (`l`, `v`, `2`, `0`) and of non-ASCII,
and an alphabet/decode-table consistency check so the compile-time reverse table
can't drift from the alphabet.
`did_dht.rs` gains `did_for_a_known_key_is_stable`, which pins the full
identifier string for a known key — the regression that would actually hurt,
asserted at the call site that gives the string its meaning.
Dropped from Cargo.toml and Cargo.lock (7 lines); no other user in the tree.
Verified: 28/28 network tests pass, zero copyleft crates remain in the lockfile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audit has claimed since 2026-07-23 that these were git-rm'd. They weren't —
only the web/dist copies went, and all of them were still tracked at HEAD nearly
three weeks later, in a repo about to be published under MIT.
Deleted (~40.7 MB):
neode-ui/public/assets/fonts/Courier_New/{CourierNew-Bold,CourierNew-Regular}.ttf
neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf
neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf
neode-ui/public/packages/wireguard.apk (17 MB)
neode-ui/public/packages/atob.s9pk (23 MB)
Courier New is Monotype proprietary and Benton Sans is a commercial Font Bureau
typeface — neither is redistributable. wireguard.apk carries GPL-2.0 libwg
components, so shipping it triggers a source offer. atob.s9pk is a Start9
package of unknown license. Redacted's upstream is OFL-1.1 but no license text
was shipped; deleting was cheaper than sourcing it, since it was unused.
Verified unreferenced before deleting, not after:
- Every @font-face rule in the tree (2 in src/style.css, 2 in
public/entropy/index.html) loads Montserrat. None of these files was ever
loaded by CSS.
- The three `Courier New` hits (tailwind.config.js `mono`, two public HTML
font-family lists) name the *system* font as a fallback — they are not
@font-face sources, so rendering is unchanged.
- wireguard.apk and atob.s9pk have zero references in any tracked file.
- These live under neode-ui/public/, which Vite copies verbatim rather than
resolving, so their absence cannot break a build.
Deliberately kept: neode-ui/public/packages/archipelago-companion.apk, which IS
live (staged by .githooks/pre-push, the Android release flow, and the in-app
pairing QR); Montserrat (OFL.txt) and Open Sans (LICENSE.txt), both properly
licensed; and neode-ui/test-install.sh, which the same audit line listed but
which is not a licensing concern.
Audit updated: §1 and §3's font/package items marked closed, the false DONE
entry rewritten as a history note rather than deleted — a DONE line here is a
claim and should be re-verified with git ls-tree, which is exactly the lesson.
§2 (zbase32, LGPL-3.0+) is now the last hard blocker.
Side effect: ~40 MB off the frontend OTA tarball.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runbook still opened with "the catalog is accepted unsigned (migration
window) and the anchor is unpinned (RELEASE_ROOT_PUBKEY_HEX = None)". Both have
been true-for-a-while false: `trust::anchor::RELEASE_ROOT_PUBKEY_HEX` is a
`Some(...)` with a verification note in its doc comment, and
`releases/app-catalog.json` carries both a `signature` and a `signed_by`
did:key.
This one matters more than a normal stale status: a reader taking the header at
face value would think the fleet still accepts unsigned catalogs and that the
one-way anchor-pinning door is still open. It isn't — pinning already happened,
so any future ceremony is a *rotation*, which is the case the doc's own warning
about mismatched-signature hard-rejection applies to most sharply.
Marked complete and kept the procedure verbatim below, since it's exactly what a
key rotation or publisher change needs. Also dropped a stale `:21` line number
from the anchor.rs citation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The inverse of the usual drift — these understate rather than overstate, which
is just as misleading for someone deciding what is safe to change.
**dht-distribution-design.md** was headed "Status: Design (no code yet)".
`core/archipelago/src/swarm/` has five modules plus `content_hash.rs`.
**phase4-streaming-ecash-plan.md** was headed "not implemented". `swarm/paid.rs`
states in its own header that it implements "DHT distribution plan, Phase 4 step
F", and there is a `streaming::` module behind five `streaming.*` RPCs
(list-services, configure-service, toggle-service, pay, prepare-payment).
Neither is reachable in a stock build, which is presumably why the headers were
never updated — and that is the part worth documenting rather than eliding. Both
now state the gates: the `iroh-swarm` cargo feature is off by default (iroh and
iroh-blobs are optional deps pulled in only by it), `config.swarm_enabled` is off
by default, and paid serving stays free for everyone until the operator enables
the `content-download` streaming service.
Checked the other plan-only docs for the same error; these two were the only
ones. `nostr-identity-import-plan.md`, `nostr-signer-login-research.md` and
`hardware-signer-design.md` correctly say no code exists — verified: no identity
import or NIP-07 login RPC, and no TROPIC01 reference anywhere in core.
`dual-ecash-design.md`'s "in progress" is right too — the `wallet.fedimint-*`
RPCs exist, no Cashu ones do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2026-07-23 status block lists as DONE: "Deleted: Courier_New/, Benton_Sans/,
Redacted/ fonts; wireguard.apk; atob.s9pk; obsolete test-install.sh (all
git-rm'd)". All seven are still tracked at HEAD and present on disk. Only the
web/dist copies went; the sources never did.
git ls-tree -r HEAD --name-only | grep -iE 'Courier_New|Benton_Sans|Redacted/|wireguard.apk|atob.s9pk'
That means a repo about to be published under MIT still carries a commercial
Font Bureau typeface and two proprietary Monotype fonts — precisely what §3 of
this audit says must not ship. An audit that reports a blocker as closed is
worse than one that never checked, so the entry is now struck through with the
file list and the verification command inline.
Deleting them is safe and I checked before saying so: nothing references the
font *files* (the three `Courier New` hits are CSS font-family fallbacks naming
the system font, not @font-face sources), and wireguard.apk / atob.s9pk have
zero references anywhere in the tree. Left the deletion itself to the operator —
it is 40 MB of tracked binaries and outside a docs pass. Removing them also
takes 40 MB off the frontend OTA tarball, which is a separate open item.
Also re-verified the rest of the remaining list:
- `zbase32` (LGPL-3.0+) is still a direct dep (Cargo.toml:113, did_dht.rs:40,49).
Still the only hard copyleft blocker.
- LICENSE (MIT), NOTICE and both THIRD-PARTY-LICENSES inventories are present —
so the headline "no license of its own" is closed; softened the verdict to say
which blockers remain rather than leaving a stale "not releasable as-is".
- The four StartOS-derived crates still exist; flagged that KEY-05 cites
core/models, so that one needs review rather than a blind delete.
- Item 6 (git filter-repo history purge) is superseded — the launch plan is a
fresh-history publish, so there is no history to rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**ADR-009** lists six "non-negotiable" mandatory security defaults. Checked each
against `core/container/src/manifest.rs` and `core/security/src/`:
- `seccomp_profile: Default` — the string `seccomp` appears **nowhere in
`core/`**. Not as code, not as a TODO. This constraint is entirely fictional.
- AppArmor — `container_policies.rs` generates and `apparmor_parser -r`s a
profile, but its own comment reads `TODO: Configure Podman to use the
profile`. `security.apparmor_profile` parses into a manifest field that
nothing ever reads.
- `user` UID > 1000 — no UID validation exists in the runtime parser at all.
- `image_tag` pinned — preflight script only; the parser accepts `:latest`.
- `readonly_root` / `no_new_privileges` — safe defaults when omitted, but
`validate_security()` never rejects an explicit `false`, so the ADR's
"Reject manifests that violate mandatory defaults" step does not exist.
Genuinely enforced: the capability allow-list and bind-mount confinement (the
latter stronger than the ADR describes). Added an Implementation status section
saying so per-row. The decision stands; the claim of enforcement did not, and on
a security ADR that gap is the whole point of writing it down.
**ADR-004** said Tor carries *all* inter-node communication and runs as the
`archy-tor` container. Neither holds: transport priority is mesh → LAN → FIPS →
Tor (`TransportKind` 1-4, Tor as last fallback, largely because of the latency
this ADR itself lists), and Tor is the host Debian service driven by
`archipelago-tor-helper` — `container-doctor.sh` actively removes an `archy-tor`
container if it finds one, and no `apps/tor` manifest exists. Added an amendment
rather than rewriting the record. Worth flagging that both changes landed
without their own ADR.
All 10 ADRs are Status: Accepted; 001-003, 005-008 and 011 verified consistent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Header claimed the whole 2026-04-22 plan "has been implemented". The
architecture was adopted, but checking each item against the tree:
- The `core/archipelago/src/reconcile/` module the doc lays out in detail —
desired.rs / current.rs / diff.rs / apply.rs / derived.rs / backoff.rs — was
never created. The reconciler shipped as container/boot_reconciler.rs +
container/prod_orchestrator.rs instead.
- FM2's named fix `reconcile::derived::render_bitcoin_conf` does not exist. The
drift was eliminated a different way: bitcoind runs with an explicit `-conf`
derived from secrets each start, and stale datadir configs are removed.
- FM1/FM3 are partial — companion UIs are Quadlet units, main app containers are
not, since `use_quadlet_backends` still defaults false. The "v1.7.48+ full
reconcile module / main containers become Quadlet units" step has not happened.
- **FM6 was never implemented.** There is no podman corrupt-state probe and no
`system renumber` recovery anywhere in the tree. The 2026-04 failure that made
a registry node unreachable would still require manual SSH today — which is
precisely the "zero-manual-intervention" target this doc opens with.
FM4 and FM5 did ship as described.
Replaced the blanket claim with a per-item table so the doc stays useful as
incident history without reading as a description of the code, and noted that
the unit path throughout says /etc/containers/systemd/ while units are actually
written to ~/.config/containers/systemd/ (the path is rootless).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_node_key_known_answer_vs_python_verifier` pinned the node Ed25519 and node
Nostr keys, and `test_release_root_known_answer` covers the release root. The
remaining six — FIPS mesh transport, identity Ed25519, identity Nostr (NIP-06),
Bitcoin BIP-84 and LND aezeed entropy — were only asserted to be mutually
distinct by `test_full_derivation_from_known_mnemonic`.
Distinctness is satisfied by ANY change to an HKDF info string or BIP-32 path.
So redefining `archipelago/lnd/entropy/v1` — the seed behind a user's Lightning
wallet — broke no test, while invalidating every backup verification a user had
already performed against docs/SEED-VERIFICATION.md. Same for the FIPS key that
authenticates a node on the mesh.
Expected values were produced independently by the Python verifier published in
that doc, whose primitives were themselves cross-checked against bip_utils and
cryptography's own HKDF (BIP-39 seed, both BIP-32 paths, x-only pubkey, bech32
and HKDF-SHA256 salt=None all matched byte for byte). This commit closes the
loop in the other direction: the Rust implementation now agrees with those same
bytes, so the doc and the code are pinned to each other.
Verified: 26/26 seed tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**container-lifecycle.md** told operators to read the reconciler's decisions with
`journalctl --user -u archipelago`. That returns nothing: `archipelago.service`
is a SYSTEM unit (`WantedBy=multi-user.target`) that merely runs as
`User=archipelago`. It's `sudo journalctl -u archipelago`. Easy to get wrong
because the companion Quadlet units next door genuinely are `--user`, so both
forms appear in the docs and only one is right per unit — spelled that out
inline. Swept the rest of docs/: no other instance.
**quadlet-compilation.md** — added the `Network=host` case. Podman rejects
`PublishPort` with host networking (crash-loop, exit 125), so the renderer drops
declared ports rather than emitting them
(`render_host_network_omits_publish_ports`). A developer reading the directive
list would otherwise expect a mapping that never appears.
Everything else in both docs verified against quadlet.rs / prod_orchestrator.rs /
boot_reconciler.rs: the unit dir, the DO-NOT-EDIT header, Pull=never,
DropCapability=ALL, Secret=…,type=env, TimeoutStartSec=0, RestartSec=10,
WantedBy=default.target, the render/write_if_changed/enable_now/disable_remove
four-step, uid 1000, adopt_existing, the user-stopped.json / user-uninstalled.json
desired-state gates, and the 30s tick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reconciler's pre-start hooks for the btcpay stack, fedimint and fmcd
chowned unconditionally on EVERY prepare — and prepare re-runs far more
often than install (every reconcile that touches the app). archi-dev-box's
journal showed the same three dirs re-chowned every ~15s. The hooks exist
to repair old installs; they now skip when ownership is already correct
(root stat probe — the daemon's rootless metadata read can't see the
subuid-owned dirs).
Co-Authored-By: Claude <noreply@anthropic.com>
Ran the doc's script rather than only reading it, and cross-checked every
primitive it implements against independent libraries (bip_utils for BIP-39
seed / BIP-32 derivation / bech32, and cryptography's own HKDF): BIP-39 seed,
m/44'/1237'/0'/0/0, m/84'/0'/0', x-only pubkey, npub encoding and
HKDF-SHA256(salt=None) all match byte for byte. The hand-rolled crypto in this
doc is correct.
Two real gaps fixed:
- **The FIPS mesh transport key was missing.** `seed.rs:227` derives it from the
same master seed via `archipelago/fips/secp256k1/v1`, and a user verifying
their backup had no way to check it — despite it being the key that
authenticates them on the mesh. Added it to the diagram and as section 2b of
the script (same shape as the node Nostr key; verified against
`derive_fips_key` and `hkdf_derive` using `Hkdf::new(None, ikm)`).
- **The "compare with your node" commands were wrong.** The RPC endpoint is
`/rpc/v1`, not `/api/rpc`, and `identity.get-node` is not a method — the real
ones are `node.did` and `node.nostr-pubkey`. Also dropped "UI: Settings >
Identity", which is not a screen that exists, in favour of the two
identity files on disk.
Verified and left alone: all five other HKDF info strings, both BIP-32 paths,
and the `node_key.pub` filename. The release-root key
(`archipelago/release/root/ed25519/v1`) is deliberately still absent — it is
derived from the project's signing seed, not a user's node seed.
Noted separately: `system.get-node-key` sits in the CSRF-exempt list
(`api/rpc/mod.rs:337`) but has no dispatcher arm, so it is an exemption for a
method that does not exist. Harmless, but it should be removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This doc is marked "Status: implemented ... shipped end-to-end" and then
describes a cryptographic verification chain that does not exist. On a repo
about to go public, that is the single worst kind of doc bug: it promises a
security property.
`signatures.manifest_hash` / `signatures.did_signature` appear exactly once in
the codebase — as two struct fields at `marketplace.rs:106-107`. Nothing reads
them. There is no hash comparison, no DID resolution, no signature check. The
authenticity actually delivered is the Nostr event's own NIP-01 Schnorr
signature, which proves the publishing key sent the event but says nothing about
the DID the manifest names.
Added a warning at the top, marked the "Manifest Signing (DID Layer)" section
and steps 3-6 of the verification flow as not implemented, and annotated steps
7-8 as advisory (validate_manifest returns scoring issues; it does not block
discovery or install).
The trust model was overstated in the same direction:
- "DID Verification | 30 | Manifest is signed by a valid DID key" is a
`did.starts_with("did:")` string test. Any publisher can claim any DID and
take the 30 points.
- "Relay Consensus | 20" is graduated and never zero (1 relay still scores 5).
- "Version History | 15 | multiple published versions (shows maintenance)" —
nothing counts versions; it's 10 for a 3-part semver plus 5 for a non-empty
repo_url.
Worked the arithmetic through: an unsigned manifest with a plausible DID string
and a pinned image scores 65, landing in the "Community" tier. Said so.
Other corrections:
- `marketplace.unpublish` is documented but was never implemented (the string
appears nowhere); removed it and noted why NIP-33 makes it non-trivial. Added
the two payment methods that do exist (`create-invoice`, `check-payment`).
- The schema section said marketplace manifests "follow the existing
apps/{app-id}/manifest.yml schema", contradicting the header three paragraphs
above. They are separate types.
- The security-enforcement list claimed a capability allow-list, a
host-networking ban and system-path mount restrictions. Those rules are real
but live in the runtime manifest parser for a different schema — marketplace
validation checks four things and gates none of them.
- `run_as_user` documented as "> 1000" in two places while the code checks
`>= 1000` and the doc's own example uses 1000.
- Data-storage tree listed `cache/trust-scores.json` and `config.json`; neither
is ever written.
- The 15-minute cache TTL and 30-minute background refresh don't exist —
discovery is RPC-triggered and the cache has no expiry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The index covered users, architecture and app development but had no entry
point for "I want to work on Archipelago itself" — so eight tracked docs were
reachable only by guessing filenames, including the two that matter most to a
newcomer: developer-guide.md (how to build the workspace, the frontend and an
ISO) and LICENSE-COMPLIANCE-AUDIT.md (dependency licensing, which is exactly
what a reader checks first on an open-source repo).
Added a "Contributing to Archipelago itself" section covering those plus
bulletproof-containers, the signing runbook, the 1.8.0 hardening plan and
CLAUDE.md; filed pine-voice-commands under Getting started and demo-build-info
under contributing.
Also noted that ADR-010 was never issued — verified across all history, so the
009 → 011 gap is not a missing file — and added the two archived session logs
(HANDOVER-2026-07-02, SESSION-1.8.0-OTA-PROGRESS) to the archive table, which
already claimed to cover completed session logs but listed none.
Link check re-run across docs/: 0 broken. Only RELEASE_NOTES_BACKLOG.md is now
deliberately unindexed (internal working list).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The header says Phases 1-3 shipped, then §1 "Where we are today" described the
world before any of them: catalog carrying "version + image override only", the
manifest "never registry-distributed", counts of 48 disk manifests and 28
catalog entries. A reader hits the contradiction immediately and can't tell
which half is current.
Retitled §1 as the pre-Phase-1 baseline it is, and added the actual state:
`releases/app-catalog.json` has 66 entries and 56 embed a full `manifest` block
— one for every `apps/*/manifest.yml` in the tree (the stale counts were 48 and
28). What's genuinely left is Phase 4 (build-context apps) and Phase 5 (drop
`apps/` from the OTA rsync), which the phase list already marks ⏳.
Also:
- The install arrow claimed "render Quadlet unit"; same overstatement corrected
in architecture.md and app-manifest-spec.md — Quadlet is opt-in, the default
is podman create+start.
- §8's open question "generated_files with inline content — already supported?"
is answered: `app.files[]` takes inline `content` with placeholder rendering.
Marked answered rather than leaving a resolved question looking open.
Verified present and unchanged: `catalog_manifest_to_overlay`,
`install_stack_via_orchestrator`, `install_immich_stack`, and the catalog-wins
merge semantics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**COMMANDS.md** named six CSRF-exempt read-only methods, two of which
(`bitcoin.getinfo`, `monitoring.current`) are not exempt — a client trusting the
doc would send them with the cookie alone and get rejected. The real set is
twelve (`api/rpc/mod.rs:326-340`); listed all of them and said plainly that
everything else needs the header. The rest of the doc verified clean: the 480 /
200 / 160-char caps, the four `assistant_*` config keys, both default model ids,
`is_sender_allowed`, `strip_archy_trigger` / `run_node_cmd`, the three
unauthenticated HTTP endpoints, and `auth.login.totp` all match the code.
**secrets.md** said `secret_env` "sets `<key>` in the container's environment",
which reads as a plain `-e KEY=value` and undersells the design. It isn't:
resolved pairs are registered as podman secrets named
`archy-env-<app-id>-<key>` and referenced by name, precisely so the value stays
out of `podman inspect` and out of plaintext `Environment=` lines in Quadlet
units. Also documented the interpolation-taint rule — a plain `environment`
entry that expands `${SECRET}` (BTCPay's connection strings) is itself treated
as secret-bearing rather than left in the clear, which is what makes it safe to
build connection strings from secrets.
Everything else in secrets.md verified against `container/secrets.rs`: the four
kinds and their file shapes, the bare-filename rule, the every-tick idempotent
`ensure_generated_secrets`, and the atomic 0600 temp-fsync-rename writer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Narrative pass over the manifest spec, plus one correction to the guide I
committed in ba052736.
- **`app.id` allowed `_`.** `is_valid_app_id` accepts lowercase ASCII letters,
digits and single hyphens only — no underscores, no leading/trailing hyphen,
no `--`. The spec's "alphanumeric + `-`/`_`" would have a developer write an id
that fails to parse.
- **"Must match the directory name"** is a convention, not a rule. The loader
(`prod_orchestrator.rs:1455-1474`) walks `*/manifest.yml` and keys off
`app.id`, never comparing it to the folder, so a mismatch silently registers
the app under a different id. Said so rather than implying enforcement.
- **The Quadlet claim was the same one architecture.md was corrected for**
(f55ed6bf): install does NOT compile to a `user.slice` Quadlet unit today.
`config.use_quadlet_backends` defaults false, so apps take the legacy
`podman create + start` path; Quadlet is opt-in per node and companion UIs are
the exception that already use it.
- **"no per-app installer code"** — true of installers, but
`run_pre_start_hooks` is a hardcoded `match app_id` covering seven first-party
apps (bitcoin-ui, filebrowser, lnd, archy-nbxplorer, btcpay-server,
fedimint-clientd, grafana). Documented as the caveat it is; anyone reading the
source will find it in a minute and the doc should not look like it's hiding it.
- `derived_env` now names the full closed allow-list including `{{BITCOIN_HOST}}`
and what it resolves to.
Correction to ba052736: I wrote there that an unknown `derived_env` placeholder
passes through verbatim. It doesn't — `validate_derived_template` rejects both
unknown names and unbalanced `{{`. Fixed that row in the guide.
Verified accurate and left alone: the capability allow-list, network_policy
values, `/dev/*` device rule, volume option allow-list, bind-source confinement,
the four generated_secret kinds, `hooks.pre_start` being schema-only, and the
30s reconciler interval (`BootReconciler::DEFAULT_INTERVAL`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Narrative pass. The Security Requirements section described a blocklist where
the code enforces an allow-list, and attributed enforcement to the wrong layer:
- **"Forbidden: mounting system paths /, /etc, /var, /usr, /proc, /sys"** — the
real rule (`manifest.rs:1290-1313`) is the inverse: `volumes[].source` must be
absolute and under `/var/lib/archipelago/`, or a plain named volume, or one of
two reviewed exceptions (`/run/user/1000/podman/podman.sock`, `/var/run/dbus`).
Anything else is a parse error. The old wording also listed `/var` as
forbidden while every app in the repo binds `/var/lib/archipelago/<id>` — a
developer reading it would not know where their own data goes.
- **"enforced by the marketplace/catalog pipeline and the node"** — split by
layer instead. The capability allow-list is parser-enforced (verified against
the 9 entries at `manifest.rs:1089-1099`); `:latest` is NOT — only
`validate-app-manifest.sh` checks it, and a `:latest` manifest still installs.
readonly_root / no_new_privileges / network_policy=isolated are parser
defaults, so omitting them is safe rather than dangerous.
Also:
- `derived_env` documented `HOST_IP`/`HOST_MDNS`/`DISK_GB` "such as"; the set is
closed and includes a fourth, `{{BITCOIN_HOST}}`. Noted that unknown
placeholders pass through verbatim rather than erroring, so a typo silently
ships `{{FOO}}` into the container.
- The networking example hardcoded `bitcoin-knots`; `{{BITCOIN_HOST}}` resolves
to knots or core depending on what's installed.
- Documented the `files[].content` placeholder set, which is a different set
from derived_env and wasn't mentioned at all — notably `{{NETWORK_GATEWAY}}`
(the nginx `resolver` fix for post-restart 502s) and `{{secret:NAME}}`.
- The "check the UI" URL `/app/my-app/` is not a route; it's
`/dashboard/apps/:id` (detail) or `/dashboard/app-session/:appId` (embed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first drift-gate attempt called plain sudo stat, which the daemon's
privilege path doesn't answer — the probe silently failed and the chown
loop continued. host_sudo_output mirrors host_sudo (systemd-run --pipe)
but returns the process output, so the ownership check gets a real answer.
Co-Authored-By: Claude <noreply@anthropic.com>
The installer's completion screen and the login-console banner both told the
operator "Web Login password123". No release build accepts that password: no
default account is ever created (`main.rs:356-362`), and the `password123`
pre-setup path is `#[cfg(debug_assertions)]` + `dev_mode`
(`api/rpc/auth.rs:36-46`). A new user following the screen gets
"User not set up. Please complete setup first." on their first-ever
interaction with the product.
Both screens now say the web UI asks you to create a password on first visit,
which is what `Login.vue` actually does when `auth.isSetup` returns false. The
SSH line is unchanged — `archipelago`/`archipelago` really does still ship
(`install-to-disk.sh:205`), and killing that is the open half of the
"kill default credentials" hardening item.
Note on the path: `image-recipe/build-debian-iso.sh` is a thin wrapper that
copies `_archived/build-auto-installer-iso.sh` and rewrites its relative
paths, so despite the directory name the archived builder is the live one.
Same string fixed in scripts/install-tui-demo.sh, which mirrors the screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Narrative pass over troubleshooting.md against the code. Seven claims were
wrong, several of them actively misleading:
- **Tor is not a container.** §15/§16 told operators to run
`podman ps --filter name=tor` / `podman restart tor` and to read
`/var/lib/archipelago/tor/hidden_service/hostname`. Tor is the host's Debian
package running as `debian-tor`; Archipelago drives it by staging a torrc and
poking `archipelago-tor-helper` (`scripts/tor-helper.sh`, which does
`systemctl restart tor`). The hidden-service dir is
`hidden_service_archipelago` (suffixed), it's root-owned 0700, and the file a
normal user can actually read is the synced copy at
`/var/lib/archipelago/tor-hostnames/<service>`.
- **The USB installer has no "Repair" mode.** Cited three times as the recovery
path. The boot menu has exactly three entries: Install, Install (verbose),
Boot from local disk. Replaced with what those entries can actually do, plus
the fact that the installer prompts for a disk and requires typing `yes`, so
booting it isn't itself destructive.
- **`bitcoin-cli -datadir=/data`** — the container's datadir is
`/home/bitcoin/.bitcoin` and RPC creds are in a generated `/tmp/rpc.conf`;
the documented command could not have authenticated.
- **"edit bitcoin.conf to add addnode="** — the entrypoint passes an explicit
`-conf` and logs "ignoring legacy datadir bitcoin.conf". Flags come from the
manifest (and the signed catalog entry that overrides it).
- **"Bitcoin requires 600GB+"** — only above the manifest's 1000 GB threshold;
below it the node runs pruned at `-prune=550`.
- **`sudo systemctl restart podman`** — apps run under rootless Podman as the
`archipelago` user, so that restarts an unrelated root socket.
- **"Settings > Network"** — DNS config and disk cleanup are both on the Server
page (`/server`), not Settings.
Also: header claimed "the 20 most common issues" over 21 sections, and §16
presented Tor as required for peering when it's the last fallback after
mesh → LAN → FIPS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The walkthrough told new users to log in with `password123` and said they'd
be "prompted to change this password immediately". Neither is true on a
release build:
- `AuthManager::ensure_default_user` is never called. `main.rs:356-362`
says so explicitly ("Don't auto-create default user — let onboarding flow
handle password setup via auth.setup"), and the function is `#[allow(dead_code)]`.
- The only `password123` login path is `api/rpc/auth.rs:36-46`, which is
`#[cfg(debug_assertions)]` AND `dev_mode` AND only fires *before* setup —
no release binary carries it.
- `Login.vue` calls `auth.isSetup` on mount and renders the "Set Up Your
Node" password-creation form when it returns false. That is the real
first-boot screen, and it is the only `auth.setup` caller in the frontend.
So there is nothing to be "prompted to change" — the user creates the
password themselves, and the doc's version taught them to look for a
default that does not exist.
Fixed in four places:
- user-walkthrough Step 8 rewritten as "Create Your Password"
- troubleshooting's "Default password is password123" solution replaced,
including the warning that deleting user.json does NOT recover a lost
password (the onboarding gate refuses auth.setup on a provisioned node)
- api-reference cURL example uses a placeholder, not the fake default
- 1.8.0 hardening plan's "kill default credentials" item now reflects that
the web half is done and only the SSH defaults still ship
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The direct metadata read can be denied in the service's rootless context even
when the directory is already correctly owned, which kept the reconciler
calling sudo chown on the same Postgres volume every minute. A root
fallback gives the guard a reliable answer on deployed nodes while remaining
much cheaper than a recursive chown.
Co-Authored-By: Claude <noreply@anthropic.com>
Verified the project-structure tree against the tree. Two stale entries:
- image-recipe/build-auto-installer-iso.sh was the old builder, now under
_archived/; the current builder is image-recipe/build-debian-iso.sh (the
release workflow drives it via scripts/build-iso-release.sh). Repointed.
- CLAUDE.md was labelled "AI development instructions"; it is now the sanitized
public contributor guide. Relabelled.
Everything else verified: run-tests.sh, first-boot-containers.sh, container.rs,
vpn.rs all exist; the add-an-endpoint / add-a-Vue-page tutorials match the
current dispatch pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The overview stated apps install as user.slice Quadlet units. Verified against
prod_orchestrator.rs: use_quadlet_backends defaults to false, so regular apps
install via the raw podman path today; the companion UI containers are the ones
that run as Quadlet units (companion.rs owns them), and the Quadlet flip to
default for all apps is opt-in/held.
Reworded both places (the layer diagram and the App Platform section) to match
reality and the container-lifecycle / quadlet-compilation dev docs: the
orchestrator owns and self-heals app containers; companion UIs run as Quadlet
units, the validated path being flipped to default. Everything else in the doc
verified accurate — crate table, module map, data paths, security model, and
the note that the four orphan crates still exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
W1.7's regression gate: after 8329b826's tree-shake fix, this makes the
mock-quarantine load-bearing — a future change that reintroduces the mock
modules into the production graph fails the build instead of shipping
silently. The demo-site build (VITE_DEMO_CONTENT=true) is exempt by design.
Co-Authored-By: Claude <noreply@anthropic.com>
get_data_dirs_for_app had no btcpay arm — the default mapped to
/var/lib/archipelago/btcpay alone, leaving postgres-btcpay (where the
ACCOUNT lives) and nbxplorer on disk. Uninstall-with-wipe then reinstalled
to the old account still enabled. The btcpay arm now covers all three dirs,
for every alias and stack-member id. The map stays deliberately hardcoded:
deletion code must never derive its targets from a manifest at uninstall
time (a bad manifest could aim the wipe at another app's data).
Co-Authored-By: Claude <noreply@anthropic.com>
BITCOIN-RPC-PROXY-EXPOSURE.md's port claims verify against code (Bitcoin RPC on
127.0.0.1:8332, the bitcoin-ui proxy on 127.0.0.1:8334). But its incident
narrative named a specific node's LAN address (192.168.63.240, five times) on a
subnet the earlier 192.168.1.x sweep did not cover. Replaced with the RFC 5737
documentation address 192.0.2.240. The incident content — the exposure, the
probes, the fix — is unchanged and remains a legitimate public security record.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified the security subsystem's design-doc claims against code:
- KEY-05's foundational claims are accurate: entropy::draw_key_bytes exists,
KeyGenRng is sealed with OsRng as its sole production member, MIN_GUARDED_LEN
is 12, and core/clippy.toml bans rand::random/thread_rng exactly as stated.
- But its per-site table listed every production nonce/key site as disposition
"migrate" (pending), when all of them have since been migrated to
draw_key_bytes(OsRng) — storage_crypto, credentials/store, wallet/bdhke,
mesh/x3dh — and zero rand::random/thread_rng remain in production. Added a
completion note so the doc no longer reads as pending work.
Both KEY-05 and PSBT-SIGNING-ARCHITECTURE referenced
ENTROPY-SEED-AUDIT-2026-07-31.md five times as their evidence base — a doc that
was moved to local-only, so a public reader could not follow it. Reworded all
five to state the audit's findings inline ("the internal entropy audit found
...") without the unresolvable path. No published doc references it now. The
link-checker missed these because they were inline code, not markdown links.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apply_data_uid ran a recursive sudo chown on every prepare_for_start, and the
reconciler re-prepares — archi-dev-box's journal showed postgres-btcpay rechowned
every ~45s despite already-correct ownership, and on framework-pt the same loop
surfaced as operator-visible 'chown failed' noise. chown_for_rootless_container
now stats the target first and returns early when the top-level owner already
matches the host-mapped uid:gid. Deep drift in a running container is still
caught by ensure_running_container_ownership's in-container write-probe, which
is the authority that actually matters (it probes writability, not stat bits).
Co-Authored-By: Claude <noreply@anthropic.com>
The guide walked a developer from manifest to install but never told them how
to validate the manifest locally first — despite scripts/validate-app-manifest.sh
existing for exactly that. A developer's first signal that their manifest was
wrong would have been an install failure on a node.
Adds a "Validate Your Manifest" step at the top of Testing, pointing at the
script (recently fixed — it had been rejecting every manifest because it shelled
out to a missing ruby). Notes the strict behaviour a new submitter hits, e.g.
an unpinned :latest tag is rejected, and that the Rust parser is canonical.
Verified: the install RPC example in this guide (id + dockerImage) matches the
handler; the cargo test target crate name (archipelago-container) is correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified all 144 documented RPC methods against the dispatcher. 143 are live;
one was fabricated: `mesh.discover` (params { timeout_secs? }, returns
{ nodes: MeshNode[] }) does not exist — "mesh discovery" appears only in code
comments as a concept, never as a method. A developer calling it gets "unknown
method".
Replaced with the real peer-listing method `mesh.peers` (no params, returns
{ peers, count }), which the frontend actually uses and which was undocumented.
Also verified: every source path cited across the docs resolves (placeholders
and a correctly-recorded deletion aside), and every documented app-manifest
field exists in the schema (no fabricated fields).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three `MEMORY → <note>` see-also references pointed at the private agent-memory
system from public docs (demo-deployment-design.md x2, registry-manifest-design.md
x1). Removed. No tracked doc references the memory system now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bitcoin-multi-version-design.md carried three layers of stale internal content:
an 80-line HTML-comment work-tracking block (per-phase status with "UNCOMMITTED
on the branch", node numbers, "Next action when resuming", "Decisions still
needed from user"); a rendered "Status: design (2026-06-22)" header that was
wrong — the feature shipped, all four phases, with the downgrade guard added
today; two private `MEMORY →` references; and a node-numbered scheduling note.
Now: the comment block is gone, the status reflects reality, the MEMORY
references and node numbers are removed, and "verify on a real node" replaced
the specific fleet addresses. The design content (source-of-truth decision,
phase designs, invariants) is unchanged.
Separately, bitcoin-version-bulletproof-rollout.md was an inter-agent rollout
handoff — node numbers, branch coordination, "the other agent owns" — not a
design or reference doc. Moved to local-only (still on disk, gitignored) like
the other handoffs; its two path references (a plan doc and a script comment)
are generalized.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This 2026-04 plan has been implemented, but it still read as an active plan
("implementation started"), linked private agent-memory paths, and ended with a
stale "To resume" work block naming fleet nodes, dated fleet state, and the next
file to edit.
- Header now marks it a historical design record and points at
container-lifecycle.md for the current behaviour.
- Removed the two private ~/.claude/.../memory/ references from the header and
the entire "To resume" section (private paths, node numbers, 2026-04-22 fleet
snapshot — none of it belongs in a public design doc).
- Genericized the one remaining node-number reference in the incident narrative.
The valuable content — the six failure modes and the reconciler reasoning that
answered them — is kept intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The open-source plan flagged three references as "the real gaps for app
developers", and the docs index named them as not-yet-written. Written now,
each from the code rather than stubbed:
- secrets.md — generated_secrets/secret_env: the two halves, the four kinds
(hex16/hex32/base64/bcrypt) and which files each writes, the idempotent
self-healing 0600 materialisation, and the rules a developer must not break
(no hardcoded fallbacks, one canonical name, right encoding). From
container/secrets.rs and the manifest schema.
- quadlet-compilation.md — manifest -> .container unit: the full directive
mapping (including Secret= by reference, never value, and Pull=never), where
units land (~/.config/containers/systemd, systemctl --user), the
render/write/enable/disable lifecycle with write-if-changed, and how to
inspect one. From container/quadlet.rs, scoped accurately to the companion-UI
path it drives today.
- container-lifecycle.md — the level-triggered 30s reconciler: desired state
from user-stopped/user-uninstalled/manifest set, the operations table, the
self-heal-vs-respect-a-deliberate-stop rule, and migrations-never-destroy-data.
From prod_orchestrator.rs and boot_reconciler.rs.
Index updated to link all three under App development and the "known gap" note
removed. Every link across the docs tree resolves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to 73970cf3. Two improvements:
- Corrects a stale claim — manifests are no longer "loaded from disk, goal is
the catalog". The signed catalog has been the delivery mechanism since
2026-06-23 (origin-wins over disk), so the guide states that, plus the
consequence contributors need: editing a disk manifest alone does not change
a catalog-covered app.
- Tightens the north-star paragraph.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md was the internal agent guide: a dated "gate is GREEN" status banner
naming a specific node, pointers to now-local-only planning docs
(PRODUCTION-MASTER-PLAN, UNIFIED-TASK-TRACKER, multinode-testing-plan), the
gitea-ai push account mechanics, and references to the private memory system.
Rewritten as a contributor guide that keeps everything public-worthy — the
invariants (rootless podman, declarative apps, manifest-declared secrets,
non-destructive migrations), the build/verify notes, the commit-and-push
discipline, and the production test-gate definition — and drops the status,
node numbers, push-account specifics, and memory references. Points at
docs/ROADMAP.md and docs/README.md instead of the internal trackers.
No infra identifiers or internal mechanics remain; all links resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two concrete, verifiable documentation gaps from the open-source review:
- docs/ had no index. Adds docs/README.md grouping the 60-odd published docs by
task — getting started, architecture, app development, design docs, ADRs,
security, roadmap — in the bitcoin/bitcoin doc/ style the plan called for.
Every link in it resolves (checked). The top-level README now points at it as
the front door rather than duplicating the list.
- ROADMAP.md and tests/lifecycle/TESTING.md linked docs/multinode-testing-plan.md,
which moved to local-only (it is a fleet node inventory, not published). Those
references now describe the scope split in prose instead of pointing at a file
that is not in the public tree.
The index is honest about what is missing: it names the three app-developer
docs the plan flagged as gaps (quadlet compilation, container lifecycle,
secrets materialisation) as not-yet-written, and points at the authoritative
code for each rather than pretending they exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo ships an MIT LICENSE and the README carries an MIT badge, but the
crates themselves declared no license, so `cargo metadata`, packaging and any
downstream mirror saw "license: null". Adds [workspace.package] license = "MIT"
and inherits it in all five members via license.workspace = true. Verified with
cargo metadata: all five now report MIT.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Today's outage window came from ordering, and the ordering was baked into the
publish script itself: it pushed main — the branch nodes read the manifest
from — together with the tag, up front, then uploaded and verified assets
afterward. So the manifest advertised the new version for the entire
upload+verify window. When an upload failed inside that window, every polling
node briefly saw a v1.7.126-alpha update whose binary 500'd and whose tarball
did not yet exist.
Reordered so the manifest goes live last:
1. push the TAG only (the Gitea release and asset URLs hang off it; the tag
alone changes nothing for nodes)
2. upload assets
3. verify every asset downloads in full and matches the manifest sha256/size
4. only then push main — the step that actually triggers nodes
Also fixes a way a bad asset could slip through unnoticed: the inline
verification ran in a `while read` pipe subshell, where its `fail` (exit 1)
terminated only the subshell and let the script continue to "published and
verified". Verification now runs in the main shell via a new
check-release-assets.sh, which fails hard on the first bad asset. The same
script is the reusable by-hand verifier used to recover today's release
(both assets confirmed 200 + sha256-match before the manifest was re-published).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13-15's two artifacts. The map names plan/wave/threat-ref per row with
today's measured results (Rust assistant suite 130/130; adapter 37; broker
25; toolConfirm, audioPlayer 11, appsConfig 13; AIUI 353/356 with the three
documented pre-existing fixture failures). Four close-out rows added
(S-invariants, evals, egress, mock-free-bundle grep). Manual-only table
discharged except the physical-handset pass, which 13-UAT records as owed
(the AIUI-06 flagged assumption wants both, and only devtools-mobile is on
record). Open questions 1/2/4 resolved with plan cites; Routstr stays
honestly open.
Co-Authored-By: Claude <noreply@anthropic.com>
Restores the signed v1.7.126-alpha manifest to main now that both artifacts are
confirmed fetchable end-to-end:
- archipelago HTTP 200, sha256 matches the manifest
- frontend tarball HTTP 200, sha256 matches the manifest
The earlier publish was rolled back (e346e552) because the manifest went live
before its assets resolved. Two separate asset faults, now fixed: the binary's
first upload landed corrupt server-side and returned 500 on download (deleted
and re-uploaded, clean); the tarball's first upload returned an empty response
and never attached (re-uploaded, 201, full 210 MB).
This is byte-for-byte the manifest the tag already carries, so its signature is
unchanged and re-verified against the pinned release root.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1.7.126-alpha manifest went live on main — which is where nodes read it
from — before its artifacts were reachable. The binary returns HTTP 500 and the
frontend tarball never uploaded (404), so any node polling would advertise an
update it cannot fetch.
Restores the previously published, still-validly-signed .125 manifest
byte-for-byte from 19487670, so nodes see the last release that actually
resolves. The v1.7.126-alpha tag and its signed manifest are unchanged in git
history; only what main serves is rolled back.
Publishing order was the mistake: the manifest is the trigger, so assets must
be verified downloadable before it lands on main, not after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed release manifest for v1.7.126-alpha, verified against the pinned
release root before committing.
Committed by hand rather than by re-running create-release.sh: the script
regenerates the manifest at step 6, which would overwrite the signature
applied at step 6b. Its own "sign it, then re-run this script" advice destroys
the thing it just asked for.
Version bump, changelog and What's New landed earlier in 1cd068e4 — the
frontend build embeds the version via the curated What's New list, so those
must exist before the build step rather than after it.
Release gate: 8 of 9 stages passed in-run. cargo-test-weekly hit its 1500s
ceiling (exit 124, a timeout not a failure) because the non-incremental
all-targets compile does not fit on this machine; the suites were then run
separately and passed 100/100, including the downgrade guard and 30 update::
tests over the OTA apply/rollback path. Right-sizing that ceiling is a
follow-up — an override that becomes routine stops being a gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live on archi-dev-box: the node still proxied web-search straight to
SearXNG :8888 unauthenticated — the repo conf was fixed in d0c9ea6e but
existing nodes' /etc/nginx/sites-enabled never gets rewritten by a source
edit. Added to the nginx self-heal battery: stale 8888/search proxy_pass →
session-gated 5678 with the Cookie forwarded (heal_stale_web_search_block,
pure + idempotent + tested). Fresh ISOs already ship the gated block.
Co-Authored-By: Claude <noreply@anthropic.com>
Mesh AssistQuery answered with a bare LLM call — no tools, no actions.
The CallerScope::Mesh variant was designed for this wiring ('the variant
exists so the shape is right when a future plan wires mesh callers into
the shared loop'); this is that plan. A trusted/allowlisted asker's prompt
now runs assistant::chat with CallerScope::Mesh { authorized } — the
operator's persisted grants cap what the model may touch (never wider),
and writes suspend on the node's own confirm gate. The reply is capped
for airtime as before, with a brevity instruction for mesh turns.
Wiring follows the blob_store pattern: RpcHandler::set_mesh_service (now
&Arc<Self>) forward-propagates an Arc<RpcHandler> into the mesh state's
new assistant_handler slot; absent (early boot) falls back to the legacy
bare-LLM answer.
Test: mesh_caller_authority_is_capped_at_operator_grants.
Co-Authored-By: Claude <noreply@anthropic.com>
Build #189 proved the IPv4 fix works (downloads all succeeded) and proved
my own edit wrong: appending inet4_only to /etc/wgetrc before wget's
package landed made dpkg's conffile prompt hit EOF, leaving wget and
debootstrap unconfigured. Moved the pin below the apt install.
Co-Authored-By: Claude <noreply@anthropic.com>
The 2912×1632 jpg painted visibly slowly over Tailscale/Tor. 1920w q82
webp is visually identical behind glass and under half the weight.
Co-Authored-By: Claude <noreply@anthropic.com>
This box (and its containers) blackhole IPv6: deb.debian.org answers AAAA
first, wget tries v6 until debootstrap's per-package timeout, and the
installer-env stage died twice today with 'Couldn't download packages'.
Probed in a debian:trixie container: v4 OK, v6 hangs. inet4_only for wget
(covers debootstrap) + Acquire::ForceIPv4 for every apt-get, including the
chroot and the rootfs Dockerfile stages.
Co-Authored-By: Claude <noreply@anthropic.com>
Two changes, one binary batch:
1. app_install/app_uninstall (task 3): '!ai please install bitcoin knots'
correctly said it can't. Both tools are category-Apps, destructive, and
ride the 13-08 confirm gate (node-authored descriptions added). Install
validates catalog membership BEFORE the dialog (a typo never spends an
approval); uninstall resolves installed ids. Both reach the SAME
package.install/package.uninstall spawns every authenticated caller
uses, via a curated Arc-taking sibling of assistant_dispatch_tool.
2. S6: cloud legs no longer strip prior USER turns from replayed history.
Turn-minimality's allowlist is now the whole conversation's operator
turns (the node's own D-08 transcript, same trust class as this turn),
still mechanically matched, B1 secret scan and 64KB cap unchanged,
fabricated user messages still truncated. The model no longer sees its
own answers without the questions.
Co-Authored-By: Claude <noreply@anthropic.com>
resolveAppIcon's final arm guessed /assets/img/app-icons/<id>.png — strfry
404'd live. DEFAULT_APP_ICON already existed; the chain now ends on it.
Co-Authored-By: Claude <noreply@anthropic.com>
Node-derived cards have no rating/year/runtime/director; rendering the
defaults read as '★ 0 · 0m' beside an empty string. FilmGrid, FilmDetail
and SongGrid now gate those spans on real values (FilmCard, Book and Place
components already did). Panel empty states were made honest in 9abc1623
('Nothing found'), so this closes the rendering-honesty item.
Co-Authored-By: Claude <noreply@anthropic.com>
create-release.sh builds the frontend at step 4 and validates the curated
changelog at step 5, then requires the freshly built bundle to contain the new
version. The version reaches the bundle only through the hand-written What's
New list, so on a fresh release that check can only pass if the changelog and
What's New entries are written BEFORE the script runs. Writing them after is
what aborted the first attempt.
Leads with the downgrade bug, since that is the one users saw: an Update button
offering the release withdrawn for an actively exploited 2FA bypass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every mock consumer is now gated on the demo flag inline (canonical Vite DCE
idiom — the cross-module DEMO_CONTENT_ENABLED const defeated folding). But
the real leak was films.ts's module-level allGenres/allSources exports:
[...new Set(mockFilms.flatMap(...))] is unprovably pure, so the treeshaker
kept the whole module — array, plex:// and cloud.example.com hosts and all —
even with zero live references. The mocks directory is now declared
side-effect-free in vite.config (they are pure data by design), so unneeded
mock modules actually drop.
Verified: clean dist build → entry bundle AND dist-wide grep show zero
mock hosts (spotify/track/example, cloud.example.com, plex://, tmdb image
host). Demo/dev builds (VITE_DEMO_CONTENT=true or import.meta.env.DEV) keep
the full pack. Tests: 353/356, failures are the three documented
pre-existing ones.
Co-Authored-By: Claude <noreply@anthropic.com>
The release gate failed cargo-fmt. The domain that replaced the IP-based
registry is longer, pushing several test assertions past the width limit, so
rustfmt wanted to re-wrap them. Pure line re-wrapping — no semantic change.
Caught by the pre-flight gate rather than after tagging, which is what it is
for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy `btcpay` entry (distinct from `btcpay-server`, no embedded manifest)
still carried a concrete 2.3.9 image. catalog_primary_image treats that as
authoritative, which is what drove the UI to offer "update to 2.3.9" on nodes
already running 2.4.2 — a rollback onto the actively exploited release.
Now 2.4.2 in both entries, signed by the pinned release root and verified.
This lands the fix for every node immediately, without waiting for the binary
carrying the downgrade guard (cbfda305) to reach them. The guard remains the
durable fix: it makes any future stale pin fail safe rather than relying on
every version claim being correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package.versions answered installedVersion "15.17" for btcpay-server while
offering "2.4.2" — 15.17 being its postgres dependency's tag. With BTCPay's
own container absent, installed_version fell back to `containers.first()`,
which for a multi-container stack is an arbitrary sibling.
That is the number the update decision is made from, and it is what the UI
shows next to the available version, so a nonsense pair like "installed 15.17,
available 2.4.2" is presented as a legitimate upgrade.
The fallback now only applies when there is exactly one container, which still
covers apps whose container is named differently from their id (immich_server
for immich). With several containers and no identifiable backend, the honest
answer is "unknown" rather than a guess at a sibling.
Extracted as select_backend_container so the rule is testable directly.
Tests: the BTCPay stack case, the lone differently-named container, and the
archy- prefixed preference. Full suite 1157/1157.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app store offered "update to 2.3.9" on a node already running 2.4.2 — the
release that fixes an actively exploited 2FA bypass. Taking it would have
rolled the node back onto the vulnerable version.
Root cause: available_update_for_images compared tags for inequality only.
Same repo + different tag meant "update available", with no ordering. Every
version claim upstream of it can go stale — the signed catalog, a legacy
catalog entry, the image-versions.sh baseline pin — and any one of them
lagging turned into a backwards Update button.
Guard added: when both tags parse as dotted-numeric versions, a lower pinned
version is never offered. Tags that cannot be ordered (RELEASE.2024-11-07…,
14-vectorchord0.4.3) keep the previous behaviour rather than silently losing
updates. This makes stale data fail safe, which matters more than any single
pin being correct.
Four sources still named 2.3.9, three of them able to act on it:
- releases/app-catalog.json — a LEGACY `btcpay` entry, distinct from
`btcpay-server`, carrying a concrete 2.3.9 image. catalog_primary_image
treats that as authoritative, so this is what drove the button. Fixed, but
held back from this commit: it needs re-signing.
- scripts/image-versions.sh — the baseline pin used when the catalog does not
cover an app.
- stacks.rs — the legacy BTCPay installer, twice. The fallback install path
would have deployed 2.3.9 outright.
- neode-ui curatedApps/marketplaceData and public/catalog.json — the store's
displayed version, hardcoded rather than read from the catalog, which is why
it still showed 2.3.9 after the update landed.
Audited every other installer for the same shape. The remaining literals are
the immich stack, which currently agrees with its manifests; hits in
set_config.rs and app_catalog.rs are test fixtures. To keep it that way,
scripts/check-installer-image-pins.py asserts that any installer literal
naming the same repository as an app manifest carries the same tag, and runs
blocking in CI. Verified it catches a simulated revert to 2.3.9.
Tests: 13/13 in image_versions including the exact BTCPay case, a genuine
upgrade still offered, equal versions silent, prerelease suffixes ordered on
their numbers, and opaque tags unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d0af38e8 shipped check-catalog-registry-trust.py without the file it reads:
`releases/**` in .gitignore silently dropped registry-trust-floor.json, so the
guard would have failed in CI and on any fresh clone. app-catalog.json only
stays tracked because it predates that rule.
Both are source rather than build output — nodes fetch the catalog from this
path on main, and the floor is what the guard checks it against — so both now
have explicit negations, with the reason recorded next to them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Encodes the sequencing rule that nearly shipped a fleet-wide outage today.
The signed catalog is authoritative on every node — catalog_image_override
makes its image refs beat the on-disk manifest. TRUSTED_REGISTRIES in the
working tree describes a binary being built now; nodes run whatever was last
shipped to them. Those two diverge for exactly as long as an OTA takes to
reach the fleet, and that window is when regenerating the catalog silently
breaks every install with "not from a trusted registry".
Regenerating today would have done precisely that: the generator embeds each
app's manifest, and those now name the new registry domain, which no deployed
binary trusts.
- releases/registry-trust-floor.json records the hosts DEPLOYED binaries
trust, separately from what the source tree accepts, with the new domain
parked under `pending` until an OTA carries it. The migration order is
written down there rather than living in someone's memory.
- scripts/check-catalog-registry-trust.py compares the catalog's hosts against
that floor and explains the ordering fix when they diverge.
- sign-catalog.sh runs it as a preflight BEFORE prompting for the mnemonic, so
a bad catalog is refused at the last reversible moment.
- CI runs it blocking, plus the drift report advisory (drift between a manifest
landing and the next signed release is expected, since only the ceremony can
close it).
Also installs PyYAML in the manifests job. That job passed only because GitHub
runners happen to ship ruby, which the validator used to require; it now needs
python3+PyYAML.
Verified: passes on the published catalog (2 hosts, both trusted); refuses a
simulated full regenerate (79 refs on the untrusted domain) and blocks the
ceremony without requesting the mnemonic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed by the pinned release root and verified before publishing
(`ceremony verify` → OK). A present-but-invalid signature is a hard reject on
nodes, so verification is the gate, not the presence of a signature field.
Surgical edit rather than a regenerate: only the btcpay-server entry changed
(66 apps in, 66 out; 49 entries still resolve through the legacy registry,
untouched). A full regeneration would have embedded the repo's manifests,
which now name the new registry domain that no deployed binary trusts yet —
publishing that would have broken every app install fleet-wide, during a
security push. That sequencing is being fixed separately.
BTCPay's image comes from docker.io, so it is unaffected by the registry
migration either way.
Nodes pick this up via package.check_updates → refresh_catalog → reload
manifests, then package.update (stop → pull → remove → recreate → verify,
with rollback on failure).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BTCPay 2.4.2 fixes a critical vulnerability that upstream reports as actively
exploited: a TOTP two-factor bypass via Greenfield Basic authentication
(btcpayserver/btcpayserver#7491).
Checked the 2.3.9 -> 2.4.2 breaking changes against how Archipelago actually
configures BTCPay; both are inert here:
- 2.4.0 removed the LNBank and Lightning Charge backends. Ours is a direct LND
connection built by container::lnd::ensure_btcpay_lnd_connection_secret.
- 2.4.2 disables Greenfield Basic auth five minutes after account creation.
Nothing in the daemon or frontend consumes BTCPay's API.
The manifest bump alone does NOT reach nodes: catalog_image_override makes the
signed catalog authoritative whenever the image repo matches, so a node would
be forced back to 2.3.9. The catalog edit is held locally until the signing
ceremony runs, because an unsigned catalog published to main would be accepted
by nodes (absent signatures are allowed) and would quietly drop authenticity.
check-app-catalog-drift.py only understood app-catalog/catalog.json, where
`apps` is a list. releases/app-catalog.json — the SIGNED catalog nodes actually
resolve apps through — keys `apps` by id and wraps each app's full manifest
under manifest.app. So the checker parsed the file that governs nothing and
raised ValueError on the file that governs everything. It now reads both shapes.
Running it against the release catalog shows the repo and the catalog agree on
content: of 34 image differences, all 34 are the registry host alone and every
tag is identical. The remaining version-string drift (v1.18.0 vs 1.18.0,
1.30.0-alpine vs 1.30.0) is cosmetic metadata, not image drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes 4 test failures introduced by 8e814ca0, which I pushed after running
only the container-crate tests while the full suite was still compiling. Both
failures were real defects, not stale assertions.
1. Catalog-driven installs would have failed fleet-wide.
8e814ca0 dropped the old registry address from TRUSTED_REGISTRIES, but the
signed catalog still advertises image refs on it — deliberately, since
rewriting a signed artifact invalidates its signature. Nodes resolve apps
through the catalog, so every install would have been refused with "not
from a trusted registry". Reinstated as LEGACY_REGISTRY_HOST, documented
as transitional and removable only once the catalog is re-signed.
2. The update fallback lost the property it exists for.
update.rs keeps two mirrors on purpose: the domain as primary, and the
old IP over plain HTTP as a fallback, because a node whose DNS or clock is
wrong (both break TLS) must still be able to update itself — the signature,
not the transport, is what makes either source safe. The bulk rewrite
pointed both constants at the domain, leaving the escape hatch dependent on
exactly what it exists to survive. Restored to its original value.
Separately, validate-app-manifest.sh is ported from ruby to python3+PyYAML.
It shelled out to ruby with stderr discarded, so on any machine without ruby
a missing interpreter was reported as "Valid YAML with top-level app block:
FAIL" and every manifest came back REJECTED. This is the first tool an app
developer runs, and it sent them to fix YAML that was never broken. Ruby was
also the odd dependency out — the repo already ships three python scripts.
It now checks for python3 and PyYAML up front and names what is missing, then
parses with PyYAML. Missing keys resolve to an absent-value object that
indexes to itself and prints empty, so call sites lost their per-hop guards:
(((app["container"] || {})["build"] || {})["context"])
becomes app["container"]["build"]["context"]. Booleans still print as
true/false rather than Python's True/False — call sites compare == "true",
so Python's capitalisation would have silently inverted the readonly_root
and no_new_privileges security checks.
Verified: full rust suite 1148/1148, 0 failed. All 56 app manifests validate
(0 rejected, 0 errored) where previously every one was rejected. No signed
artifact modified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RC1's aiui/index.html pointed at the stale checked-in demo bundle while
today's dist sat beside it unreferenced: demo/aiui was tried first, and the
rsync without --delete merged it over the fresh capture from /opt. Now the
newest index.html across all candidates wins (demo/aiui remains the
fresh-clone fallback) and the copy deletes before writing.
Found by mounting the RC1 ISO and diffing bundle hashes against the tree —
exactly the 'verify the frontend INSIDE the ISO' rule.
Co-Authored-By: Claude <noreply@anthropic.com>
Live evidence, two ways: the 9abc1623 banner never fired because D-16 hides
ungranted tools (model never calls → refused_categories always empty), and
the [[needs:id>]] marker fix failed because a small local model answers with
a workaround narrative instead of emitting structured markers.
The model's reliable, trained behavior is tool CALLING — so disabled tools
are now listed in a DISABLED prompt section and remain in the schema. A call
hits the execution gate, which refuses and records the category → the
trusted chrome offers Settings → AI Data Access. Deterministic and
model-independent. The prompt split is UX/attack-surface shaping; the
security boundary remains the server-side grant re-check in execute_tool
(loop_.rs), unchanged and now the single enforcement layer by design.
Tests: ungranted_tool_only_ever_in_disabled_section (section-aware),
disabled_tools_are_listed_as_callable_but_refused, marker extraction kept
as a harmless safety net. 127/127 assistant suite green.
Co-Authored-By: Claude <noreply@anthropic.com>
Replaces the registry host across 86 files: 309 references, covering all 40
app manifests, the orchestrator and container crates, the release and catalog
scripts, both demo-images workflows, the ISO builder, demo-deploy, and the
frontend marketplace data.
Verified the domain actually serves the registry before rewriting anything,
rather than assuming the web host implies the registry:
- TLS verifies clean, HTTP/2 on the web root
- an anonymous token grants a manifest fetch (HTTP 200) with no credentials
- skopeo inspect --no-creds resolves an image and lists its tags
That last check is the one that matters: an outside developer with no account
can now pull, which was the functional blocker for publishing at all.
Plain-HTTP references become HTTPS in the same pass, so OTA downloads stop
crossing the network in the clear.
Deliberately NOT rewritten:
- The public FIPS anchor on port 8444. It is a functional network endpoint
every node dials to bootstrap the mesh — closer to Bitcoin Core's hardcoded
seeds than to leaked infrastructure. The domain does resolve to the same
host, so it could become a hostname, but that adds a DNS dependency to the
path used precisely when things are broken. Worth a deliberate decision,
not a side effect of this change.
- The companion APK on port 2100. The domain returns 404 for that path, so
rewriting it would swap a working URL for a broken one. The Releases page
does serve (200), which is where the plan already wants those binaries.
- releases/app-catalog.json, releases/manifest.json and release-manifest.json.
These carry `signature` and `signed_by`; editing their contents invalidates
the signature and the fleet refuses artifacts that fail verification. They
were rewritten in a first pass and reverted — they must be regenerated and
re-signed through the signing ceremony instead, which needs the mnemonic.
So the catalog still advertises the old host until that ceremony runs. Nodes
resolve images through the signed catalog, not the on-disk manifests, so this
commit alone does not change what a node pulls.
Verified: archipelago-container 75/75; every manifest still parses with a
top-level app block; no signed artifact modified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 9abc1623 banner waited on refused_categories, but refused_categories
only fills when the model CALLS a gated tool — and D-16 hides ungranted
tools from the prompt, so the model never calls: it answered 'I can't do
that' in prose and the banner never fired. Live-verified: revoke media,
ask for content, no banner.
- build_system_prompt takes the disabled categories and teaches a marker:
'say it can be switched on in Settings → AI Data Access and end with
[[needs:<id>]]' — category names only, never tool names (D-16 holds)
- extract_needs_markers strips the markers from the reply and folds them
into refused_categories; unknown ids pass through as text (an offer is
the worst a bad marker can cause — never a grant)
- egress's seed-screen test now covers the new paragraph too
Co-Authored-By: Claude <noreply@anthropic.com>
Keeps the dev and test tooling an outside contributor would want, and takes
our node addresses out of it.
Scripts that silently defaulted to one of our nodes now require an explicit
host and exit 2 without one: smoke-test.sh, trust-archipelago-cert.sh,
dev-container-test.sh (which also derives its RPC and health URLs from the
SSH target instead of a second hardcoded copy), and image-recipe/dev-branding.sh.
A default that points at a machine the user does not own is worse than no
default: it fails confusingly, or reaches a stranger's device.
Usage examples, mock data and test fixtures move to the RFC 5737
documentation range (192.0.2.0/24). CGNAT test values stay inside
100.64.0.0/10 so the range-check semantics they exercise still hold, and
192.168.1.0/.1/.254 are left alone — those are gateway logic and UI
placeholders, not our addresses.
Playwright and the perf spec defaulted their baseURL to one of our nodes;
they now default to localhost:8100, the local dev server.
Removed neode-ui APP_URLS entirely. It is dead code — exported, never
imported — and it pinned fedimint's *prod* launch URL to 192.168.1.228:8175.
Had anything consumed it, every user's node would have tried to reach an
address that on their LAN is either nothing or someone else's machine.
Deleting beats sanitizing dead config.
Verified: frontend 868/868 vitest across 108 files; archipelago-container
75/75; mesh tests 9/9; audit-secrets 5/5. Zero node addresses and zero node
names remain in tracked files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo is source code and guidelines only. Nothing about how Archipelago's
own fleet is run, or how the team works, stays in it.
Untracked (kept on disk, gitignored) — 250 files:
- .planning/ (199) and loop/ — internal development process
- fleet operations tooling that targets specific nodes: deploy-to-target,
deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server,
setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair,
image-recipe/sync-from-live.sh
- image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of
which are live-server workflow and fleet node inventories
- the Phase 10 on-node verification and evidence records, which cite .planning/
as their evidence base
KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10
docs and then put back: it is cited as normative rationale from ten places in
the codebase, including core/clippy.toml, which bans rand::thread_rng and
points at it for the reason. That makes it a guideline, not an internal record.
Node names removed from source (48 occurrences across comments, manifests and
test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt,
zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the
date, which is what carried the meaning; the machine name did not.
Three of those were live test values rather than comments and were replaced
with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name.
An earlier pass substituted "a test node" into a hostname assertion, producing
an invalid hostname; caught and fixed as test-node.local.
Wipe mechanism: .local-only/manifest.txt inventories every local-only path and
.local-only/wipe.sh deletes them on one confirmation, refusing to touch
anything git still tracks. Both are themselves untracked, so the public repo
does not carry a map of internal filenames.
Verified: cargo check -p archipelago --all-features clean; archipelago-container
75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link
in tracked markdown resolves (0 broken).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
usePlayer.play() never looked at song.sources[] — a real library track went
straight to (CSP-blocked) Wavlake and reported 'Not found on Wavlake' while
its bytes sat on the operator's disk. Node sources (same-origin /content/<id>,
Range-streamed) now play first; Wavlake is the metadata-only fallback.
FilmDetail likewise only played YouTube sources; own/peer/IndeeHub sources
(same-origin, media-src 'self') now win, YouTube stays the free-films
fallback.
Co-Authored-By: Claude <noreply@anthropic.com>
Untracking the ops docs broke every reference to them. Repoints or removes
those references across README, architecture, ROADMAP, the archive index,
lifecycle TESTING, the hardening plan, and the security docs — pointing at
the issue tracker where a live task list was meant, and dropping the entry
entirely where it only existed to link an internal file.
Also fixes two pre-existing broken links found by validating every relative
link in the tracked docs:
- README linked docs/OPEN_SOURCE_READINESS.md, which never existed
(underscores vs hyphens).
- reticulum-daemon/README.md linked a local Claude session plan at
../../.claude/plans/enchanted-strolling-rocket.md — outside the repo, and
a path that would have shipped publicly pointing at nothing.
All relative links in tracked markdown now resolve: 0 broken.
audit-secrets.sh still 5/5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operations docs move out of git entirely rather than being sanitized. They
stay on disk for local use and are gitignored, so the Phase 6 export (which
takes HEAD) can never carry them. 15 files: the fleet runbook, hotfix
process, node inventories, internal trackers, session handoffs, the key
rotation/signing-posture records, and the open-source plan itself.
For the docs that remain public, infra identifiers are replaced with things
that are better documentation rather than placeholders: curl examples now
use `archipelago.local`, the product's own mDNS name, so a reader can run
them as-is instead of substituting an address that was never theirs.
Deliberately NOT scrubbed, both verified as functional rather than leaked:
- `tx1138.com` is the shipped default block explorer (DEFAULT_TX_EXPLORER in
useTxExplorer.ts, surfaced in WalletSettingsModal). Product behavior.
- `git.tx1138.com` in core/container/{image_policy,registry}.rs is a retired-
registry constant the code matches on to strip stale entries from legacy
node configs. Removing it would break migration for older nodes.
- `192.168.1.254` in bulletproof-containers.md is the LAN gateway in a podman
bug description, and `192.168.1.x` in user-walkthrough.md is already generic.
Whether a personal domain should be the shipped explorer default in a public
product is a separate product question, not a security one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sequencing change per user decision: credential rotation/revocation runs
last, immediately before Phase 6, instead of first. Safe under fresh-history
publish — the scrub commits never become public — but recorded as a HARD
blocking gate on Phase 6, with an explicit rotation sign-off added as a
numbered pre-publish step so "scrubbed" cannot be mistaken for "rotated".
Also corrects the plan against what execution actually found:
- the fleet password was in 8 tracked files, not 7 (3 in .planning/)
- both Gitea tokens are already dead (401); only the `ai` password is live
- the Framework node's SSH password was rotated out-of-band and is unrecorded,
which would block it from receiving the fleet rotation
- .planning/ is 199 tracked files of internal agent state — added to Phase 2
as the largest un-triaged internal block still in the tree
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scrubs the fleet SSH/UI password from every tracked file (22 occurrences)
and removes inline credentials from the code paths that used them.
Docs and trackers keep the surrounding context — these are published under
docs/history/ per the open-source plan — with the literals replaced by
<FLEET_PW> / <FLEET_PW_ALT> so the "two variants exist" detail survives
without the values.
Three of the eight files were in .planning/ and were NOT in the plan's
enumerated list; the reworked audit-secrets.sh found them.
Code changes:
- neode-ui/test-openwrt.mjs: node URL and password come from ARCHY_NODE_URL /
ARCHY_NODE_PW; the SSH target derives from the URL instead of a hardcoded
tailnet IP; exits 2 when unset.
- scripts/run-post-install-tests.sh: drops the built-in "testpass123!"
default and adds --password-stdin; refuses to run unauthenticated instead
of silently trying a known password. --phase1-only still needs no password.
- .gitea/workflows/post-install-tests.yml: sshpass with an inline literal
replaced by key auth (NODE_SSH_KEY secret); password comes from the
NODE_UI_PASSWORD secret and is piped over stdin rather than argv, so it
stays out of the node's process list and the job log. Default target IP
removed.
scripts/audit-secrets.sh now reports 5/5 pass, 0 fail.
Note: rotation of the exposed credentials is deliberately deferred to the
pre-publish gate and is NOT done by this commit — these values are still
live. See Phase 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>