Completes Task 3 on top of the recovered wip checkpoint (be8f24b4):
- fix the programmatically-generated MP3 fixture's frame length: lofty's
Header::read computes samples*bitrate*125/sample_rate with truncating
integer division BEFORE adding the padding byte, so the FF FB 52 C4
frame is 209 bytes, not 210 — the off-by-one made cmp_header miss the
second frame sync and reject the whole file as containing an invalid
frame (mp3_id3v24_yields_full_record now passes; fixture-only fix,
production code untouched)
- all 7 music::tags tests green; no binary audio fixtures committed
(fixtures are built byte-by-byte into tempdirs at test run time)
- extract_tags canonicalizes and confines to caller-supplied media_roots
before opening any file (T-13-20); non-audio is a distinct NotAudio
error vs the Ok/has_tags=false untagged fallback (T-13-21)
- entity types in music/mod.rs implement 13-MUSIC-MODEL.md exactly:
hybrid-identity TrackId, derived albums/artists, MUSIC_SCHEMA_VERSION=1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verbatim checkpoint of uncommitted executor work (music/mod.rs, music/tags.rs,
mod music; in main.rs) before verification. Tests not yet run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 checkpoint:decision resolved by operator: hybrid-identity (path
row key, lazily-backfilled content-hash dedupe column), derived-albums
(computed at read time from track tags, not stored rows), a single
JSON index at data_dir/music/index.json matching content_server.rs's
load_catalog precedent, and both own-library + peer sources indexed.
MUSIC_SCHEMA_VERSION starts at 1; a newer-version index on an older
binary is treated as absent rather than reinterpreted or overwritten.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recovered after a broken-pipe session cut off right after Task 4 finished:
the summary was fully written (Self-Check PASSED) but never committed.
Re-verified on resume before committing: /aiui/-scoped CSP header live on
archi-dev-box, build/verify scripts present+executable, render screenshot
intact. STATE.md advanced: 6/15 plans done, next is 13-04.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continuation executor (post-reboot) ground-truthed the 30b2e02f WIP
build-aiui.sh checkpoint as complete/correct, finished Tasks 2-3 and
scripts/verify-aiui-deploy.sh, and proved the build+deploy+verify cycle
end-to-end on the real archi-dev-box node (this machine). Paused at Task
4's remaining human/browser-required steps because the live node's nginx
config predates even 13-02 — syncing it is a bigger diff than this plan's
own CSP addition and belongs to a human-supervised deploy, not an
unsupervised executor push to a live node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post-deploy check for AIUI: resolves the LIVE chunk set by fetching the
service worker's precache manifest (sw.js — vite-plugin-pwa's
generateSW-mode workbox.precacheAndRoute([{url:...}]) array) over HTTP,
fetches each live chunk, and greps the fetched bytes for a marker string.
Exits non-zero when the marker is absent from every live chunk.
This exists because the node's assets/ directory is a never-pruned
graveyard (feedback_node_side_frontend_verify_stale_chunks): a disk grep
reports "deployed" before the deploy actually happened, because a dead
chunk from an old build still contains the old string. Never opens a
remote shell onto the node and never greps the node's filesystem directly
— every check is an HTTP fetch, exactly what a browser session would do.
Verified locally against a real AIUI build served over HTTP: a marker
actually present in a live-precached chunk (index.html) passes (exit 0,
2 chunks checked before the match); a nonexistent marker correctly fails
(exit 1) as the negative control — not a check that always passes.
Wired into deploy-to-target.sh's primary AIUI deploy path in the prior
commit (073bf6f3), which already calls this script by name after the copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds assert_safe_same_host_deploy(local_src, remote_dst) to lib/common.sh:
pure, no SSH inside it, callable directly from a test with fixed inputs.
Returns 0 only when the two already-resolved paths are equal; refuses
(non-zero, message naming both paths + the 2026-07-31 incident) on any
other same-host mismatch.
This closes a real gap in the 2026-07-31 incident's original fix: the old
guard's two `case` blocks refused only containment (source-in-destination
or destination-in-source). A SIBLING directory — for example this very
worktree, archy-phase13, deploying onto TARGET_DIR's resolved symlink
target (archy, the main checkout) — is neither contained by nor containing
of the destination, so the old guard let it through and `rsync --delete`
would have mirrored the sibling onto the main checkout, deleting everything
the sibling lacks. Found while retargeting deploy-to-target.sh for D-19,
not a D-19 effect itself.
deploy-to-target.sh's guard block now calls assert_safe_same_host_deploy
instead of the two inline containment-only case blocks (old logic removed,
not left dead alongside the new call).
tests/production-quality/deploy-guard-same-host.sh pins all five
<behavior> cases (identical/contained/containing/sibling/unrelated)
against the function with no SSH, no rsync, no real deploy — including the
exact archy-phase13-vs-archy pair as the sibling-directory regression pin.
Manually confirmed non-vacuous: flipping the sibling fixture's expectation
to "allow" makes the test fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes the 13-09 build-aiui.sh WIP checkpoint (30b2e02f) that survived the
operator reboot ground-truthed and confirmed correct: require_base_path,
frozen-lockfile install, verify_dist's asset-href/commit-attribution checks
all verified working against a real build. Fixed one grep-forbidden leftover
(a comment mentioning the retired scripts/aiui.pin path).
Rewires both AIUI sections of deploy-to-target.sh (primary --live path and
the --both/secondary path) and setup-aiui-server.sh to build/deploy from
aiui/packages/app/dist instead of the retired ../AIUI sibling checkout:
- Primary section now calls scripts/build-aiui.sh instead of an inline
`pnpm build`, then scripts/verify-aiui-deploy.sh after the copy. The
demo/aiui/ fallback now prints a loud, unmissable warning naming that it
is shipping a checked-in dist rather than a fresh build.
- Secondary/--both section retargeted to the in-repo dist path; its
fallback-to-.228-streaming behavior is otherwise unchanged.
- setup-aiui-server.sh calls scripts/build-aiui.sh automatically when the
dist is missing or stale, instead of printing a manual `cd ../AIUI/...`
command and exiting (D-15: enforced, not remembered).
No remaining `../AIUI` reference in either script.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build-aiui.sh (mid-write) + aiui/.gitignore, committed verbatim and UNVERIFIED
— not a task completion. The 13-09 executor will be killed by the reboot; its
continuation should read this checkpoint, judge it against the plan's
must_haves, and reset --soft / build forward as appropriate (same recovery
pattern as the 6ba52b22/13b576da broken-pipe rescue at the start of this phase).
Already committed by 13-09 before this: 6ac0ebbf (CSP sandbox task).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator-reported: keyboard opening in chat pads the tab bar and scrolls the
page instead of scaling the chat window. Triage: neode-ui already ships
interactive-widget=resizes-content + the --visual-viewport-height var, so
mobile-web Chrome resizes correctly — but an Android WebView ignores that meta
entirely, and the described pan-plus-padding is the adjustPan/edge-to-edge-
without-IME-insets signature. Companion-side fix documented for handover in
docs/companion-keyboard-viewport.md (manifest adjustResize, or IME insets when
edge-to-edge, plus a chrome://inspect verification recipe). Web side gets the
one real parity gap: AIUI's standalone index.html lacked the meta neode-ui has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator-reported: selecting a different item in the content window left the
context-surface banner showing the previous item's image. Root cause: detail
views are reused, not remounted, and useBannerFallback kept primaryIndex/
stage/apiUrl alive across the prop change — once stage hit 'api' or 'done' it
never re-evaluated. Reset is keyed on title + the primary URL set, with a
generation guard so an in-flight fetch for the old item cannot stamp its
artwork onto the new one. Heals Film/TVSeries/Book detail at once; 3
regression tests pin it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a Content-Security-Policy header to both nginx `location /aiui/`
blocks whose connect-src is scoped to the AIUI path prefix, so AIUI's
own JavaScript is browser-prevented from issuing a same-origin fetch
to /rpc/v1 with the ambient session cookie. Explicitly rejects the
`sandbox` iframe attribute (allow-scripts + allow-same-origin is the
known escape; dropping allow-same-origin breaks AIUI's storage and
its origin-checked bridge) and records why in both the nginx comment
and a new comment above the Chat.vue iframe. Adds
referrerpolicy="no-referrer" to the iframe so a media URL or page path
never leaks upstream via Referer.
Also adds an explicit `location /aiui/api/openrouter/ { return 404; }`
to both server blocks, closing 13-02's Task 3 checkpoint finding
(operator-accepted deviation 2026-08-03): the relay was already
structurally gone but the SPA catch-all served 200/405 instead of 404.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator-reported: the model-picker and conversation-menu overlays are hard to
read over busy chat content at path-glass-card's shared rgba(0,0,0,0.65).
Scoped .header-overlay-panel (0.88) in ChatHeader.vue only — path-glass-card
itself is untouched, so BookDetail/ArticleDetail/TVSeriesDetail/WebsiteDetail/
ContentPanel/ChatWindow keep their existing glass. Unlayered scoped rule beats
the @layer components class without !important, and reaches the panels through
their Teleport to body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mobile load speed and the 'how to use AIUI' brief not opening. Both noted with
the caveat that the deployed AIUI bundle is stale (pre-D-14), so they must be
reproduced against a fresh in-repo build before being chased.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
21 passed / 0 failed across assistant::, plus assistant_methods_require_session
run explicitly. Both windows had non-defect root causes: window 19 was lane
staleness (missing 0de67ca6's PortMapping test-constructor fix, which made the
whole crate's test build fail), and window 16's repeated kills were the
orchestrator's own too-short timeout sending SIGTERM on a cold build, which I
had wrongly attributed to memory contention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lane merged main at 0c4826f8, one commit before 0de67ca6 added
auth/auth_rationale to PortMapping's test constructors in prod_orchestrator.rs.
That left the lane unable to compile ANY test in the archipelago crate, which
is why 13-05 could not observe its 13 tests pass (window 19). Not a defect in
this phase's work — just staleness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
# core/archipelago/src/main.rs
Task 3: four registry-wide structural tests in tools.rs, iterating registry()
so a future tool that crosses the D-09 ceiling fails CI rather than depending
on a reviewer noticing:
- registry_never_exposes_excluded_authority (S-04/T-13-24): scans every
ToolDef's name+description for EXCLUDED_AUTHORITY_TERMS.
- read_tools_never_confirm (S-07/T-13-31): every non-destructive tool
executes via the real execute_tool choke point without raising anything
confirmation-shaped. bitcoin_status/network_status excluded from live
execution (their handlers make real outbound network calls that would
make this test flaky on a sandboxed box); their destructive:false
placement is still covered by the other assertions.
- loop_is_bounded (S-13/D-05): MAX_TURNS is enforced, and 3 consecutive
malformed-argument calls for the same tool name abort the turn with an
apology before a 4th scripted backend turn is ever polled.
- every_tool_has_explicit_category_and_destructive: sanity-checks the
registry has exactly the 13 hand-written tools (4 destructive) that made
it in, as a runtime backstop to the acceptance criteria's static grep for
`..Default::default()`.
Negative-case demonstration (per the plan's acceptance criteria): a
hypothetical `wallet_send_sats` tool with a description mentioning
"spending sats" trips EXCLUDED_AUTHORITY_TERMS's "spend" term, verified by
tracing the exact haystack-contains logic registry_never_exposes_excluded_authority
runs (see 13-05-SUMMARY.md for why this was traced rather than executed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1: registry() grows from the tracer's single system_disk_status tool to
the full 13-tool D-06 curated allowlist (9 read tools, 4 destructive write
tools), each hand-written with its own JSON Schema, PermissionCategory and
destructive flag -- nothing derived from api::rpc's method table. Adds
EXCLUDED_AUTHORITY_TERMS (D-09's excluded authority, scanned by Task 3's
registry-wide test), SETTABLE_KEYS/READABLE_SETTINGS_KEYS (AIUI-02's
hand-picked settings surface, claude_api_key permanently absent from
SETTABLE_KEYS), tools::dispatch (per-tool RPC dispatch) and
tools::validate_business_rules (allowlisted-key / installed-app-id
validation that runs before the destructive/confirm gate so a plainly-wrong
request is refused with the real reason instead of the generic
"not yet implemented" placeholder). assistant_dispatch_tool gains a params
argument and the RPC method table Task 1's tools need.
Task 2: grants.rs adds Grants (D-16 default-closed permission-category
store, persisted 0600 under data_dir/assistant/grants.json; a missing file
is default_closed(), never permissive). CallerScope::granted_categories
becomes async and reads the persisted store instead of a hardcoded default;
CallerScope::Mesh gains an `authorized` field so a mesh peer's ceiling is
never wider than the operator's own grants. ToolExecCtx gains the AI-SPEC
S-13 consecutive-validation-failure counter (>2 failures for the same tool
name aborts the turn with an apology, checked in run_loop). build_system_prompt
appends only currently-granted-category tools' names/descriptions -- an
ungranted tool never appears in the prompt string (defense in depth; the
execute_tool grant re-check is the actual gate). assistant_chat.rs adds
assistant.list-tools / assistant.grants-get / assistant.grants-set, all
routed through the existing single assistant.* dispatcher arm (dispatcher.rs
untouched, verified by git diff --exit-code).
dispatcher.rs is not touched -- all new RPC surface goes through 13-01's
assistant.* prefix arm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release gate requires every CHANGELOG version to have a matching
block in Settings > What's New. Generated by scripts/sync-whats-new.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two live incidents on archi-dev-box today, one bug. Both times a safety
decision read an ABSENT manifest field as if it were a value, and a
node's installed manifests always lag the binary — so "absent" is the
state of essentially every port on every node.
1. Gating any `session` port regardless of `bind` published Bitcoin's
loopback-only RPC 8332 on the LAN, Tailscale and IPv6 within seconds
of deploy.
2. The `bind`-keyed replacement looked safe because it protected
`bind: 127.0.0.1` ports — but LND's gRPC 10009 and REST 18080 carry
an EMPTY bind, so they fell through. One container recreate from
pinning them to loopback and breaking Zeus and every remote wallet.
`auth` is now `Option<PortAuth>`, separating two questions that were
conflated:
* `auth_policy()` — what to CLASSIFY the port as. Undeclared reports as
Session, i.e. shows in the audit as something that should be behind
the gate. Reporting is always safe.
* `auth_is_declared()` — whether the daemon may ACT. Only an explicit
declaration authorises changing how a port is published.
Also reverts the daemon-side publish rewriting entirely. The node proved
it wrong twice over: the recreate path that actually ran was in
package::install, not podman_client, so the pin never fired; and even
`bind: 127.0.0.1` written directly into the node's manifest was
overridden by the signed catalog. Publishes are built in several places
and all of them already honour `bind`, so the migration belongs in the
catalog as data — not in daemon-side inference that can only ever cover
one path and guess wrong on the rest.
Tests: 75/75 container, incl. the LND wallet-port shape (`host: 10009`,
empty bind, no auth) asserted to be non-actionable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Peering a node was not enough to message it — you had to be in radio
range once before chat worked, which defeats the point of federating.
`send_message` chose its transport from the attached radio:
let use_typed_envelope =
archy && matches!(device_type, Meshcore | Reticulum);
Only the typed path knows about FIPS/Tor. Everything else fell through to
`peer_dest_prefix`, which resolves an over-the-air ROUTING key — so on a
node running Meshtastic, or with no radio at all, sending to a federated
peer failed. It only worked once a LoRa advert had created a radio twin
for the same archipelago identity, which is precisely the "connect on
LoRa first" the operator hit.
Federation contacts are reachable off-radio by definition — that is what
`upsert_federation_peer` records with `reachable: true` — so the
transport choice must not depend on which radio is plugged in. A
federation-synthetic contact id now always takes the typed path.
This loses no radio-first behaviour: `send_typed_wire` already prefers a
REACHABLE radio twin when the payload fits the frame, and only then falls
back to FIPS and Tor. The fix routes federation contacts INTO that logic
rather than around it.
Test pins the predicate across every device type, including the two that
failed (Meshtastic, Unknown), and asserts ordinary radio contacts and
stock clients still route exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13-06 delivered the content pipeline and unit-tested it, but nothing in the
live UI invokes it, and 13-11 as written only added an equally-uncalled
sibling. No plan in the phase triggers the fetch from a UI event. Without this
AIUI-03 ships green-tested and visibly broken — empty grids. Wiring belongs
here, where useArchy.ts and ChatPage.vue's render tree are already in scope.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- archyBridge.ts: content:push case resolving the pending content:request
by id, and requestArchyContent(kind, scope) mirroring requestContext's
shape. Not in the plan's files_modified list, but required to satisfy
Task 3's own instruction to register the content:push handler on the
existing single bridge listener rather than adding a second
window.addEventListener('message') — see SUMMARY deviations.
- useContentPanel.ts: setArchyContent + archyContentActive; guards only
the panelFilms/panelSongs/panelPodcasts assignments inside
updatePanelFromText so Archy-sourced grids stay the source of truth
once populated, per plan scope. Books/TV/images/places/magazine/code/
recipes/news are untouched (13-PATTERNS.md: partial deprecation).
- useArchy.ts: requestArchyContent(kind, scope) calling
archyBridge.requestArchyContent then useContentPanel().setArchyContent.
No FilmGrid/SongGrid/NewsGrid/ContentGridView/content.ts edits (D-12).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- aiui-protocol.ts: AIUIContentRequest (kind + optional scope, no RPC
method or params) and ArchyContentPush (adapted bundle + permitted
flag).
- contextBroker.ts: handleContentRequest gates on the media/files
permission categories (either grants access), resolves scope to
content.list-mine / content.browse-peer (fanned out across every known
federation peer) / content.owned-list, and routes results through
archyContentAdapter's adaptContentItems before crossing the iframe
boundary. contentRequestSeq is a monotonic guard: a stale RPC response
that resolves after a newer content:request has started is discarded
rather than posted (AIUI-03 concurrency edge).
- contextBroker.test.ts: permission-denied, own-scope, and stale/
out-of-order coverage. Fixed a pre-existing latent flake risk in this
file — perms.toggle() is not idempotent across tests because the
permissions store persists to localStorage, which vi.clearAllMocks()
does not reset; switched the new tests to perms.enableAll().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- archyContentAdapter.ts: hand-written adaptContentItems mapping (D-12),
fixture-pinned at the adjacency, empty, ordering and paid-lock edges
named in AIUI-03; classifyByMime covers the m4a/aac/opus/wma extension
gap ShareModal.vue's mime map leaves today; buildMediaUrl never puts a
credential in a query string (T-13-32).
- filebrowser-client.ts: streamUrl now returns a query-free same-origin
raw-file URL, relying on the path=/ cookie login() already sets instead
of also putting the JWT in the URL (T-13-39 — closes the pre-existing
leak CONTEXT.md names, rather than merely not repeating it).
- filebrowserStreamUrl.test.ts: regression pin for the fix, including a
traversal case confirming sanitizePath behavior is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator-accepted deviation from 13-02 Task 3: the relay is structurally gone
but /aiui/api/openrouter/ still answers 200 via the SPA catch-all. 13-09 already
owns this nginx config, so the explicit return 404 belongs here rather than
bolted onto a completed plan.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Positive path confirmed by the operator on real hardware. Machine half
independently re-probed by the orchestrator rather than taken from the
executor's report. Openrouter status-code finding accepted as a deviation with
the reasoning recorded; explicit 404 scheduled in 13-09.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run_runtime_assets() reinstalls a second on-node copy of the nginx template
over /etc/nginx/sites-available on every daemon restart. Found on
archy-x250-dev3 during 13-02 Task 3, where a hand-patched deploy was reverted
within ~5s of the restart. Live OTA hazard: an operator can deploy an nginx
fix, watch it apply, restart, and lose it with no error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Observed live on archi-dev-box: archy-fedimint-ui rebuilt 45 times in 10
minutes, every ~35s, indefinitely. bitcoin-ui, lnd-ui and electrs-ui were
all one reconcile away from the same loop.
`context_is_newer_than_image` decides to rebuild when the build context's
newest mtime is later than `podman image inspect .Created`. The rebuild
that follows is a full layer-cache hit, so podman reuses the identical
image and leaves .Created untouched — the condition that triggered the
rebuild is still true afterwards. The check cannot converge: it rebuilds
on every reconcile tick forever, burning CPU and churning the container.
It bites after any deploy that refreshes /opt/archipelago/docker/*, which
makes the contexts newer than the shipped images — so this is fleet-wide
on every OTA, not local to one node.
Fix: stamp the context mtime that was built into an image label and
compare against that instead. A label is part of the image config, so a
cache-hit build with a new value still produces a new image — the thing
being tested does change, and the comparison settles after exactly one
rebuild. Verified against real podman before writing it: two cache-hit
builds with different label values produced distinct image IDs
(6cfdbc9bcd3e vs a9b10eb9a558), each carrying its stamp; the indexed
inspect format was checked against an image with real labels, and a
missing label prints empty (handled, along with "<no value>").
Images built before this carry no label and fall back to .Created, so
behaviour is unchanged for them and each self-heals on its first
reconcile after upgrade — nodes fix themselves rather than needing the
manual `podman build --no-cache` pass this needed by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds tests/production-quality/aiui-proxy-closed.sh (follows lnd-cors-test.sh's
shape) and deploys+runs it against a real, genuinely remote node
(archy-x250-dev3, operator-approved deviation from archi-dev-box — see
SUMMARY key-decisions for why).
Confirmed on the node: unauthenticated /aiui/api/claude/v1/messages and
/aiui/api/ollama/api/tags both 401; claude-api-proxy sidecar unit gone;
nothing listens on :3142; the second key ledger (claude-api-proxy.env) is
gone. Along the way, root-caused and worked around a real deploy-topology
gap — the daemon self-heals nginx config from a second, stale on-node
template copy on every restart, silently reverting a hand-patched fix.
One finding is reported honestly rather than tuned away: deleted
/aiui/api/openrouter/ returns 200/405 via this app's SPA catch-all, not the
plan's literal 404 — the relay is structurally gone (zero proxy_pass to
openrouter.ai), but the exact status code doesn't match the acceptance
criterion. Left open for a human decision, per this task's own instruction
not to force a probe to pass.
This is Task 3 of a checkpoint:human-verify plan with gate="blocking". The
positive-path browser check and the openrouter-finding disposition remain
for a human; this executor does not self-approve the gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found while setting up the on-node test, and both fail silently in
the same direction — the gate reports success while protecting nothing,
which is the exact failure the module was written to prevent.
1. Loopback-pinned ports were skipped entirely.
`identity.rs` dropped any port whose manifest sets `bind: 127.0.0.1`,
reasoning that a loopback publish is not externally reachable. But
`listener.rs` requires loopback-pinning as the PRECONDITION for gating —
while an app holds 0.0.0.0:<port> the kernel will not let the gate bind
that port at all. So the two contradicted each other: pinning an app, the
one action that lets the gate take over, was also what removed it from
the gated set. Completing the entire migration would have gated nothing,
and GateStatus would have reported zero unprotected ports while doing it.
`bind` cannot carry this decision, because two unrelated intentions
produce an identical loopback publish: Bitcoin's RPC 8332 is pinned so
the LAN CANNOT reach it (fronting it would newly expose it on every host
address, behind a login but exposed where it deliberately was not),
whereas a migrated app is pinned precisely so the gate CAN. Inferring
from `bind` breaks one or the other, so the intent is now declared:
`PortAuth::Local` means the first case. The three ports that are
host-local by intent (bitcoin-core/knots 8332, aiui 5180 — all already
`bind: 127.0.0.1`) say so, and a loopback publish with `auth: session`
stays gated. A test pins that property.
2. The port map was never refreshed.
`AppGate::refresh()` existed, was documented as making catalog changes
apply without a restart, and was called by nothing. The map was built
once in `new()`, so an app installed while the daemon runs would never be
gated — and would never appear in `unprotected` either, so the node would
report itself fully enforced while serving a brand-new app to anyone who
asked. The sweep now refreshes before classifying.
Tests: 22/22 appgate, 73/73 archipelago-container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every cycle has needed a manual check that releases/manifest.json got
signed, because the script would happily commit and tag one that hadn't.
The signing step is conditional: with no TTY and no
RELEASE_MASTER_MNEMONIC it prints a warning and falls through. The commit
at step 7 then ran regardless, so the release commit — and its tag —
carried an unsigned manifest.
publish-release-assets.sh already refuses to ship one, but that backstop
arrives a step too late. Nodes fetch releases/manifest.json straight from
branch `main` (the same URLs this script prints for verification), so the
COMMIT is what exposes it to the fleet, not the publish. By the time
publishing is refused, the unsigned manifest is already on main and nodes
are already declining to auto-apply.
So the same gate now runs before the commit: presence of a signature,
signed_by matching the release root, and `ceremony verify` for the crypto.
A release commit carrying a manifest no node will accept has no valid use,
so this refuses to create one rather than leave a tag that has to be
re-cut. The earlier warning is corrected too — it promised the run would
continue, which is no longer true.
Verified the predicate against three manifests: signed -> allow, signature
stripped -> refuse, signed_by swapped to another DID -> refuse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator deleted /home/archipelago/Projects/AIUI after the subtree import was
proven byte-identical (tree 5ac3173a on both sides, every branch contained in
development, no stashes, clean tree). The ../AIUI script paths no longer
resolve, so they fail loudly instead of shipping stale bytes. Still in scope
for this plan — a deploy script that dies on a missing directory is not a
shipping story — but the severity note is corrected so a future executor does
not act on a stale premise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The planner correctly flagged dev-start.sh and deploy-tailscale.sh as out of
its mandate. Verified the risk is live, not theoretical: the orphaned
pre-migration clone still exists AND still has a built packages/app/dist, so
both scripts copy stale AIUI bytes and report success rather than failing
loudly. That is the same silent-staleness class as the /assets 404. Same
one-line fix as the two scripts already in scope, so it belongs in this plan
rather than in a follow-up nobody schedules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Substantive rework, not a path swap:
- Retires D-15's pin-and-verify model. scripts/aiui.pin, pin_commit and
--update-pin are dropped outright — there is no second repository left
to pin, so build-aiui.sh now attributes a build to this repo's own
`git rev-parse HEAD` instead.
- Re-derives the build: aiui/ is an in-repo pnpm/turbo workspace with its
own package.json and lockfile but no committed node_modules, so
build-aiui.sh must `pnpm install --frozen-lockfile` before it can build
(new requirement; the old model assumed a developer's separate AIUI
clone was already installed).
- Retargets deploy-to-target.sh (both its primary and --both/secondary
AIUI sections) and setup-aiui-server.sh off the stale
$PROJECT_DIR/../AIUI/packages/app/dist path, which still resolves on
disk to a stale pre-migration clone and would otherwise silently ship
old bytes instead of failing loudly.
- Carries the /aiui/-scoped CSP sandbox work (AIUI-04) through unchanged
per D-19, and fixes two acceptance-criteria drifts discovered while
verifying the plan against deploy-to-target.sh's post-13-02 state and
nginx-archipelago.conf's post-pentest-hardening state (CSP header count
and the "no session gate needed" grep), neither of which is a D-19
effect.
- Folds in a real defect found while doing this work: the 2026-07-31
same-host deploy guard only catches path containment, not sibling
directories — the exact shape this worktree's own topology exhibits
(archy-phase13 as a sibling of the main checkout, reachable over
loopback SSH). New Task 3 widens it to refuse any same-host
source/destination mismatch, extracted into a testable
assert_safe_same_host_deploy in scripts/lib/common.sh and pinned by
tests/production-quality/deploy-guard-same-host.sh. The checkpoint task
is renumbered Task 3 -> Task 4 accordingly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mechanical path swap: useArchy.ts now lives at aiui/packages/app/... in
this repo (D-19), not the old separate clone. Drops the separate-branch/
push language. Verified paths and referenced symbols still exist and at
essentially the same line numbers post-subtree-import; task content and
must_haves are otherwise unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mechanical path swap: AIUI's composables now live at aiui/packages/app/...
in this repo (git subtree import, D-19), not at the old separate clone
/home/archipelago/Projects/AIUI. Drops the "separate development branch to
push" language accordingly. Verified every retargeted path exists on disk
before rewriting; task content and must_haves are otherwise unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the 'Talk to AIUI about it' path from the other side. archyBridge
gains a chat:prefill case behind its existing parent-origin validation, and
ChatInput prefills + focuses with the caret at the end.
Prefills rather than auto-sends: the operator sees and can amend the question
before it costs a model call, and a draft they had already started is never
clobbered by a background handoff. Auto-send is the natural seam for the
follow-up that actions things directly.
The bridge buffers a prefill that arrives before the composer mounts (collapsed
chat, mobile content tab) and replays it on registration, so a Cmd+K ask into a
cold frame is not silently dropped. onPrefill returns an unsubscribe so a
remounting composer cannot leak a stale handler.
Verified: vue-tsc clean; AIUI suite 332 passed. The 3 remaining failures
(seed-songs extraction x2, web-search system prompt) are pre-existing — I
confirmed by reverting 13-01's two AIUI files to their parent state and
reproducing the identical 3 failures without any phase-13 change present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reproduced again on this node today: with no session cookie, six app
ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175
Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so
served on every host address. Same bug class as the /lnd-connect-info
and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app.
LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>,
so this is one gate rather than four. It lives in the daemon rather than
a per-app sidecar (umbrel's app_proxy model): rootless, no extra
container per app, and it can reuse machinery that already exists.
It invents no authentication policy. verify_password, TOTP secret
decryption, verify_code with used-step replay protection, the session
store, and — importantly — the SAME LoginRateLimiter instance as the
JSON-RPC path, so an attacker cannot get a fresh budget of password
guesses by moving to an app port. Only the transport differs, an HTML
form instead of JSON-RPC, because a browser being sent to an app cannot
speak JSON-RPC.
2FA comes for free: a session still pending its TOTP step fails
validate(), so the gate rejects it without knowing what a second factor
is.
Details worth keeping:
- 401, not a redirect. A redirect to a login page is indistinguishable
from the app itself redirecting, and machine clients would follow it
and parse HTML as their API response.
- Cookie and Authorization are stripped before proxying. The app has no
use for the node session and must never be able to log or forward it.
- The challenge page names and pictures the app being opened, so the
visitor can confirm what they are authenticating to.
- device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for
machine clients. None = node-wide, which every existing companion
token is; migrating them by guessing a scope would silently revoke
access nobody asked to revoke. An empty list is rejected rather than
minted, since it reads as unrestricted while authorising nothing.
The rollout is necessarily per-app and the gate is built to say so. A
container publishing 0.0.0.0:<port> claims every host address, so the
gate cannot bind that port until the app is pinned to bind: 127.0.0.1
and recreated — gate-first is impossible, and all-at-once would recreate
every container on a node simultaneously. Every port it cannot claim is
logged at warn each sweep and recorded in GateStatus::unprotected,
surfaced by security.app-gate-status. The failure mode being designed
against is a gate that binds nothing, logs at debug, and reports success
while every app stays exactly as open as before — worse than no gate,
because it stops anyone looking. Same reasoning that ruled out an
nft drop-in, whose absence is a silent no-op.
Not yet done: pinning the 39 gated ports to loopback, repointing
HiddenServicePort at the gate, and on-node verification.
Tests: 21/21 appgate, workspace builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cmd/Ctrl+K search could only match text against known screens; anything it
did not recognise dead-ended at 'No results'. That text is now handed to the
assistant instead: a blue accented row (chat-bubble + sparkle) appears while
there is a query, always last in the keyboard order, so Cmd+K -> type -> Enter
reaches AIUI without the mouse. On a zero-match query it is the only option.
The prompt travels by postMessage, NOT as an iframe URL param. Chat.vue's
aiuiUrl is deliberately free of reactive dependencies so the iframe src stays
byte-identical and AIUI survives a tab switch (see the D14_FLAGS comment);
threading the question through the URL would reload AIUI and discard the
conversation on every ask — the opposite of the intent. Two regression tests
pin this: the src is byte-identical across an ask, and ask/askedAt are
stripped afterwards so a refresh cannot silently re-ask.
The ask is queued and flushed on AIUI's 'ready' handshake, because arriving
from Cmd+K on a cold Chat tab means the iframe has not connected yet.
AIUI-side receiver lands separately; until then this posts a message AIUI
ignores, which is inert rather than broken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A vertical line crossing both dashboard cards, appearing at random on
hover and hard to catch deliberately.
Diagnosed from the screenshot rather than by reproduction. Decoding it
and scanning column by column found a lone brightness step at CSS x=633
that never returns — every legitimate container edge in the page shows
up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850,
875, 1437), so an unpaired one is not a border. Sampling by region
placed it inside the cards and nowhere else: 10/13 rows inside My Apps,
11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same
screen x in both cards, which means the boundary lives in screen space
and cuts whatever backdrop-filter surface it crosses.
style.css already neutralises backdrop-filter for the shared glass
classes inside the dashboard's animated perspective/scroll containers,
because Chromium/Brave mis-rasterise it there — that block was written
for the black-rectangle corruption. `.home-card-shell` declares its own
`backdrop-filter: blur(18px)` in Home.vue and was never added to the
list, so it was the only unmitigated blur surface on the dashboard.
That is exactly the set of pixels the seam appears in. A hover repaint
re-rasterises part of the backdrop, and the refreshed half meets the
stale half at the damage boundary.
Adding it to the existing list also makes the shell consistent with the
tiles beside it: its fill is already rgba(0,0,0,0.65), the same as
.glass-card, which renders unblurred here.
The list is hand-maintained, which is how this shipped — a component
declaring backdrop-filter in its own <style> is simply not covered and
nothing fails. So the fix comes with a test that parses Home.vue for
locally-declared backdrop-filter rules and asserts each is in the
mitigation list. Verified it catches the real bug: reverting the
one-line fix makes it fail naming `.home-card-shell`.
Tests: 3/3 new, vue-tsc clean, mitigation confirmed in the built CSS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>