wg-setup-host.sh sets up the tunnel on the backend host: keys generated there and kept there, wg0.conf, wg-quick enabled, and the phone's config printed as a scannable QR. Split tunnel (AllowedIPs is only the backend subnet), single-address addressing per PLAN.md, and the three things it can't do for you -- router UDP forward, DDNS, hairpin check -- spelled out at the end. test-wg-tunnel.sh stands up a real WireGuard tunnel between two network namespaces inside one machine, so the production posture (bind wg0 and nothing else) is testable with no router, phone, or internet exposure. Verified: real handshake, server listening on 10.66.0.1:8443 only, and an authorized request from inside the tunnel answering 200 over pinned TLS -- the leaf's 10.66.0.1 SAN is what a phone will validate too. repo_root() now resolves from the running executable before falling back to the compiled-in path: the repo is shared host<->VM over virtiofs at different absolute paths with a shared target/, so a binary built on one side and run on the other looked for its config where nothing exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
7.8 KiB
ai-app
A phone interface to AI coding sessions (Claude Code and llama.cpp via pi), replacing the Claude app for daily use. Rust/Axum backend on the desktop, Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token between them.
PLAN.md is the design source of truth. Read it before building or
changing anything structural. It records every decision with its date, its
rationale, and the alternatives that were rejected and why — keep that habit
when a decision changes: update the plan in place, don't let this file and
the plan drift into two versions of the truth. This file is the working notes
layer: conventions, commands, and things that have bitten.
The central design point, worth not undoing by accident: a session is a
child process speaking JSONL over stdio, translated into one common event
model. Claude Code (stream-json) and pi (RPC mode) are two translators
behind one Driver trait; the transcript, the SSE stream, the phone UI, and
SSH spawning (the same command wrapped in ssh host …) all work purely in
the common model. A new session type is a new driver — never a
session-type branch in shared code (routes, transcript, app screens).
Layout
Mirrors ../local-updater deliberately — same stack (axum 0.8 +
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform,
single :androidApp module), same cert scheme, same registry pattern (every
session mutation funnels through the manager so in-memory and on-disk state
can't come apart). Read local-updater's README.md and AGENTS.md for the
conventions before diverging from them; module-by-module intent for this
repo is in PLAN.md's "Backend layout" section.
server/— Rust backend (ai-server).main.rsbootstraps (TLS, the auth layer, token/QR enrollment, wg0 binding),routes.rshas the HTTP table in its module doc comment,auth.rsthe bearer-token middleware,config.rsthe persisted schema,session/the manager (registry pattern),Drivertrait + event model,EchoDriver, and transcripts.app/— Compose Android app, single:androidAppmodule, packagecom.example.aiapp, label "AI Sessions".AppRoot.ktis the navigationwhen;Api.kt/EventStream.ktthe REST + SSE clients;Events.ktthe event model mirror;ServerConfig.ktsettings + Keystore-sealed token; screens inSessionListScreen/SessionScreen/SpawnScreen/SettingsScreen.gen-dev-cert.sh/certs/— copied from local-updater's scheme (idempotent CA, reissued leaf; regenerating the CA strands the installed app — same one-way door). Dev SANs cover 127.0.0.1, 10.0.2.2 (emulator → host), and the LAN IP alongside the WireGuard address.
Status
Phases 1–3 done 2026-08-24 (see PLAN.md's phase list for what each
verified): the skeleton pipe, the full Claude driver (streaming, tools,
permission + AskUserQuestion cards, steering, interrupt, --resume
crash recovery, images both ways), and the usage screen. Phase 4
(pi/llama.cpp) is deferred — not testable in this VM. Next: phase 5
(SSH; needs a decision on how to test — no keys in ~/.ssh here) and
real-phone/WireGuard bring-up, which is operational rather than code.
Checking your work
- Server:
./run-tests.sh(orcargo test) +cargo clippy --all-targetsfromserver/— the build stays warning-clean, keep it that way. - App: from
app/,. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin;./run-android.shbuilds, installs, and launches on the emulator. - Run the server for development with
--bind 127.0.0.1(wg0 doesn't exist on this machine yet; the default fails closed). First run prints the enrollment QR/URI with the token — capture it from the log. - Prefer exercising the server directly over going through the UI:
curl --cacert certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions. The emulator app reaches it athttps://10.0.2.2:8443; enroll it withadb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"(quote so the device shell doesn't eat the&s).
Where things run (host vs this VM)
Established 2026-08-25, and it decides more than it looks like:
- The host (192.168.1.168) is the backend machine. It runs
local-updater's server today and is where
ai-serverbelongs in production: it has the LAN address the phone can reach, and it's where WireGuard terminates.wg-setup-host.shsets that up (keys,wg0.conf, the phone's QR); run it there withsudo WG_ENDPOINT=<ddns name>. - This VM is a dev sandbox behind qemu user-mode networking (10.0.2.15, gateway 10.0.2.2) — outbound only. The host is reachable at 10.0.2.2, but nothing outside can initiate a connection into the VM, so the tunnel and the real phone can never terminate here.
- The repo is the same files on both sides over virtiofs, at different
absolute paths:
~/host/repos/ai-appin the VM,~/stuff/vm/ai/repos/ai-appon the host.server/target/is shared along with it, so acargo buildon one side replaces the other's binary (and each rebuilds from scratch after the other).repo_root()resolves from the running executable for exactly this reason — a host-built binary run in the VM used to look for its config under a path that doesn't exist here. wg0(10.66.0.1) now exists in this VM too, so the production path —ai-serverwith no--bind— is exercisable during development. It has no reachable peer and doesn't need one; the interface existing is what the server requires. Consequence: with no--bind, the emulator can't reach the server (it dials 10.0.2.2), so keep using--bind 127.0.0.1for app work../test-wg-tunnel.sh up|test|downbuilds a real tunnel between two network namespaces inside one machine and drives the server through it — a genuine handshake against 10.66.0.1 with pinned TLS, no router or phone involved. That's the way to verify the wg0-only posture.
Things that have bitten
- tracing caches callsite interest process-wide. A test that hits a
tracing::warn!with no subscriber installed can poison the interest cache for a concurrent test that captures logs (flaky "nothing was logged" failures). Keep every exercise of a logging code path under the one capturing subscriber — that's why the auth middleware has a single combined gating+logging test. - The keyboard pans the window unless the activity opts into resize.
Without
android:windowSoftInputMode="adjustResize", opening the IME slides the whole window up (top bar off screen) instead of resizing —imePadding()alone doesn't fix it and the transcript looks empty. - CMP 1.11 deprecates the
compose.*dependency accessors — declareorg.jetbrains.compose.<x>:<x>directly (material3 has its own release train, separate from the CMP version).
Environment notes (this machine, learned in local-updater)
- Android SDK is at
~/Android/Sdk, not the root-owned/opt/android-sdkthe ambient$ANDROID_HOMEmay point at; copy local-updater'sandroid-env.shoverride pattern. - Each agent command runs in a fresh shell — exported environment does not
carry over. Chain:
cd app && . ./android-env.sh && ./gradlew …. Never pipesourceintohead/grep(subshell discards the exports). - The shared emulator AVD is named
tdep— one emulator across the Android projects on this machine, not one per repo. - Long-running servers launched from an agent must be fully detached
(
setsid nohup … & disown -h, verifyPPID 1), and everypgrep -f/pkill -fpattern needs its first character bracketed ([a]i-server) in every occurrence in the command, or the pattern matches the shell running it. Full explanation in local-updater'sAGENTS.md— it bites exactly the same way here.