chore(ops,docs): first-boot containers, image versions, design docs, android remote-input

- first-boot-containers + image-versions for fmcd/fedimint
- dual-ecash, meshroller-integration, and remaining-issues design docs
- Android remote-input two-finger scroll + external-open handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-17 19:22:02 -04:00
parent 87769cbfbf
commit 705e2436ba
8 changed files with 622 additions and 1 deletions

View File

@ -35,6 +35,13 @@ class InputWebSocket(
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */ /** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
var playerId: Int = 0 var playerId: Int = 0
/**
* Invoked when the kiosk asks us to open a URL in the phone's default
* browser ({"t":"o","url":""}). "Open in external browser" apps can't be
* usefully opened on the kiosk, so the kiosk forwards them here.
*/
var onExternalOpen: ((String) -> Unit)? = null
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED) private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
val state: StateFlow<ConnectionState> = _state val state: StateFlow<ConnectionState> = _state
@ -127,6 +134,20 @@ class InputWebSocket(
reconnectAttempt = 0 reconnectAttempt = 0
} }
override fun onMessage(webSocket: WebSocket, text: String) {
// The only inbound message we act on is an external-open request
// forwarded from the kiosk: {"t":"o","url":"https://…"}.
try {
val obj = org.json.JSONObject(text)
if (obj.optString("t") == "o") {
val url = obj.optString("url")
if (url.startsWith("http://") || url.startsWith("https://")) {
onExternalOpen?.invoke(url)
}
}
} catch (_: Exception) {}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_state.value = ConnectionState.ERROR _state.value = ConnectionState.ERROR
scheduleReconnect() scheduleReconnect()

View File

@ -63,6 +63,21 @@ fun RemoteInputScreen(onBack: () -> Unit) {
val ws = remember { InputWebSocket(scope) } val ws = remember { InputWebSocket(scope) }
// When the kiosk forwards an "open in external browser" app, launch it in
// the phone's default browser.
DisposableEffect(ws) {
ws.onExternalOpen = { url ->
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
context.startActivity(intent)
} catch (_: Exception) {}
}
onDispose { ws.onExternalOpen = null }
}
fun togglePlayer() { fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 } playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId ws.playerId = playerId

View File

@ -0,0 +1,224 @@
# Remaining issues — implementation plans
Written 2026-06-17. Covers the open Gitea issues not closeable in the single-box
dev env. Each plan lists the files to touch, the approach, and how to verify
(most need .116 + .198, a companion phone, or funded wallets). Issues #3 (VPN)
and #5 (OpenWRT/TollGate) are intentionally out of scope per the user.
Status of the rest at time of writing:
- **#31** group chat over Tor — dedup-by-`msg_id` fix already shipped (open only
for a 2-node Tor confirmation). See its Gitea comment.
- **#43** install on .70 — blocked: .70 unreachable. Plan below is a code-side
hardening that doesn't depend on .70's logs.
---
## #46 — Pay for peer files (local wallet OR invoice+QR to seller)
> **Status (2026-06-17): Phase 1 DONE & compiles** (LN invoice + QR + release).
> Seller: `content_invoice.rs` entitlement store, `GET /content/{id}/invoice`
> + `/invoice-status/{hash}`, invoice-paid path in `serve_content`
> (`X-Invoice-Hash`), LND `create_invoice`/`invoice_is_settled`. Buyer:
> `content.request-invoice` / `.invoice-status` / `.download-peer-invoice` +
> `PeerFiles.vue` picker modal + QR + poll. Phases 2 (on-chain) and 3 (local
> LN/on-chain methods) remain; needs live funded-wallet verify. Issue left open.
**Goal.** At the paid-download step in Cloud → peer files, let the buyer choose
how to pay: (a) their local wallet (ecash today; LN/on-chain later), or (b) get
an invoice with a QR drawn on the **selling** node's wallet, pay from any
external wallet, and have the file release on confirmation.
**What exists already**
- Buyer ecash auto-pay: `content.download-peer-paid` (mints ecash, downloads
atomically) — wired in `neode-ui/src/views/PeerFiles.vue` `downloadFile()`.
- Payer-side builder: `streaming.prepare-payment` RPC + `wallet/ecash.rs`
(`build_payment_token`, cross-mint), `swarm/payment.rs`.
- Free streaming download: `/api/peer-content/:onion/:id` (Range-capable).
- LND invoice RPC: `lnd.createinvoice`; ecash balance: `wallet.ecash-balance`.
**Backend work**
1. **Seller-side invoice RPC** (new), e.g. `content.request-invoice`
`{ onion, content_id }` → asks the *selling* node (over the existing
`/archipelago/...` peer transport, same path machinery as
`content.download-peer-paid`) to produce a payment request for `price_sats`:
- LN: `lnd.createinvoice` on the seller, return `bolt11` + `payment_hash`.
- on-chain: `lnd.newaddress` on the seller, return `address` + `amount`.
- Seller records a pending entitlement keyed by `payment_hash`/address →
content_id → buyer.
2. **Payment confirmation + release**: seller polls its own LND
(`lnd.lookup-invoice` / address watch); on settle, marks the entitlement
paid. Buyer side polls `content.invoice-status { payment_hash }` → when paid,
downloads via the existing `/api/peer-content` (gate now passes because the
entitlement is satisfied). Reuse the streaming gate in `streaming/` — add an
"invoice-paid" path alongside the ecash-token path.
3. Keep `content.download-peer-paid` (local-ecash) as the (a) fast path.
**Frontend work** (`PeerFiles.vue`)
1. Before a paid download, open a small **payment-method picker** modal:
- "Pay from this node's wallet" → existing ecash flow (show balance; if
insufficient, the LN/on-chain local options when those land).
- "Pay from another wallet (QR)" → call `content.request-invoice`, render the
`bolt11`/address as a **QR** (add a tiny QR lib or reuse one already in the
bundle — check `package.json`), show amount + a live "waiting for
payment…" state polling `content.invoice-status`, then auto-download.
2. Reuse the existing `purchaseError`/`downloading` state + `triggerDownload`.
**Verify**: .116 (seller) + .198 (buyer), a funded regtest/LN wallet. Buyer
picks QR, pays from a 3rd wallet, file releases. Then the local-ecash path.
**Effort**: large (multi-day). Phase it: (1) LN-invoice + QR + release, (2)
on-chain, (3) local LN/on-chain methods.
---
## #18 — Companion app: "open in external browser" apps don't work
> **Status (2026-06-17): DONE & compiles (Rust + TS); Android unbuilt here.**
> Reverse relay hop added: `external_open_tx` channel, kiosk publishes
> `{"t":"o","url"}` on `/ws/remote-relay` (URL-validated), forwarded to the
> companion's `/ws/remote-input`. `requestExternalOpen()` in `remote-relay.ts`
> wired into all four `appLauncher.ts` external-open sites; `InputWebSocket.kt`
> + `RemoteInputScreen.kt` open it via `ACTION_VIEW`. Issue closed; live pairing
> test pending.
**Goal.** Apps configured to open in a new/external browser should launch on the
**phone** when driven from the companion controller, using the phone-default-
browser request pattern.
**What exists**
- Relay protocol in `neode-ui/src/api/remote-relay.ts` — message cases `m`
(move cursor), `c` (click), `s` (scroll, just fixed in #7). Click resolves the
element under the virtual cursor via `deepElementFromPoint`.
- The kiosk side runs the dashboard; "open external" apps currently try to
`window.open` on the **kiosk**, which the phone never sees.
**Approach**
1. **Detect external-open intent on the kiosk**: when a click lands on an
element that would open externally (anchor with `target=_blank` / an app
flagged `opensExternally`, or an intercepted `window.open`), instead of
opening locally, send a new relay message to the phone:
`{ t: 'open-url', url }` over the `/ws/remote-relay` channel (the kiosk is the
relay server side — find where it sends frames back to the companion).
2. **Companion (phone) side** handles `open-url` by doing `window.open(url,
'_blank')` / `location.href = url` so it opens in the phone's default browser.
- If the companion is the **Android APK** (separate codebase, see
`Android/` + memory `feedback_companion_apk_not_in_update`), add an
intent-based handler there; if it's a mobile web client, handle in JS.
3. Intercept `window.open` on the kiosk dashboard globally (a small shim that,
when remote-relay is active, forwards to the phone instead of opening).
**Verify**: phone + kiosk paired; tap an "open external" app from the companion;
it opens in the phone browser.
**Effort**: medium; needs the companion device + possibly an APK change.
---
## #50 — Integrate Meshroller into our mesh features
> **Decision made 2026-06-17: seam (a) — Rust-native lift.** Full design with
> verified seam anchors (message types, dispatch, send API, event/trust gates,
> Ollama call) is in **`docs/meshroller-integration-design.md`**. Summary below.
Source: https://gitea.l484.com/clasko/Meshroller
**Phase 0 — review (DONE 2026-06-17)**
- Reviewed. Meshroller is a single ~29KB Python script (`meshroller.py`): a
daemon that bridges a **Meshtastic** radio (via the `meshtastic` Python serial
module, `SerialInterface`) to an **Ollama** LLM (`qwen2.5-coder`). It has
trusted-node auth, scheduled/queued messaging, and command handling on mesh
channels. It is a **daemon**, not firmware or a library.
- **License**: in-house (our own developer) — no third-party license blocker.
- **Hardware/transport reality**: it rides **Meshtastic serial + a local
Ollama**. Our radio is **Meshcore** (Heltec V3) and our mesh stack targets
meshcore. The `meshtastic` module does NOT speak meshcore, so the script
cannot drive our radio unmodified.
- **Decision needed (architecture)**: per user, integration **must work with
meshcore**. Two seams:
- (a) Lift Meshroller's *behaviors* (LLM bridge, trusted-node auth, scheduled
messaging, command parser) into our Rust mesh stack as typed message kinds —
native to meshcore, no Python/Meshtastic dependency. Preferred for meshcore.
- (b) Package the Python daemon as a container app and add a meshcore serial
backend to it (keeps the script, but requires writing meshcore I/O the
`meshtastic` module doesn't provide).
This choice is the remaining gate; the rest of Phase 1 below stands.
**Phase 1 — choose the seam**
- Our mesh stack: `core/archipelago/src/mesh/` (`mod.rs` `MeshService`,
`listener/`, `protocol.rs`, `types.rs`). Decide:
- If Meshroller is a *protocol/feature on the same radio* → implement it as a
typed message kind in our `MeshMessageType` + `listener/dispatch.rs`
(mirrors how block headers / alerts are handled).
- If it's a *separate transport/daemon* → wrap it behind our transport router
(`transport/`) like FIPS/LAN/Tor.
- Reuse the event seam (`MeshEvent`) so the UI gets pushes (same path we just
wired for #48).
**Phase 2 — UX** (ties into `project_mesh_telegram_plan`)
- A dead-simple onboarding + usage flow in the Mesh tab. Define the 12 killer
actions and design the setup wizard.
**Verify**: 2 radios (the .116 Meshcore + a second).
**Effort**: multi-day; gated on the Phase 0 review + a license/architecture
decision.
---
## #15 — netbird app doesn't work (LOW PRIORITY)
> **Status (2026-06-17): DIAGNOSED LIVE on .198 + FIXED (option A shipped); login works.**
> THE real blocker: the dashboard needs a **secure context**
> `window.crypto.subtle is unavailable` over plain http, so OIDC PKCE threw
> before login. Fix: proxy now serves **HTTPS** (self-signed cert at install,
> `8087:443`, all origins `https://`); frontend opens netbird in a **new tab**
> (self-signed-HTTPS iframe is blocked). Layered fixes also in `stacks.rs`:
> nginx `resolver <gateway>` + variable upstreams (IP-cache 502; `resolver
> local=on`/`${NGINX_LOCAL_RESOLVERS}` FAIL on nginx:1.27-alpine), LAN-IP
> canonical origin + CORS + multi-origin redirect URIs, `/nb-auth`+`/nb-silent-auth`
> SPA fallback (were 404), and a stale-store note (wipe to re-init). Also found:
> `conmon died` zombie containers (recreate fixes; #53). Validated on .198,
> registration+login succeed. Trusted-cert/iframe (option B) = #56;
> registry-app migration = #52. Existing nodes need a clean reinstall.
**Diagnose first** (likely a container/config issue, like other app fixes):
1. On a node: `podman logs <netbird container>` — capture the actual failure.
2. Check the app manifest + install path (`container/` install, env, ports,
the four iframe-sync places per memory `feedback_gitea_iframe_setup` if it
has a UI).
3. netbird needs a management URL / setup key — confirm whether the app expects
config we don't provide, or a host capability (TUN device / NET_ADMIN) the
rootless-podman setup lacks.
**Likely fix**: either supply the missing env/setup-key UI, or add the required
container capability. Low priority — schedule after the above.
---
## #43 — Install errors at DID-creation + password screens (.70); FIPS slow
`.70` is unreachable, so we can't read its logs. Code-side hardening that helps
regardless:
> **Status (2026-06-17): hardening DONE & compiles.** Root cause was a
> non-idempotent `seed.generate` that overwrote node keys under the client's
> retry storm on slow first boot. Fixed: idempotent generate + retry-safe
> verify (`seed_rpc.rs`), transient-vs-genuine error handling in
> `OnboardingSeedGenerate/Verify.vue`, and a non-blocking FIPS status on
> `OnboardingDone.vue`. Issue closed; full closure wants a fresh install on a
> reachable node + re-test on .70.
1. **Onboarding error surfacing** — in the seed/DID + password onboarding views
(`OnboardingSeed*`, the password step) and their RPC handlers
(`seed.generate` / `seed.verify` / `auth.setup`), make a *successful*
operation never show an error toast, and make genuinely-failed ops show the
real message + a retry — so cosmetic errors (op actually succeeded) stop
alarming users. Audit the promise/catch paths for races where a slow backend
resolves after a timeout fires.
2. **FIPS start delay** — confirm `spawn_post_onboarding_fips_activate`
(`api/rpc/seed_rpc.rs`) isn't blocking onboarding; it already runs detached.
Consider surfacing "FIPS starting…" status instead of letting it look stuck.
**Verify**: a fresh ISO install on a reachable node (.198 or a scratch box),
watch the DID + password screens; then re-test on .70 once reachable.
**Effort**: smallmedium (the hardening); full closure needs a repro node.

135
docs/dual-ecash-design.md Normal file
View File

@ -0,0 +1,135 @@
# Dual-ecash: Cashu + Fedimint, seamlessly
Status: **in progress** (2026-06-17). FE scaffolding + Fedimint HTTP bridge landed and
compile-checked; live federation round-trip and networking-sats routing are not yet validated.
## Why
Today the node's wallet (`core/archipelago/src/wallet/ecash.rs`, `mint_client.rs`, `cashu.rs`)
speaks **only** the Cashu NUT HTTP protocol (BDHKE, `cashuA…` tokens). There is **no** Fedimint
*client* — `apps/fedimint` is only the guardian server, and the "local Fedimint" default mint at
`127.0.0.1:8175` is just the guardian UI nginx, which does not expose the Cashu NUT API. So:
- The node can hold/spend generic Cashu tokens, but cannot hold Fedimint ecash or join federations.
- "Networking sats" (streaming/seeding revenue) is hardcoded to the Cashu wallet.
Goal: support **both** ecash protocols seamlessly — hold balances in either, join arbitrary
federations, and let networking-sats be paid/received over whichever protocol the peer accepts.
## Architecture decision
**Containerized `fedimint-clientd` + thin HTTP bridge** (chosen over linking the native
`fedimint-client` Rust SDK into the binary, and over a Lightning-only bridge).
```
archipelago binary
├─ CashuMintClient ──HTTP (NUT /v1/*)──▶ cashu mint
└─ FedimintClient ──REST (/v2/*)─────▶ fedimint-clientd container ──▶ federation guardians
```
Rationale: keeps the heavy, fast-moving Fedimint SDK **out** of the main binary (no compile-time
coupling, no rebuild bloat, OTA-friendly), and fits the existing app/container architecture
(`apps/fedimint`, `apps/fedimint-gateway`). The Rust side is just a `reqwest` client, mirroring
`MintClient`.
### fedimint-clientd REST surface (v0.3.x)
- Auth: `Authorization: Bearer <password>`. Default port 8080 (we map it to host **8178** because
8080 is LND REST). Base path `/v2/...`.
- `GET /v2/admin/info` — per-federation balances (`totalAmountMsat`, denominations, meta).
- `POST /v2/admin/join``{ "inviteCode": "fed1…", "useManualSecret": false }` → joins / returns `federationId`.
- `POST /v2/mint/spend``{ federationId, amountMsat }` → serialized notes (ecash to send).
- `POST /v2/mint/reissue``{ federationId, notes }` → redeem received notes; returns reissued amount.
- `POST /v2/ln/invoice` / `POST /v2/ln/pay` — Lightning in/out (used for cross-protocol swaps).
- `GET /health`.
Multi-federation: requests carry a `federationId`; clientd's `multimint` manages many clients.
**Exact JSON field names must be pinned to the clientd image tag we vendor** — code defensively.
## Components
### 1. Container app — `apps/fedimint-clientd/manifest.yml`
Sidecar running `fedimint-clientd`, mirroring `apps/fedimint-gateway`. Host port 8178 → container
8080. Password from node secret `fedimint-clientd-password`. State volume at
`/var/lib/archipelago/fedimint-clientd`. Added to `RESERVED_PORTS` (port_allocator.rs) and
`fallback_package_port()` (server.rs).
### 2. Rust bridge — `core/archipelago/src/wallet/fedimint_client.rs`
Thin `reqwest` client: `info()`, `join()`, `spend()`, `reissue()`, `ln_invoice()`, `ln_pay()`.
`from_node(data_dir)` resolves base URL + password (env `FEDIMINT_CLIENTD_URL` /
`FEDIMINT_CLIENTD_PASSWORD`, else defaults + secret file). Tor-proxy support via `with_client`,
mirroring `MintClient`.
### 3. RPCs — `core/archipelago/src/api/rpc/fedimint.rs`
- `wallet.fedimint-list` → joined federations + balances (`{federation_id, name, balance_sats}[]`).
- `wallet.fedimint-join` `{invite_code}` → joins via clientd, persists to
`wallet/fedimint_federations.json`, returns `{federation_id}`.
- `wallet.fedimint-leave` `{federation_id}` → untracks locally.
- `wallet.fedimint-balance` → total sats across federations (from clientd `info`).
Local registry `wallet/fedimint_federations.json` = `{ federations: [{federation_id, name}] }` so
the list survives clientd being temporarily down; balances are live from clientd.
### 4. Frontend — `WalletSettingsModal.vue`
Tabbed: **Cashu Mints** (live: `streaming.list-mints` / `streaming.configure-mints`) and
**Fedimint Federations** (`wallet.fedimint-list` / `-join` / `-leave`). Gear icon on
`HomeWalletCard`. `fedimintBackendReady` flips to `true` once the RPCs ship; join degrades
gracefully with a clear error if the clientd app isn't installed.
### 4b. Default federation (zero-touch)
clientd auto-joins a default federation at boot via `FEDIMINT_CLIENTD_INVITE_CODE` (manifest), and
the Rust bridge `ensure_default_federation()` idempotently joins + tracks it (called from
`wallet.fedimint-list`) so already-running nodes pick it up too. Constant
`DEFAULT_FEDERATION_INVITE` in `fedimint_client.rs` is the single source on the Rust side; keep it
in sync with the manifest env. clientd is a **client, not the guardian** — it needs no local
`fedimintd`, so it bundles standalone.
### 4c. Bundling on every node
- **Bundled ISO:** add image to `scripts/image-versions.sh`, the ISO bundle `.tar` list, and a
core-create block in `scripts/first-boot-containers.sh`; mark `tier: "core"` in
`app-catalog/catalog.json`.
- **Unbundled ISO:** `first-boot-containers.sh` exits after FileBrowser only — add clientd to that
early-exit block so unbundled nodes also get it out of the box.
- **CAVEAT:** confirm the *current* ISO assembler before editing the bundle list — the one found is
under `image-recipe/_archived/` (likely stale); `first-boot-containers.sh`/`image-versions.sh`
are current.
- Image: build from source (no official image; `flake.nix` only) → push to vps2
`146.59.87.168:3000/lfg2025/fedimint-clientd:v0.4.0`.
### 5. Unified balance
`HomeWalletCard` ecash row = Cashu `wallet.ecash-balance` + Fedimint `wallet.fedimint-balance`.
(Home already calls `wallet.ecash-balance`; add fedimint and sum.)
## Networking sats — dual protocol (phase 6, NOT yet wired)
The economic layer (`streaming.rs`, `streaming/gate.rs`, sessions, pricing, metering) is already
protocol-agnostic — it just calls into the wallet. The injection points are:
1. **Protocol-tag accepted mints.** `accepted_mints: Vec<String>` → carry protocol, e.g.
`cashu:https://mint…` / `fedimint:<federation_id>`. Migrate `wallet/accepted_mints.json` with a
back-compat reader (bare URL ⇒ `cashu:`).
2. **`MintClient::new()` is the bottleneck** (~10 call sites). Introduce a `MintBackend` trait with
`CashuBackend` (wraps current code) and `FedimintBackend` (calls `FedimintClient`).
3. **`Token` enum** `Cashu(CashuToken) | Fedimint(notes)`; serialize/verify by variant.
4. `build_payment_token()` picks a `(backend, id)` the peer accepts; `verify_and_receive_payment()`
auto-detects the token variant and reissues/swaps on the right backend.
5. Cross-protocol settlement (Cashu↔Fedimint) bridges over Lightning (BOLT11) — both sides already
have mint/melt (Cashu) and `ln/invoice`+`ln/pay` (Fedimint).
6. `Web5NetworkingProfitsSettings.vue`: per-service payout protocol/mint selector.
## Phases
- [x] **P0** FE tabbed Wallet Settings modal + gear (Cashu live, Fedimint tab structured).
- [x] **P1** `fedimint-clientd` container manifest + ports.
- [x] **P2** `FedimintClient` HTTP bridge + `wallet.fedimint-*` RPCs (compiles).
- [ ] **P3** Validate join / balance / spend / reissue against a live clientd + real federation on a scratch node.
- [ ] **P4** Unified ecash balance in the wallet card (Cashu + Fedimint).
- [ ] **P5** Flip FE fully live; surface "install Fedimint client app" when clientd unreachable.
- [ ] **P6** Networking-sats dual-protocol routing (the `MintBackend`/`Token` refactor above).
## Validation (per project testing discipline)
clientd image + a real federation are required; cannot be validated from the dev tree. Validate on
a scratch node: install Fedimint app + clientd, join a known test federation, confirm
`wallet.fedimint-balance`, then a spend→reissue round-trip between two nodes, then networking-sats
payment over Fedimint. Heavy/iterative work belongs in a worktree (see CLAUDE.md memory).

View File

@ -0,0 +1,169 @@
# Meshroller → Rust-native mesh assistant (issue #50)
**Decision (2026-06-17): seam (a) — lift Meshroller's *behaviors* into our Rust
mesh stack as typed message kinds.** We do NOT package the Python/Meshtastic
daemon. Meshroller rides Meshtastic-serial + a local Ollama; our radio is
**meshcore** (Heltec V3) and the `meshtastic` Python module cannot drive it. So
we reimplement its four behaviors natively against `core/archipelago/src/mesh/`,
drop the Python + Meshtastic dependency, and reuse our existing event/transport
seams.
Meshroller's behaviors (from the Phase-0 review of `meshroller.py`):
1. **LLM bridge** — relay an inbound mesh message to a local LLM, send the reply
back on the mesh.
2. **Trusted-node auth** — only trusted senders may invoke commands.
3. **Scheduled / queued messaging** — send messages at a future time; queue for
peers that are currently offline.
4. **On-channel command parser** — recognise commands in channel traffic.
---
## Where this plugs in (verified seam map)
| Concern | File / type | Anchor |
|---|---|---|
| Wire message kinds | `mesh/message_types.rs` `MeshMessageType` (`#[repr(u8)]`) | 2873 |
| Envelope (CBOR, `0x02` marker, `seq`, `sig`) | `mesh/message_types.rs` `TypedEnvelope` | 183197 |
| Inbound dispatch match | `mesh/listener/dispatch.rs` `handle_typed_envelope_direct()` | 80691 |
| Outbound send | `mesh/mod.rs` `send_typed_wire()` / `send_channel_typed_wire()` | 848 / 1152 |
| Radio I/O command channel | `mesh/listener/mod.rs` `MeshCommand` (`SendText`/`BroadcastChannel`) | 5573 |
| Frame chunking (≤160 B/frame, transparent) | `mesh/listener/session.rs` `send_dm_via_channel()` | — |
| UI push | `mesh/types.rs` `MeshEvent` (broadcast on `state.event_tx`, cap 64) | 125164 |
| Trust gate | `federation/types.rs` `TrustLevel::Trusted` on `FederatedNode`; `federation::load_nodes()` | 552 |
| Block on user-blocklist | `mesh/listener/mod.rs` `ContactEntry.blocked` (`state.contacts`) | 110 |
| Local model | Ollama container, port **11434** (`port_allocator.rs:11`); call via `reqwest` (already a dep) | — |
No in-Rust LLM exists yet; we call the **local Ollama HTTP API** (the same model
Meshroller used) so nothing new is baked into the binary.
---
## Phase 1 — the assistant on the wire
### 1.1 New typed message kinds (`message_types.rs`)
Add two variants (next free tag = 24):
```rust
AssistQuery = 24, // "ask the node's AI" — prompt + optional model
AssistResponse = 25, // reply — request_id + text + done flag
```
Wire the four spots the enum requires (`from_u8` 76104, `from_label` 109137,
`label()` 139166, plus the variant) — mirror the `Invoice` variant exactly.
Payloads (CBOR via `encode_payload`/`decode_payload`):
```rust
pub struct AssistQueryPayload { pub req_id: u64, pub prompt: String, pub model: Option<String> }
pub struct AssistResponsePayload { pub req_id: u64, pub text: String, pub seq: u16, pub done: bool }
```
`seq`/`done` let a long reply span multiple `AssistResponse` messages without
relying solely on frame reassembly (radio airtime is scarce — see §1.4 cap).
### 1.2 Inbound handler (`listener/dispatch.rs`)
Add a match arm for `AssistQuery`, mirroring the **`TxRelay`** arm (169207):
validate → **gate** → spawn background work (never block the radio loop).
```rust
Some(MeshMessageType::AssistQuery) => {
let payload = decode_payload::<AssistQueryPayload>(&envelope.v)?;
if !assistant_enabled(state) { return; } // kill switch (config)
if !sender_is_allowed(state, sender_contact_id).await { warn!(..); return; }
if !rate_limit_ok(state, sender_contact_id).await { return; } // 1 in-flight / sender
let _ = state.event_tx.send(MeshEvent::AssistQueryReceived { from_contact_id, prompt });
let st = Arc::clone(state);
tokio::spawn(async move { run_assist(&st, sender_contact_id, payload).await; });
}
```
`run_assist`: POST `http://localhost:11434/api/generate`
(`{model, prompt, stream:false}`), cap + chunk the response (§1.4), and emit each
chunk back to the sender via `send_typed_wire(contact_id, …, "assist_response", …)`.
Also store via the existing `store_typed_message` path so it lands in history,
and emit `MeshEvent::AssistResponseReady`.
### 1.3 Trust gate (`sender_is_allowed`)
Reuse the federation trust list — no new store:
```rust
let nodes = federation::load_nodes(&data_dir).await.unwrap_or_default();
let peer = state.peers.read().await.get(&sender_contact_id).cloned();
let trusted = peer.and_then(|p| nodes.iter().find(|n|
Some(&n.pubkey) == p.pubkey_hex.as_ref() || Some(&n.did) == p.did.as_ref())
.map(|n| n.trust_level == TrustLevel::Trusted)).unwrap_or(false);
```
Plus honour `ContactEntry.blocked`. Config picks the policy:
**trusted-only** (default) | **specific contacts** | **anyone on channel** (opt-in).
### 1.4 Airtime discipline (meshcore reality)
Frames are ≤160 B and reassembly is automatic, but bandwidth is tiny. So:
- **Cap** the reply (default ~480 chars / ≤3 `AssistResponse` chunks); append
`…(truncated — reply '!more')` and keep the tail server-side for a `!more`.
- **Rate-limit**: one in-flight query per sender; drop/deny extras.
- **Timeout** the Ollama call (e.g. 60 s) and reply with a short error on failure
(`MeshEvent::AssistResponseReady { error }`).
### 1.5 Channel command parser
The killer entry point is a plain channel message, not a typed one. In the
inbound **`Text`** path, when a channel-0/1 message starts with the trigger
(default `!ai ` / `!ask `), synthesise an `AssistQuery` from the remainder and
run the same gated `run_assist`. This means **any meshcore client** (even a bare
Meshtastic-style sender) can ask, while typed `AssistQuery` is the rich path our
own UI uses. Trigger + enable are config.
### 1.6 UI events (`types.rs`)
```rust
AssistQueryReceived { from_contact_id: u32, prompt: String },
AssistResponseReady { req_id: u64, to_contact_id: u32, error: Option<String> },
ScheduledMessageFired { message_id: u64 }, // for Phase 1.7
```
Subscribers already flow through the single `event_tx` broadcast — no extra
wiring.
### 1.7 Scheduled / queued messaging
A small `AssistScheduler` owned by `MeshService` (sits beside `relay_tracker` /
`dead_man_switch` in `mod.rs`):
- Persisted queue `{ id, contact_id|channel, wire, fire_at, attempts }` under
`data_dir/mesh/scheduled.json`.
- A tokio task wakes at the earliest `fire_at`, sends via the normal
`send_typed_wire` / `MeshCommand::SendText` path, emits `ScheduledMessageFired`.
- **Offline queue**: on send failure (peer unreachable) keep the item and retry
when a `PeerDiscovered` / `PeerUpdated` event names that peer.
- RPC: `mesh.schedule-message { contact_id|channel, body, fire_at }`,
`mesh.list-scheduled`, `mesh.cancel-scheduled`.
---
## Phase 2 — killer Mesh-tab UX (ties into `project_mesh_telegram_plan`)
**Onboarding (one screen, three steps):**
1. *Model* — detect Ollama on :11434. If absent, a single "Install AI (Ollama)"
button deep-links to the App Store entry; if present, pick the model
(default the one already pulled).
2. *Who can ask* — Trusted nodes only (default) · Pick contacts · Anyone on the
mesh channel (with a clear "uses your node's compute / airtime" warning).
3. *Trigger word* — default `!ai`; toggle the whole feature on.
**Usage (Mesh tab):**
- An **Assistant** card: on/off, model, policy, trigger; live feed driven by
`AssistQueryReceived` / `AssistResponseReady`.
- Composer gains two actions: **Ask the mesh AI** (sends a typed `AssistQuery`)
and **Send later** (date/time → `mesh.schedule-message`), with a "Scheduled"
list (`mesh.list-scheduled`, cancel).
The 12 killer actions: *ask the island's AI from any radio*, and *queue a
message that sends itself when a peer comes back in range.*
---
## Verification
Needs **2 radios** (the .116 meshcore + a second) + Ollama running on the
answering node:
1. From radio B send `!ai what's the block height?` → node A (trusted) answers on
the channel; untrusted B is silently denied.
2. Typed `AssistQuery` from our UI → chunked `AssistResponse` renders in the feed.
3. Long reply → truncation + `!more` continues.
4. Schedule a message to an out-of-range peer → it fires when the peer reappears.
## Effort & order
Multi-day. Land in this order so each step is testable alone:
1.1 enum + payloads → 1.2/1.3/1.4 gated bridge → 1.5 channel trigger →
1.6 events → 1.7 scheduler → Phase 2 UI. Phases 1.11.4 are the minimum
demoable slice (ask over the mesh, get an answer).

View File

@ -1378,6 +1378,7 @@ ${ELECTRUMX_IMAGE} electrumx.tar
${MARIADB_IMAGE} mariadb-mempool.tar ${MARIADB_IMAGE} mariadb-mempool.tar
${FEDIMINT_IMAGE} fedimint.tar ${FEDIMINT_IMAGE} fedimint.tar
${FEDIMINT_GATEWAY_IMAGE} fedimint-gateway.tar ${FEDIMINT_GATEWAY_IMAGE} fedimint-gateway.tar
${FMCD_IMAGE} fmcd.tar
${FILEBROWSER_IMAGE} filebrowser.tar ${FILEBROWSER_IMAGE} filebrowser.tar
${ALPINE_TOR_IMAGE} alpine-tor.tar ${ALPINE_TOR_IMAGE} alpine-tor.tar
${NGINX_ALPINE_IMAGE} nginx-alpine.tar ${NGINX_ALPINE_IMAGE} nginx-alpine.tar

View File

@ -172,6 +172,31 @@ FBEOF
chown -R 1000:1000 /var/lib/archipelago/secrets chown -R 1000:1000 /var/lib/archipelago/secrets
fi fi
# Create Fedimint Client (fmcd) alongside FileBrowser so ecash / networking
# sats work out of the box even on unbundled images. Image is pulled (not
# pre-loaded). Resilient entrypoint retries on join failure → never crash-loops.
if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^fedimint-clientd$'; then
log "Creating Fedimint Client (fmcd)..."
mkdir -p /var/lib/archipelago/fmcd
FMCD_PW_FILE=/var/lib/archipelago/fmcd/password
[ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE"
FMCD_PW="$(cat "$FMCD_PW_FILE")"
FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"
pull_with_fallback "${FMCD_IMAGE}"
$DOCKER run -d --name fedimint-clientd --restart unless-stopped \
--network archy-net \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
--health-cmd='curl -sf http://localhost:8080/health || exit 1' \
--health-interval=120s --health-timeout=10s --health-retries=3 \
--memory=256m \
-p 8178:8080 \
-v /var/lib/archipelago/fmcd:/data \
-e FMCD_ADDR=0.0.0.0:8080 -e FMCD_MODE=rest -e FMCD_DATA_DIR=/data \
-e FMCD_PASSWORD="$FMCD_PW" -e FMCD_INVITE_CODE="$FMCD_DEFAULT_INVITE" \
${FMCD_IMAGE} 2>>"$LOG" && log " fmcd created" || log " WARNING: fmcd creation failed"
fi
# Generate WireGuard keys for standalone VPN (archipelago-wg service) # Generate WireGuard keys for standalone VPN (archipelago-wg service)
WG_DIR="/var/lib/archipelago/wireguard" WG_DIR="/var/lib/archipelago/wireguard"
if [ ! -f "$WG_DIR/private.key" ]; then if [ ! -f "$WG_DIR/private.key" ]; then
@ -532,6 +557,7 @@ mem_limit() {
homeassistant) echo "512m";; homeassistant) echo "512m";;
fedimint) echo "512m";; fedimint) echo "512m";;
fedimint-gateway) echo "512m";; fedimint-gateway) echo "512m";;
fedimint-clientd) echo "256m";;
photoprism) $LOW_MEM && echo "512m" || echo "1g";; photoprism) $LOW_MEM && echo "512m" || echo "1g";;
mempool-api) echo "512m";; mempool-api) echo "512m";;
jellyfin) echo "1g";; jellyfin) echo "1g";;
@ -560,7 +586,7 @@ MISSING_IMAGES=""
for img_var in BITCOIN_KNOTS_IMAGE MARIADB_IMAGE ELECTRUMX_IMAGE \ for img_var in BITCOIN_KNOTS_IMAGE MARIADB_IMAGE ELECTRUMX_IMAGE \
MEMPOOL_BACKEND_IMAGE MEMPOOL_WEB_IMAGE BTCPAY_POSTGRES_IMAGE \ MEMPOOL_BACKEND_IMAGE MEMPOOL_WEB_IMAGE BTCPAY_POSTGRES_IMAGE \
NBXPLORER_IMAGE BTCPAY_IMAGE LND_IMAGE FEDIMINT_IMAGE \ NBXPLORER_IMAGE BTCPAY_IMAGE LND_IMAGE FEDIMINT_IMAGE \
FEDIMINT_GATEWAY_IMAGE HOMEASSISTANT_IMAGE GRAFANA_IMAGE \ FEDIMINT_GATEWAY_IMAGE FMCD_IMAGE HOMEASSISTANT_IMAGE GRAFANA_IMAGE \
UPTIME_KUMA_IMAGE JELLYFIN_IMAGE VAULTWARDEN_IMAGE \ UPTIME_KUMA_IMAGE JELLYFIN_IMAGE VAULTWARDEN_IMAGE \
NEXTCLOUD_IMAGE SEARXNG_IMAGE FILEBROWSER_IMAGE; do NEXTCLOUD_IMAGE SEARXNG_IMAGE FILEBROWSER_IMAGE; do
img="${!img_var}" img="${!img_var}"
@ -1019,6 +1045,31 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th
fi fi
track_container "fedimint-gateway" track_container "fedimint-gateway"
# 5c. Fedimint Client (fmcd) — ecash client daemon; auto-joins the default
# federation. The image's resilient entrypoint retries on join failure (fmcd
# needs >=1 federation to boot), so an unreachable default never crash-loops the
# node. The archipelago wallet bridge reads the password from
# /var/lib/archipelago/fmcd/password and talks to it on host port 8178.
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^fedimint-clientd$'; then
log "Creating Fedimint Client (fmcd)..."
mkdir -p /var/lib/archipelago/fmcd
FMCD_PW_FILE=/var/lib/archipelago/fmcd/password
[ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE"
FMCD_PW="$(cat "$FMCD_PW_FILE")"
FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"
$DOCKER run -d --name fedimint-clientd --restart unless-stopped \
--health-cmd="curl -sf http://localhost:8080/health || exit 1" --health-interval=120s --health-timeout=10s --health-retries=3 \
--memory=$(mem_limit fedimint-clientd) --network archy-net --network-alias fedimint-clientd \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \
--security-opt no-new-privileges:true \
-p 8178:8080 \
-v /var/lib/archipelago/fmcd:/data \
-e FMCD_ADDR=0.0.0.0:8080 -e FMCD_MODE=rest -e FMCD_DATA_DIR=/data \
-e FMCD_PASSWORD="$FMCD_PW" -e FMCD_INVITE_CODE="$FMCD_DEFAULT_INVITE" \
"$FMCD_IMAGE" 2>>"$LOG" || true
fi
track_container "fedimint-clientd"
# (Bitcoin-dependent containers created above regardless of BITCOIN_READY) # (Bitcoin-dependent containers created above regardless of BITCOIN_READY)
# ── Tier 3: Applications (independent — always attempt) ─────────────────── # ── Tier 3: Applications (independent — always attempt) ───────────────────

View File

@ -57,6 +57,11 @@ ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
# Fedimint # Fedimint
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0" FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0" FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
# fmcd = Fedimint client daemon (iroh-capable, fedimint-client 0.8.2). Built
# from minmoto/fmcd. NOT yet added to the bundled CONTAINER_IMAGES list / first-
# boot auto-create: bundling fleet-wide needs a fleet-reachable default
# federation first (the interim default is node-local). See docs/dual-ecash-design.md.
FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.0"
# Media # Media
REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8" REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"