Files
ai-app/AGENTS.md
T
irisandClaude Fable 5 d2d2832ec8 Keep state and keys out of the shared repo
The dev VM is treated as untrusted, and the repo is a read-write virtiofs
mount shared with the backend host -- so a CA private key sitting in it is
a key that machine can sign with, and a leaf signed by this CA is one the
phone's pinned app accepts without question. Pinning against a CA the
attacker holds is no pinning at all.

So certificates are now generated on the machine that serves them, into
$XDG_CONFIG_HOME/ai-app/certs at 0700 with 0600 keys (AI_APP_CERTS
overrides), and config.json and session transcripts move to the XDG config
and data directories. Transcripts move for a plainer reason than the keys:
they are whole conversations, and they were world-readable at 0644.

Two smaller things fall out. The host and VM stop sharing one config,
which had already put a test token on the production backend. And state
stops living where `git clean -xdf` would take the enrollment and every
transcript with it.

State that predates the move is still read from the repo, with a warning
naming where to move it, so an existing install keeps working rather than
silently coming up on an empty config -- the precedence is covered by a
test, since picking the wrong file would otherwise be silent.

Verified: 31 tests, clippy clean; the certificate script writing 0700/0600
into an overridden directory; and the server logging the fallback and
serving from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 03:49:34 -04:00

155 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.rs` bootstraps (TLS, the
auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP
table in its module doc comment, `auth.rs` the bearer-token middleware,
`config.rs` the persisted schema, `session/` the manager (registry
pattern), `Driver` trait + event model, `EchoDriver`, and transcripts.
- `app/` — Compose Android app, single `:androidApp` module, package
`com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation
`when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the
event model mirror; `ServerConfig.kt` settings + Keystore-sealed token;
screens in `SessionListScreen/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 13 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` (or `cargo test`) + `cargo clippy --all-targets`
from `server/` — the build stays warning-clean, keep it that way.
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`;
`./run-android.sh` builds, 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 at `https://10.0.2.2:8443`; enroll it with
`adb 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-server` belongs in
production: it has the LAN address the phone can reach, and it's where
WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`,
the phone's QR); run it there with `sudo 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-app` in the VM,
`~/stuff/vm/ai/repos/ai-app` on the host. `server/target/` is shared
along with it, so **a `cargo build` on 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-server` with 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.1` for app work.
- `./test-wg-tunnel.sh up|test|down` builds 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.
- **The `claude` CLI is installed in this VM only, not on the host.** So
the backend reaches it the same way it would any other machine: a
configured host, and a session that names it. For the host to ssh in,
the VM needs an inbound port forward in its launch configuration
(qemu `hostfwd`) — usermode networking has none by default.
- **Nothing secret goes in the repo.** The VM is treated as untrusted (see
PLAN.md's security section), and the repo is shared read-write with the
host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.json`
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only. The server
still reads a pre-move `config.json`/`sessions/` from the repo, with a
warning, so an old install keeps working.
- Certificates are generated **on the machine that serves them**
(`./gen-dev-cert.sh`, honours `AI_APP_CERTS`). Running it in the VM makes
a separate throwaway dev CA for emulator work — never install a build
pinning that on the real phone.
- Point development at a scratch state directory rather than the real one:
`--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`, or
`XDG_CONFIG_HOME=… XDG_DATA_HOME=…`.
## 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** — declare
`org.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-sdk`
the ambient `$ANDROID_HOME` may point at; copy local-updater's
`android-env.sh` override pattern.
- Each agent command runs in a fresh shell — exported environment does not
carry over. Chain: `cd app && . ./android-env.sh && ./gradlew …`. Never
pipe `source` into `head`/`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`, verify `PPID 1`), and every
`pgrep -f`/`pkill -f` pattern 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's
`AGENTS.md` — it bites exactly the same way here.