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>
"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>
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 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>
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 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>
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>
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>
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>
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>
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>
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>
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>
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>
The archy content latch was global and permanent: once mount-time content
latched archyContentActive, no extracted [[film_ext:…]] recommendation card
could ever render again, and an empty tool result kept the latch — so a
'recommend me films' turn beside an empty catalogue showed prose only, with
'Nothing found' overwriting nothing. The chat had lost its rich previews.
- setArchyContent records which buckets the node actually supplied
(archySupplied) and latches active only on a non-empty delivery
- updatePanelFromText's no-overwrite guard is now per-bucket: node truth
wins buckets it filled; empty buckets stay writable for extracted previews
- the extraction fallback title no longer clobbers 'Nothing found'/'Loading…'
Regression tests: previews render in an empty bucket, node truth survives
tags in a filled bucket, 'Nothing found' survives a both-empty turn.
Co-Authored-By: Claude <noreply@anthropic.com>
Two reasons the content surface 'often doesn't surface the content':
- content.indeehub-projects items carried no mime/filename, so the UI
adapter classified every film 'excluded' and the films grid could never
render. They are films: they now declare video/mp4.
- apps_list surfaced the container-list RPC's BARE ARRAY; the broker reads
{ items: [...] }, so the apps grid was silently dropped every turn.
Wrapped at the tool boundary — the shared RPC's own shape is untouched.
Co-Authored-By: Claude <noreply@anthropic.com>
'recommend me 10 scifi films' answered prose-only and OFFERED to check the
catalogue — the preamble invited knowledge recommendations (paragraph 2) but
only ordered tool calls for existence questions (paragraph 3), so the model
never ran the tool and emitted none of the tags the iframe renders as rich
preview cards.
- discovery of a kind the node could hold (films/music/books…) now gets a
catalogue-and-peers check FIRST, knowledge picks on top
- the 'would you like me to look?' stall is banned outright — looking is
one tool call, do it then answer
- the preamble teaches the exact [[film_ext:Title|Year|Director]] /
tv_ext / song_ext / book_ext / podcast_ext formats, same-line with the
one-line reason, real titles only, never for items the tool returned
Co-Authored-By: Claude <noreply@anthropic.com>
vue-tsc rejected the double cast; vitest strips types so it slipped
through. Narrow once into a local instead.
Co-Authored-By: Claude <noreply@anthropic.com>
- serve_content takes owner_session: a validated operator session skips the
availability/paid gates (Availability::Nobody stays delisted); the cookie
is re-validated in the content handler, same discipline as the model proxy
- the Tor proxy serves already-purchased items from the local content_owned
cache with Range slicing (206) instead of re-hitting the seller's 402 —
the buyer-side store exists so an owned item is never bought twice, and
its cards were rendering as permanent placeholders
- adapter: 'own'-scope items never render locked (a locked card suppresses
the playable URL — the placeholder-only grid the operator reported)
- broker: normalize 'purchased' OwnedRpcItems per item with the seller's
onion, and group 'peers' items per seller onion, so buildMediaUrl gets a
peerOnion and card URLs stop coming out empty
Co-Authored-By: Claude <noreply@anthropic.com>
Reproduced live: 'show me paid for peer files' returned surfaces=1 with 3
purchased images over the bridge, then updatePanelFromText set panelOpen
from the REGEX-inferred tab list — a plain markdown list matches nothing,
so the panel closed and the user saw prose only. panelOpen now follows
orderedTabs (Archy tabs lead). Regression test pins the exact turn.
Co-Authored-By: Claude <noreply@anthropic.com>
S4: /aiui/api/web-search proxied straight to SearXNG with no auth — anyone
reaching the web port ran searches attributed to the node's IP. Now routed
through the daemon's session-gated model proxy like the claude/ollama legs
(both nginx server blocks), forcing format=json upstream (the client never
sent it — search could 200 with HTML that parsed as nothing).
S3: the forwarder also serves the STANDALONE frontend, whose bodies carry
full history/images with no assistant loop behind them — a pasted seed
phrase went to Anthropic unscreened. The forwarder now runs the egress
secret-shape scan (G-B1) with the node's own secrets dir as deny corpus on
Claude bodies and search queries; blocked requests get a plain-language 400.
Also fixes a REAL gap in the egress tokenizer found by these tests: a JSON
key glued to a string value's first word ('content":"abandon...') dropped
that word, so an exactly-12-word seed pasted as a bare message yielded an
11-member run — checksum misses, backstop misses. Non-member words now
rescan within the token. egress 15/15 + model_proxy 10/10 green.
Co-Authored-By: Claude <noreply@anthropic.com>
The browser broker redacted log lines (password=/token=/macaroon key=value,
64+ hex, 64+ base64) while the node-side tool only untrusted-wrapped — so a
log line carrying rpcpassword=<32-hex> crossed to cloud backends below the
egress screen's threshold. Port the broker's three patterns to the tool
boundary as a pure line redactor + JSON walker; unit-tested (124 assistant
tests green).
Co-Authored-By: Claude <noreply@anthropic.com>
ai_grants_unified UNIONED the assistant grants.json with the legacy
settings/ai_permissions.json on every read. On archi-dev-box legacy held
all-ten and grants.json held four, so the Settings UI and the AIUI frame
saw every category ON while the assistant refused six — and no UI toggle
could fix it, because both write paths existed but only ai.permissions.set
synced both files. Now: an existing grants.json answers alone; the legacy
file is consulted only when no grants file exists (pre-unification
upgrade), and that read migrates forward and persists the authority.
assistant.grants-set now also rewrites the legacy file in step. Regression
tests: authority is not widened by legacy; migration folds forward once.
Co-Authored-By: Claude <noreply@anthropic.com>
Per operator decision 2026-08-07 (mock content is isolated to
demo.archipelago-foundation.org, never in shipped code): the auto-seeded
'node-demo' conversation (invented balances, file listings, bitcoin.conf)
no longer ships on nodes — a VITE_DEMO_CONTENT build flag (or dev) gates
it, /seed, and the Guide 'Load Demo' button. The genuine onboarding guide
still seeds everywhere. The fixture's bitcoin.conf rpcpassword is now
unmistakably example-shaped: fake must never look like a real credential.
Co-Authored-By: Claude <noreply@anthropic.com>
The key rode the wholesale settings→localStorage save, sitting at rest
readable by any same-origin script, while the AES-256-GCM key-vault built
for exactly this sat bypassed. Now: the key lives in a memory-only store
ref, persists only into the encrypted vault when a passphrase session is
active (migrating into the vault on unlock), and a one-time migration lifts
any existing plaintext key out of localStorage and re-saves the scrubbed
settings object immediately. Settings UI reports honestly how the key is
held. Typecheck clean; test suite unchanged (348 pass, 3 pre-existing fails).
Co-Authored-By: Claude <noreply@anthropic.com>
The Network permission's Settings label promises 'no IP addresses', and the
browser-side broker honours it — but the node-side tool forwarded
network.diagnostics verbatim, so a granted Network category sent the node's
WAN IP and SSID (both location-identifying) to cloud model backends. Strip
both at the tool boundary; NAT/UPnP/Tor/DNS connectivity shape stays.
Pure helper + unit test (123 assistant tests green).
Co-Authored-By: Claude <noreply@anthropic.com>
Handoff carries the peer-files correction, the podman-lifecycle trap, the
concurrent-agent warning, and the release-binary drift that blocks the ISO.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carries the full task list with per-item status, the three commits'
rationale, the live measurements that overturned the earlier
peers-have-no-content conclusion, the browser-verification recipe, and
the binary-drift blocker that must clear before the ISO.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Browser-verified on archi-dev-box; all three were only visible by
driving the real UI.
The tab bar. `setArchyContent` put the node's grids up, then
`updatePanelFromText` replaced the bar with tabs inferred from the reply
text. "show me my own shared content" therefore landed on an "AI Brief"
— a prose restatement of the answer already on the left — with the
populated image grid no longer reachable. Guarding the panel arrays was
not enough: they held the right data while the tab bar had discarded the
way to see it. Archy tabs now lead, and the title follows the leading
tab. The prose stays; it just is not the only thing shown.
Tab order follows bucket size. A node with 13 photos and 2 tracks opened
on Songs and titled itself "2 Songs" for a 15-item answer.
Web search never worked embedded. `searchWeb` hardcoded
`/api/web-search` while every other call is built from BASE_URL. Under
`/aiui/` that asked the HOST for a path only the AIUI-scoped nginx
location serves, so it hit the node's API gate for a 403 and the CSP
refused the connection on top. Now BASE-relative. Additionally, the
embedded path skips the client-side search entirely: `streamViaArchy`
sends only the user's text, so the system prompt those results were
folded into is never transmitted — it was a round trip and a console
error per turn whose output provably reached no model. Web search for
the embedded path belongs node-side, with the other tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SearXNG defaults to `formats: [html]`. Its JSON API answers 403 —
and JSON is the only thing AIUI's web search speaks, since
`/aiui/api/web-search` proxies straight through to `/search`. Both
places that seed settings.yml (the first-boot script and the installer)
omitted `search.formats`, so web search has never worked on a node
whose SearXNG was installed, running and healthy. It reads as the
assistant being unable to search rather than as one missing config key.
Verified on archi-dev-box: `format=json` went 403 -> 200, returning 28
results for "bitcoin halving" from Brave and DuckDuckGo. Google and
Startpage self-suspend on a self-hosted instance (access denied /
CAPTCHA), which is expected and costs little given Brave's independent
index.
Existing nodes need the same two lines added to
/var/lib/archipelago/searxng/settings.yml and a restart; this commit
only fixes what new installs get.
Also fixes a build break: `fetchLibraryContent` built a bundle literal
that predates the images bucket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects, one visible symptom: a correct prose answer beside an
empty grid.
1. The assistant's curated RPC bridge had an arm only for
`content.list-mine`. `tools.rs` mapped the `peers`, `purchased` and
`films` scopes onto three real, dispatcher-registered handlers that
`assistant_dispatch_tool` had never heard of, so every non-"own"
scope died on its catch-all. Downstream that read as "the peers have
no content" — it was a missing match arm, and the tool never ran.
Regression test added: every scope the schema advertises must reach a
real handler.
2. `content.browse-all-peers` wrapped its whole fan-out in one
`timeout(..).unwrap_or_default()`, which DISCARDED every completed
batch the moment the budget expired. One slow peer turned a
partly-successful browse into "0 reached, 16 unreachable". Observed
live on archi-dev-box: back-to-back calls returned real peer items,
then nothing. Now accumulates per batch and checks a deadline between
them, so partial results always survive. Budget 20s -> 45s: two
batches of eight at a 10s per-peer timeout had no headroom at all.
3. `assistant.chat` returned only `{ text }`. The structured results of
any content tool the turn ran were dropped inside the loop, so the
surface had nothing to render. The turn now carries them through
(captured raw, before the untrusted wrap, since they go to a renderer
that treats every field as inert data, never back into the prompt).
4. The adapter classified images as 'excluded' and dropped them. A node
sharing mostly photos rendered as an empty grid while AIUI's image
grid sat unused. Images now have a bucket, with the paid-lock and
extension-fallback handling audio and video already had.
Also: the panel says "Loading…" while a turn is in flight and "Nothing
found" when it comes back empty, instead of leaving the previous
query's heading standing as though it answered this one; the system
prompt tells the model to call the content tool and summarise rather
than re-list what the cards already show; and a refused tool now names
its permission category so the trusted chrome can offer the settings
screen instead of leaving "I don't have a tool for that" as the only
clue.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"What films are there from my peers" has three causes behind one answer, and
only one is being worked. The tool gap is in flight in a concurrent session
(content_list + SURFACE_TOOLS). Separately and unowned: AIUI declares six
context categories while the broker serves ten, so media/search/ai-local/notes
cannot be requested by AIUI at all — sanitizeMedia sits behind a door AIUI
cannot open, which is likely why the model claimed no capability rather than
reporting an empty library.
And the part neither fixes: measured with a real node-signed Nostr session
through the gate, /api/projects and /api/projects/private both return 0 items.
A correct "0 results" will be indistinguishable from a broken tool, so seed a
project or verify against a peer that has content before calling it done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Persists the 17-item session task list so it can be rebuilt in a fresh session
(the task tool is session-scoped and would otherwise evaporate), with what
shipped and what each remaining item actually is.
Records the findings that change expectations rather than leaving them to be
rediscovered: the 16 federated peers are not serving content so peers_reached 0
is correct, IndeeHub's catalogue is genuinely empty, two AI permission stores
existed for the same ten categories, and tailscaled owns :443 so nginx must
bind LAN addresses explicitly or it fails EADDRINUSE and silently keeps the old
config.
STATE.md's stopped_at points at it, so /gsd-resume-work lands correctly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UNIFIED GRANTS. There were two stores for the same ten categories:
settings/ai_permissions.json (what Settings wrote) and
assistant/grants.json (what actually gates the tool list). Toggling Settings
did nothing for the assistant, so with grants stuck at {"apps","system"} the
model truthfully answered "I don't have a tool for that" no matter what the
operator enabled — the real cause behind "the settings I enable keep
disabling". Their serde forms already matched one-for-one, so this is a
duplicate rather than two concepts. ai.permissions.get/set now read and write
the assistant's grants; the legacy file is still written so a downgrade does
not lose grants, and anything recorded only there is folded in on read.
PEER BROWSE now mirrors Cloud.vue's peer-files fan-out, as the operator asked:
concurrent with a cap and a per-peer timeout, rather than sequential. Cloud
caps at 3 because CHROMIUM's connection pool was starved (02-08) — a browser
constraint the daemon does not share, and measurably wrong here: at 3 a 20s
budget got through 2 batches of 16 peers and reached none. At 8 every peer is
attempted inside the budget.
Measured after deploying: 20.0s, peers_total 16, peers_reached 0. FIPS itself
is healthy (anchor connected, 3 authenticated peers, 4 fips_ok dials) but 14
dials fall back and fail, so the peers are not serving /content. The empty
film list is therefore correct — the transport works and the peers are down.
Reported as partial with counts so the assistant can say so instead of
implying the peers have nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
content.browse-all-peers had a per-peer timeout but no OVERALL budget. On this
node that meant >45s with no answer, which the assistant reported to the
operator as "having trouble accessing the peer content list". Measured cause:
16 federated peers, 1 reachable. Now bounded to 20s total, returning partial
results with peers_reached / peers_total / peers_unreachable / partial, so the
assistant can say "1 of 16 peers answered" instead of implying the rest have
nothing. Verified on the node: 20.015s, was >45s.
NodeCertificateSection had no container — I copied a section that sits INSIDE a
card rather than one that provides its own. Now uses the same
`glass-card px-6 py-6 mb-6` shell and heading level as every other settings
section, so it matches on desktop and mobile.
setup-node-ca.sh now also ensures the nginx HTTPS listener, because a CA is
useless if nothing serves TLS. It binds LAN addresses ONLY: tailscaled already
owns :443 on the tailnet addresses with its own Let's Encrypt cert, so a plain
`listen 443 default_server` binds 0.0.0.0 and fails EADDRINUSE — and nginx then
keeps running the OLD config while the reload reports success. Hit exactly that
on archi-dev-box. Port 80 keeps serving: nodes are reached by IP on LANs where
forcing a redirect would strand anyone who has not installed the CA.
Live now: https://192.168.63.240/ and https://<host>.local/ both 200 with
verify=0 against the node CA, http still 200, tailscaled's 443 untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"[Vue Error] ReferenceError: Cannot access 'b' before initialization" from
Ye.immediate, taking the whole Mesh view down.
A watcher with `immediate: true` runs DURING setup. This one calls
handleFetchContent, whose body touches consts declared further down the setup
block — so on any session where history already contained an inline
content_ref, it dereferenced a binding that did not exist yet. handleFetchContent
itself is a hoisted `function`, which is why the call site looked innocent.
The initial pass moves to onMounted, which runs after setup completes: every
binding is initialized, and already-loaded history still gets the same
treatment as new messages, which is what `immediate` was there for.
Also adds .planning/todos/pending/2026-08-07-open-task-list.md — one flat list
of everything open, including the app-lifecycle reports (fedimint guardian
installs but does not work, BTCPay wipe not wiping, Bitcoin Knots vanishing,
fedimint gateway dying at 88%), the missing app_install tool behind
"!ai install bitcoin knots", and the LND UI 401s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator asked "what films are there to watch from my peers" and the model
answered, honestly, that it had no tool for it. It was right: content_list
mapped only to content.list-mine — this node's own shared files. Peer
catalogues and IndeeHub were unreachable from the assistant entirely.
content_list now takes scope: own | peers | purchased | films, dispatching to
content.list-mine / content.browse-all-peers / content.owned-list /
content.indeehub-projects. The model picks from a closed enum and never names
a method, so an invented scope falls back to "own" rather than reaching
anything it was not granted (T-13-34).
Two new RPCs behind it:
- content.browse-all-peers aggregates every federated peer in ONE call. The
dashboard fans this out client-side, but asking a model to enumerate peers
and loop is how it ends up claiming it has no tool. Rides FIPS —
PeerRequest::new(fips_npub, onion, "/content") with a 6s FIPS fast-fail then
Tor — so the onion is the peer's identity and FIPS is the transport.
Sequential with a per-peer timeout, not an unbounded fan-out: 02-08 traced a
real UI stall to browse-peer starving the connection pool. One peer being
down is the normal case and contributes nothing rather than failing the call.
- content.indeehub-projects fetches IndeeHub's catalogue, public plus (via a
node-signed NIP-98 login) the operator's private titles. Node-side because
signing that in the browser would put identity material next to the model,
which this phase rules out by name. Tolerant of IndeeHub's field spellings
across versions, and absent/stopped/empty all yield an empty list rather
than failing the caller.
action_key includes the scope, so listing peers cannot be replayed as listing
own files. 15/15 assistant::tools.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regression from wiring the peers scope: requestArchyAllContent awaited all
three scopes together, so the grid waited on the slowest. `peers` browses every
federated node over FIPS (Tor fallback) and routinely takes tens of seconds or
times out when a peer is offline. On-device that read as
"content(peers) failed: Content request timed out" plus an AIUI that felt very
slow to open — with nothing rendered meanwhile, even though local content was
ready immediately.
Now `own` paints as soon as it lands and `owned`/`peers` fold in as they
arrive. A scope that times out costs only its own results.
Also records the operator's console findings as tasks: the `files` context
timeout, the web-search CSP block (13-09, now firing on every query), the
strfry icon 404, IndeeHub's relay.nostr.band socket, and the ask that `!archy`
over mesh be able to action container commands with text responses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator-reported via a Bitcoin-films transcript: "Banking on Bitcoin" was
captioned with *The Rise and Rise of Bitcoin*'s description, "Cryptopia" with
*The Bitcoin Standard*'s, and the section header "Documentaries:" bled into the
first card of each group. Read as the model talking nonsense; the model's prose
was correct throughout and only the pairing was wrong.
Several patterns anchor with `(?:^|\n)` so they fire only at a line start.
That makes m.index point at the NEWLINE — one character before the line the
match is really on — so extractDescriptionForTag's window, which walks back
from `matchIndex - 1`, landed on the PREVIOUS line. The description became
"previous line + this item's own text".
Normalised inside the helper rather than at each of its nine call sites, so a
pattern that gains a `(?:^|\n)` anchor later cannot silently reintroduce it.
Fault-injected to prove the tests are not vacuous: with the fix removed, two
fail with exactly the reported strings — 'Documentaries: – Early documentary
fo…' and 'The Rise and Rise of Bitcoin – Early …'. 26/26 with it restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator: "the AI Data Access settings are not persistent through sessions,
often turns them all off."
They were stored in localStorage, which is scoped to an ORIGIN — and a node
answers on several: LAN address, Tailscale address, <host>.local, hostname.
Granting Media over the LAN and returning over Tailscale showed every switch
off again. Not reset: never set *there*. It also made a working content path
look broken, because every scope silently returns nothing without a grant, so
an ungranted permission is indistinguishable from an empty library — that is
exactly what an empty films search turned out to be.
The grant answers "what may the assistant read about THIS NODE", which is a
property of the node, not of one browser at one address. New
settings/ai_permissions.rs (same shape as session_policy: atomic temp+rename,
sanitised on read and write, fails closed on a corrupt file — an unreadable
grant file must never read as "everything allowed"). New ai.permissions.get /
.set, absent from the unauthenticated allowlist so they require a session.
Migration, not replacement: if this browser holds grants and the node holds
none, the local set is pushed UP rather than wiped. Without that, upgrading
would silently revoke the grants of everyone who set them before this change.
The node still wins in every other direction, so a revocation made on one
device takes effect everywhere — otherwise revoking would be impossible from a
second device.
Unknown category ids are stored verbatim rather than validated against a
hardcoded list: a third copy of that list would silently drop a new category on
upgrade. Storing a category grants nothing by itself — the broker checks before
fetching and the node re-checks before answering (T-13-33).
Hydration happens ONCE at broker start, not inside each permission gate: the
gates are hot-path, and awaiting there adds an RPC to every content and context
request. The first attempt did it per-gate and the existing broker tests caught
it by failing on consumed mocks.
Rust 7/7, store 18/18, broker 23/23.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The node signed a real NIP-98 event with its own key and presented it to
IndeeHub through the gate: 200, with a real JWT pair issued. The app's own
bearer token then rides back through the gate — /api/auth/me,
/api/projects/private and /api/projects all 200, matching loopback.
/api/projects/private was the endpoint recorded as unreachable without a
Nostr session, so item 4's private-films path is unblocked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
secret_shaped_content_never_reaches_the_stub was RED because its fixture was
the first twelve wordlist entries — not a parseable mnemonic. The 2026-08-06
precision rewrite of screen_outbound moved from a word-run shape rule to BIP-39
checksum validation (the shape rule had blocked legitimate turns on a live node
twice); egress.rs's own test was updated to a checksum-valid fixture and this
copy was not, so it asserted behaviour that had been deliberately retired.
The named behaviour was intact throughout: screen_outbound runs on the Routstr
paid leg before any body is sent, a real mnemonic is blocked, and
checksum-invalid runs of 20+ wordlist members are still caught by
IMPLAUSIBLE_MEMBER_RUN. Fixture is now a checksum-valid mnemonic, asserted as
parseable so it cannot silently rot the same way again.
Also records the operator's rendering contract in the surfaces todo: chat gets
the mini version, the content/context surfaces expand it, nothing rich may
overflow the bubble at mobile width.
Nine of ten turns in the exported transcript answered in markdown prose where
the content surface (grids/cards) and context surface should have carried it.
Records each turn against the surface it should drive, plus two security items
found in the same evidence: a cleartext rpcpassword rendered into the chat, and
the RED screen_outbound test that lets a seed-shaped body reach a third-party
inference provider.
Item 1 was not an interception problem and needed no session-aware rule —
the gate was deleting the app's own Authorization header. Item 4's /relay
502 was a root-owned volume, not networking. Both deployed and verified on
archi-dev-box; the extension login itself still needs a human in a browser.
The gate removed `Authorization` unconditionally before proxying, so every
credential an app owns was destroyed one hop before the app saw it.
IndeeHub's Nostr login is the reported case: it signs a NIP-98 event and
sends `Authorization: Nostr <event>` to its own /api/auth/nostr/session.
The header arrived stripped and its backend answered "Authorization header
is missing" — a 401 that no signer could ever satisfy. That is why a NIP-07
browser extension in a tab, the parent frame's NIP-07 bridge
(nostr-provider.js) and AIUI all broke at once while the signing itself was
never at fault. Proven on the node: the same POST returns a real NIP-98
validation error on loopback and the gate's login page through the gate.
The gate accepts exactly one header credential — `Authorization: Bearer
<app-scoped device token>` — so only that one is ours to withhold. authorize()
now reports which credential allowed the request, and the header is dropped
only when it WAS the gate's token, mirroring the surgical cookie strip
directly above it. Any other scheme (Nostr, Basic, an app's own bearer)
rides through untouched.
Credential-less allowlist paths still drop the header: nothing there needs
auth, so an unverified token is not handed to the app.
Tests: an app's Authorization is not classified as the gate's, and a real
proxy hop against a local server shows the app's credential arriving intact
while a gate device token does not.
Everything needed to continue cold: the loop protocol with real deploy and
verify commands, the ordered work list with each item's evidence, Phase 13's
exact remaining state (13-15 only, check 4 passed on-device), and the traps
that cost time tonight — verify on the node not from source, rustls does not
check key/cert pairing, build-aiui.sh hangs after succeeding, AIUI needs
VITE_BASE_PATH=/aiui/.
STATE.md's stopped_at now points at it, so /gsd-resume-work lands correctly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IndeeHub worked all year and broke when the gate rolled out. Cause, verified on
the node: GET /manifest.json returns 401 + the gate's login HTML. A browser
fetches <link rel="manifest"> in no-credentials mode unless the tag opts in
with crossorigin="use-credentials", so the session cookie is NEVER offered and
the gate challenges a fully authenticated user. The app's service worker then
serves its cached shell, whose every network call fails — which reads as "the
app is broken" rather than "the gate refused it". Any gated app with a PWA
manifest has the same failure.
Passed through unauthenticated on purpose, and deliberately as small as the
problem: an EXACT-match allowlist of /manifest.json, /site.webmanifest and
/favicon.ico. Static, non-user-specific, and no more revealing than the gate's
own login page, which already shows the app's name and icon.
Exact match, never a prefix — a prefix would let /manifest.json/../api/secrets
ride through. A test pins that: 8 near-miss paths (traversal, query-string
traversal, /api/manifest.json, /manifest.jsonx, case variants, /admin,
/api/auth/nostr/session) must all still be challenged.
19/19 appgate tests pass. This does NOT address the app's own auth endpoints
being intercepted — that needs a session-aware decision and is recorded
separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every item was observed on archi-dev-box or read from source, not inferred:
the content-card parser mispairing titles with the previous description (the
real cause of "idiotic responses" — the model's prose was correct), IndeeHub's
three independent faults (empty public library, Nostr-only private auth, relay
502 on loopback), the fleet-wide gate bug that 401s credential-less PWA
manifest fetches and app-owned auth endpoints, and AI Data Access grants living
in per-origin localStorage when they are a property of the node.
Input for a research + plan pass, explicitly not the plan itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On-device, an empty films search looked like a broken fetch. The console said
only "library: not permitted" — the content scopes returned null without a
word, so an ungranted Media/File permission was indistinguishable from "this
node genuinely has no films". That ambiguity cost real diagnosis time and sent
me looking for a code fault that was not there.
Each scope now names itself when denied. The permission was the whole cause;
no content path was broken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported on-device: searching for films in AIUI returns nothing. Init only ever
asked for scope 'own' (content.list-mine — this node's own shared files), so
IndeeHub and everything else purchased, which lives in 'owned'
(content.owned-list), and other nodes' catalogs in 'peers' were never fetched.
Both scopes existed only as type-signature options with no call site anywhere
in the app.
requestArchyAllContent() now loads all three concurrently and merges once.
Merged rather than three setArchyContent calls because that sink REPLACES
films/podcasts — separate pushes would leave only whichever resolved last, the
same class of bug as the shared sequence guard fixed in aac81503. Deduped by
id, since a title can legitimately appear both owned locally and offered by a
peer. Each scope is caught individually so one dead or slow peer costs only its
own results, which is normal rather than exceptional.
requestArchyContent also stops clobbering songs with an empty array, mirroring
what requestArchyLibrary already did for films/podcasts.
vue-tsc clean, 3/3 useArchy tests pass, and the change is verified present in
the built bundle rather than assumed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found on archi-dev-box the moment the gate tried to serve TLS: the key was
installed root:root 0600, nginx's master reads it as root, but the archipelago
daemon runs as User=archipelago and got "Permission denied (os error 13)".
Every app port then quietly stayed plain HTTP — the exact fail-open shape the
gate exists to prevent, and it would have looked like "TLS just doesn't work"
with no obvious cause. The warn-level log the tls module deliberately emits for
a present-but-unloadable certificate is what turned this into a ten-second
diagnosis instead of a hunt; it earned its keep on its first real deployment.
Key is now group-owned by the service user at 0640, with a fallback to the
user's primary group and a clear message when no such user exists. Nothing
wider than that.
Verified on the node afterwards, on one gated port (8096):
https 401 verify=0 TLS terminated, chain valid against the node CA
http 401 same port, plain HTTP, unchanged
no CA verify=20 untrusted client correctly rejected
The reissued key was also picked up with NO daemon restart — the mtime reload
path proven in production, not just in a unit test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records why 13-15's check 2 cannot pass as written (peers/owned scopes have no
caller) and the merge design settled before stopping, so the next session does
not rediscover that setArchyContent replaces rather than merges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The AIUI-03 stale-response guard used ONE counter for every content:request,
so requests for different kinds cancelled each other.
useArchy.ts init fires content('all','own') and library('own') back to back.
Both sequence numbers are assigned synchronously, before either awaits, so the
first request always resolved with a stale number and was silently discarded.
Films, podcasts and this node's own files never reached the grid no matter what
the user did — only music ever arrived. Nothing logged, because discarding is
the guard working as written.
The guard is now keyed by kind+scope. Different kinds populate different grids
and cannot stale each other by definition; only a newer request for the same
grid can, which is what the guard was actually for. The existing out-of-order
test (same kind twice) is untouched and still passes.
This is the same shape as the defect 13-11 already fixed once: machinery built
and unit-tested end to end, while nothing real ever reached the UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Includes the rustls finding (it does not verify key/certificate pairing) so
the explicit check is not later mistaken for redundant, and the archi-dev-box
caveat: it has no HTTPS dashboard, so it cannot reproduce the iframe failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An app port must answer whatever the browser asks for: an HTTP dashboard
embeds http://host:PORT, an HTTPS one embeds https://host:PORT, and an HTTPS
page cannot embed an HTTP frame at all. So the choice is per-node, not
per-fleet, and a second port number would mean every manifest changes and
torrc doubles.
Instead the gate peeks the first byte. A TLS ClientHello is 0x16; no HTTP
method starts with it. peek() leaves the bytes in the socket buffer, so the
acceptor still sees a complete, untouched ClientHello. TLS and plain share one
generic serve_http(), so authentication, proxying and upgrade handling cannot
drift apart by scheme.
EXISTING NODES ARE UNAFFECTED BY CONSTRUCTION. Anything that is not a TLS
handshake takes the identical path as before, and a node with no certificate
serves plain HTTP exactly as today — TLS is strictly additive.
rustls does NOT verify that a private key matches its certificate. Established
by test, not assumed: with_single_cert accepted a pair from two different keys
and would only have failed mid-handshake in a user's browser — a security
control that reports success and does nothing, the exact shape this module's
own docs warn about. So the pairing is now proven explicitly (sign a fixed
message with the key, verify against the certificate's public key) and a
mismatch refuses to serve.
Also: cert and key mtimes are stamped as a PAIR, because reissuing writes them
separately and keying on one would serve a certificate that no longer matches
its key; a 15s first-byte timeout closes the slowloris window one step earlier
than the existing header-read timeout; PKCS#8 and PKCS#1 keys are both
accepted so a hand-made key does not silently downgrade a working node.
Deps pinned to the rustls 0.21 line reqwest already resolves — no new vendor,
no second rustls major. Test fixtures are throwaway (localhost SANs only), not
any node's identity.
38/38 appgate tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both schemes now work, and each one works properly:
- HTTP dashboard -> http app origin (unchanged; no certificate needed)
- HTTPS dashboard -> https app origin (needs the node CA + TLS on the port)
The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed
content — blocked outright, before the SameSite cookie question the symptom
was filed under. It is also what made the two origins schemefully cross-site,
so following the page's scheme fixes both causes at once.
Backend-reported runtime URLs get the same treatment: the daemon reports
http:// because that is how the app binds locally, which is right for the node
and wrong for a browser on an HTTPS page.
pageScheme() defaults to http when location.protocol is absent (non-browser
contexts) — the safe direction, since inventing an https URL for a port that
serves no TLS would break a working setup. That default is also why the three
existing resolveAppUrl tests, whose fixture stubs location without a protocol,
keep passing unmodified rather than being edited to fit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects the iframe-login root cause on record: trust is per-origin including
port, and a cert interstitial cannot be accepted inside an iframe, so the
SameSite cookie was a downstream symptom rather than the cause.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The node served a bare self-signed leaf, so a browser exception had to be
granted per ORIGIN — scheme + host + port. The dashboard on :443 and an app
on :8334 are different origins, and a certificate interstitial CANNOT be
accepted inside an iframe, so a gated app embedded over HTTPS could never
render no matter how many warnings the user clicked through. (Mixed content
blocks the plain-HTTP variant first, before the SameSite cookie question the
symptom was originally filed under.)
A CA fixes it structurally: ports are not part of a certificate's identity, so
one leaf with the right SANs covers every port on the host, and one installed
CA trusts them all.
- scripts/setup-node-ca.sh generates the CA (4096-bit, pathlen:0, keyCertSign
only) and issues a 397-day leaf covering archipelago.local, the hostname, the
Tailscale MagicDNS name and every global address the host holds. Idempotent —
re-running reuses the CA and only reissues the leaf, so gaining an address
does not invalidate copies users already installed. --force-ca is the
deliberate escape hatch and says what it costs.
- nginx serves the public CA at /ca.crt on both schemes, unauthenticated by
design: a device fetches it before it can validate the node, so gating it
behind HTTPS or a login would be a chicken-and-egg.
- Settings → System shows the fingerprint and per-platform install steps.
crypto.subtle does not exist outside a secure context — precisely the case
this feature exists to fix — so an HTTP dashboard gets the openssl command
to verify by hand instead of a blank field.
Verified locally: chain validates, key pairs with the leaf, CA:TRUE/CA:FALSE
are correct, keys are 0600. Two TLS servers on different ports both verify
(ssl_verify_result=0) against the CA alone and are rejected without it — the
one-CA-covers-every-port claim, tested rather than assumed.
Not yet wired: app ports still serve plain HTTP. Putting TLS on them is the
next step and is what actually closes the iframe-login bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A container that is up but hasn't answered its probe yet rendered the hard
failure overlay — padlock icon, "App not reachable", "the container is
stopped". Both bitcoind (RPC -28 for its whole warm-up) and lnd (unreachable
until the wallet unlocks) sit in that window on every boot, so the node
looked broken while it was working normally.
The retry machinery was already correct: 6 × 10s of automatic re-checks, and
the app appears on its own when it answers. Only the headline was wrong. While
those retries are in flight AND the package reports running/starting/restarting
(or health "starting"), the overlay now shows the app's own pulsing icon,
"<App> is starting…", and says the container is running. Once retries are
exhausted the failure is real again and the original copy returns.
Follows the ElectrumX sync-screen precedent already in this file, which
suppresses the same overlay for the same reason. The explicit blocked-reason
and must-open-new-tab paths are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A resume that reads STATE.md plus this file was still missing real work:
two planning docs untracked on main since 2026-08-05, the indeedhub
crash-loop on .38/.88, nine items still OPEN in RELEASE-1.7.121-TASKS.md,
and 19 uncommitted files in the archy-mesh worktree. All now listed here
so this one file is the whole picture rather than most of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HANDOFF.json and .planning/.continue-here.md both described phase 09
(2026-08-02, BotFights demo work) which was fully reconciled and pushed
in both repos at the time they were written. They are the FIRST thing
/gsd-resume-work reads, so they made a clean resume open on the wrong
phase entirely. phases/02-ui-performance/.continue-here.md is likewise a
closed-out note from 2026-07-31.
STATE.md's Session Continuity now names the real fork: 13-15 blocked on
four operator browser checks, the four non-phase node/infra tasks, and
the follow-on A/B/C proposal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both had been sitting untracked in the working tree since 2026-08-05 —
exactly the "finished work lost because it was never committed" failure
CLAUDE.md's #1 process rule exists to prevent.
APP-PORT-AUTH-GATE.md carries the gate's design rationale ("you cannot
gate a socket you do not own") and, in its open questions, the TLS/scheme
fork that still blocks the gated-app iframe login: if the dashboard is
HTTPS and app ports are HTTP, a Secure session cookie is never sent.
RESUME-2026-08-05-appgate-fixes.md carries the .122-.125 release trail,
the two self-inflicted .124 bugs and their guards, and the open indeedhub
crash-loop (indeedhub-minio absent on .38/.88).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toggle must change what is POSSIBLE, not ask the frame to behave. Records
the verified mechanics: CSP is nginx-emitted (static add_header), the setting
lives only in browser localStorage today, and the node's nginx self-heal
reverts hand edits — so the setting moves node-side and the CSP derives from
it, allowlisted rather than wildcard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drafted during 13-15 device verification from what the operator actually hit:
the 13-09 CSP blocks wss:// relays and enrichment from the embed (real
regression, needs a broker-vs-widen decision), /api/tmdb and /api/web-search
are unimplemented on the node, AIUI-02 and AIUI-05 were declared but never
planned, and nostr polish + zaps were explicitly deferred by 13-CONTEXT.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three defects in the two query classifiers that pick the content tab and
its header label. Found while writing the regression test for the
"Podcast recommendations" mislabel the operator reported on-device.
- "recommend me 10 scifi films" matched NOTHING: the film rule listed
film|movie|movies but not the plural `films` — the operator's own
phrasing. It opened no content tab at all.
- "listen to a podcast" classified as `song`: the song rule's bare
`listen` was checked before the podcast rule. Specific terms now win —
podcast is matched first, and `listen to` is no longer a podcast token
(so "listen to music" stays a song query).
- A bare `show` counted as a podcast word, which is how an operator
phrases nearly everything ("show me my files", "show the logs"), so
unrelated queries rendered "Podcast recommendations".
Both classifiers are fixed identically and the reason they must agree is
now stated in each — they label the same panel. 16/16 content tests, 56/56
composable tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four defects found by on-device UAT, 2026-08-06.
1. D-08 persistence was WRITE-ONLY. chat() loaded the transcript only
AFTER the loop, to append — the model was never shown any of it. The
assistant answered "I don't have access to any previous conversation
history" with its own transcript on disk, and "and is it healthy?"
resolved to the node instead of the app just discussed. History now
replays into every turn (text only: a stale tool result must not be
re-presented as this turn's evidence), scoped by HistoryKey. The
replayed prefix is excluded from the append, or each turn would
re-persist the conversation and grow it geometrically.
2. The operator persona forbade the very answers the content surfaces
render. 13-01's prompt refuses anything without a matching tool, so
"recommend me 10 sci-fi films" was declined and the film/song/podcast
grids from 13-11 could never populate — two plans in contradiction.
The refusal rule now governs ACTIONS ON THE NODE; general questions
and recommendations are answered from the model's own knowledge.
(Whether the node should also SEARCH THE WEB depends on AIUI's
web-search setting, which embedded mode never forwards — captured as
a separate todo because it opens a new egress path.)
3. The content-surface loader labelled unrelated queries "Podcast
recommendations": the classifier matched a bare "show", which is how
operators phrase almost everything ("show me my files").
4. "Surfacing…" tracks at 0.2em and its final glyph collided with the
close button; the header now spaces them properly.
assistant::history 9/9 green incl. replay_feeds_prior_turns_back_to_the_model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second on-device failure in one session: after the wordlist fix, dev3
blocked cloud turns AGAIN mid-session as 13-10's history grew — splitting
on every non-alphabetic character let words from unrelated JSON fields
chain into one run. Both failures took the whole feature down rather than
protecting anything, which is the worse failure for a screen to have.
Shape is the wrong signal. A real mnemonic's last word encodes a checksum
over the rest, so an accidental run of English words parses as a mnemonic
only about one time in sixteen. Candidate runs are now validated with the
same bip39 crate the wallet uses:
- tokenize on whitespace (a seed phrase is space-separated); a token's
leading alphabetic segment counts, and alphanumerics after it end the
phrase, so a seed glued to a closing quote is still caught
- block only if a 12/15/18/21/24 window parses as a real mnemonic
- IMPLAUSIBLE_MEMBER_RUN (20) backstops checksum-invalid material such as
a typo'd 24-word seed, which prose cannot plausibly produce
Documented trade-off: a checksum-invalid run under 20 words no longer
blocks. The rule that did block it also blocked every legitimate turn,
twice, on a live node. 15/15 egress tests green, including the real
system prompt, scattered-JSON prose, and a genuine mnemonic in JSON.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recurring regression (reported again on .228, 2026-08-06): clicking a tx
opened the tx1138.com consent modal even though the node runs Mempool.
Root cause was never the preference — getAppState() reports
'not-installed' for an app whose container list simply has not been
fetched yet, so a click that landed before the list arrived took the
external path. Timing-dependent, hence 'fixed a thousand times'.
- container store: flag + (shared in-flight
promise) so 'not yet known' is distinguishable from 'not installed'.
- openTx: awaits real data, and the local app wins whenever installed —
including stopped/restarting, where the app session's own controls are
the right landing place. Only a genuinely app-less node goes external.
- 5 regression tests incl. the race itself; vue-tsc -b clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shape-only heuristic ('any 12 consecutive lowercase 3-8-char words')
matched ordinary prose — including the node's own system prompt — and
blocked 100% of live cloud chat turns (found on dev3, the first real
Claude call through this screen; log: kind=bip39-word-run every turn).
Membership in the crate's own bip39 English wordlist (already a dep via
seed.rs) distinguishes prose from seed material: glue words break runs,
real seeds are nothing but members. Regression test pins the real system
prompt + a clean wire body to Allow; the 12-word genuine-seed case still
blocks. 13/13 egress tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The device-detected modal's region selector now drives real RNode
settings instead of a "managed by the daemon config" shrug: choosing a
region shows its concrete plan (frequency/bw/SF/CR/power) and Apply &
Connect writes it through mesh.rnode-config-apply — the same
radio-confirmed round-trip as the Device panel, best-effort so a plan
failure never aborts the connect. RNODE_REGION_PLANS moves to
utils/loraRegions (single source shared by panel + modal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applying RF settings deliberately restarts the radio daemon (~15-20s).
Two things treated that healthy, expected gap as a fault (operator,
2026-08-06):
- radio_state was single-shot: a query landing inside the restart
window reported "The radio daemon did not answer the state query"
for a restart that was working correctly. It now retries for ~30s
and says the radio is restarting while it waits. A real device-level
refusal (not an RNode) still returns immediately.
- The device-setup modal auto-opens for any detected-but-unconnected
port, so the restart looked like a newly plugged stick and
interrupted the apply. Apply and Reboot now suppress auto-detect for
90s via mesh.suppressDeviceDetect().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>