The status section had carried an open question since phase 3: SSH could not be exercised in this VM because there is no second machine and no key in ~/.ssh. There is a second machine, though -- this one. Ssh it to itself with a throwaway key and a host of bob@127.0.0.1, point the provider's command at /bin/echo rather than claude, and the whole path runs: connection, remote exec, and the process's death arriving as `status: exited` in the transcript. It costs no tokens and touches nothing real, and the key comes back out afterwards. Done that way just now against the new transport, so the technique is written down as something that worked rather than something that should. Also recorded: the login shell in this VM is fish. `cd '…' && exec '…'` is valid there and the POSIX single-quote escaping happens to mean the same thing, but both are luck, and a non-POSIX remote shell is the first thing to suspect if an argument is ever mangled on the way over. Phase 4 is no longer deferred -- Bryan asked for llama.cpp today -- so the status paragraph stops saying it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
194 lines
11 KiB
Markdown
194 lines
11 KiB
Markdown
# 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 `../dev-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 dev-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 and the RON its file is written in,
|
||
`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`.
|
||
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
||
the backend (built in `server/`, installed and controlled through
|
||
`server/service`) and then the APK (built in `app/`), in that order. The
|
||
project it serves is the repository, not either half of it, which is why
|
||
this sits at the root rather than in `app/`.
|
||
- `server/src/certs.rs` — the TLS certificates, generated in process on
|
||
first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf
|
||
reissued every start covering every local IPv4 plus 127.0.0.1 and
|
||
10.0.2.2 (emulator → host). Regenerating the CA strands the installed
|
||
app — the one-way door.
|
||
|
||
## 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 5 (SSH) is written and now exercised** (2026-08-28). A session
|
||
names a host, `session::transport` turns that into an `ssh host …`
|
||
invocation, and the driver never learns which it got. Phase 4 (llama.cpp)
|
||
is being built now, no longer deferred. What is left is
|
||
real-phone/WireGuard bring-up, which is operational rather than code.
|
||
|
||
**How to test SSH here, since there is no second machine:** ssh this VM to
|
||
itself. Generate a throwaway key, append the public half to
|
||
`~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with
|
||
`identityFile` pointing at it plus
|
||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it
|
||
touches nothing real. Point a provider's `command` at something harmless
|
||
like `/bin/echo` rather than at `claude`: the transport is what is under
|
||
test, the process exiting immediately is the signal, and it costs no
|
||
tokens. A session spawned on that host logs `running /bin/echo on loopback
|
||
(bob@127.0.0.1)` and lands `status: exited` in its transcript, which is
|
||
the whole path — connection, remote exec, process death reported. **Take
|
||
the key back out afterwards**; this VM's `authorized_keys` is not scratch
|
||
space.
|
||
|
||
Note the remote login shell here is **fish**, not a POSIX shell. The
|
||
remote script (`cd '…' && exec '…'`) happens to be valid in both, and the
|
||
POSIX single-quote escaping `ssh.rs` does happens to mean the same thing
|
||
in fish — but that is luck rather than design, and a shell that isn't
|
||
either would be the thing to suspect first if a remote spawn ever
|
||
mangles an argument.
|
||
|
||
## Checking your work
|
||
|
||
- Server: from `server/`, `./run-tests.sh` (or `cargo test`) +
|
||
`cargo clippy --all-targets` + `cargo fmt`. The build stays
|
||
warning-clean and rustfmt-clean at the defaults — there is no
|
||
`rustfmt.toml` and there should not be one.
|
||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||
:androidApp:compileDebugKotlin :androidApp:lintDebug` — format, typecheck
|
||
and lint, the app-side equivalent of the line above. Then `./build-apk.sh`
|
||
to produce the APK to install on a phone (through Dev Updater), or
|
||
`./run-android.sh` to build, install, and launch on the emulator.
|
||
- **Android Lint is not optional and is not run by a build.** It found a
|
||
crash that had been shipping: `java.time` on a minSdk-24 app with
|
||
desugaring off. It is clean now apart from Compose 1.11.1 having a 1.12.0
|
||
available; keep it that way, and suppress with `tools:ignore` plus a
|
||
written reason rather than by lowering the bar.
|
||
- **The APK pins the CA of the machine that builds it**, read at build time
|
||
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and
|
||
generated into a constant. So the server must have started once on that
|
||
machine first — the build stops with that instruction otherwise — and an
|
||
APK built in this VM only works against a server in this VM.
|
||
- Run the server for development with `--bind 127.0.0.1`. Without it the
|
||
server binds wg0, which exists here but is unreachable from the emulator
|
||
(it dials 10.0.2.2). 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 ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`.
|
||
The CA is wherever `--certs` put it — by default under
|
||
`$XDG_CONFIG_HOME` (`~/.config` when that is unset), never in the
|
||
checkout, so a relative `certs/ca.pem` finds nothing.
|
||
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 machine
|
||
itself — the two boxes, the shared `~/repos` mount, gitea, and why the VM is
|
||
treated as untrusted — is described once in `~/.claude/MACHINE.md`; what
|
||
follows is only what that means for **this** project.
|
||
|
||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||
address the phone can reach is, and 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>`.
|
||
- **The tunnel and the real phone can never terminate in the VM**, because
|
||
nothing outside can open a connection into it. Phone bring-up is host
|
||
work.
|
||
- `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 only in the VM, so from the host it is a remote.**
|
||
The backend reaches it the 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 it does not have by default.
|
||
- **Nothing secret goes in the repo**, which is shared with the host and
|
||
attacker-writable under this project's threat model (PLAN.md's security
|
||
section). So state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.ron`
|
||
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
|
||
- Certificates are generated **by the server, on first start**, into
|
||
`$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created
|
||
once and then left alone; the leaf is reissued every start, so covering a
|
||
new address is a restart. Starting the server in the VM therefore 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.ron --data-dir /tmp/…/sessions --port 8444`, or
|
||
`XDG_CONFIG_HOME=… XDG_DATA_HOME=…`.
|
||
|
||
## Things that have bitten
|
||
|
||
Project-specific only — a lesson that would bite any project on this
|
||
machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or
|
||
`~/.claude/MACHINE.md` (the machine itself) instead.
|
||
|
||
- **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.
|
||
- **A PEM constant must start at the opening quotes.** A generated
|
||
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory`
|
||
its preamble sniff, so it tries DER instead and fails at runtime with
|
||
`ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it.
|
||
- **ZXing only looks for a dark code on a light ground.** The enrollment
|
||
QR is block characters in the terminal's foreground colour, so a
|
||
dark-themed terminal renders it as a negative and the in-app scanner
|
||
silently never matches — while the phone's own camera app, which tries
|
||
both, does. The scanner asks for `Intents.Scan.MIXED_SCAN`, which
|
||
alternates normal and inverted frames; keep it that way rather than
|
||
making the server dictate the colours. `EnrollmentScanActivity` also
|
||
turns off the library's 10% framing-rect inset (it decodes only what is
|
||
inside it) and its laser/result-point decorations.
|