dev-updater now carries a built-in service implementation, generated from
a template and driven through the identical interface a project-supplied
script uses, so a project whose service is unremarkable no longer writes
one. ai-app's was unremarkable: `ExecStart=$BINARY` and `command="$BINARY"`
with no arguments and no environment. 233 lines of it, and the half that
matters most -- the OpenRC branch, which neither project can exercise from
a systemd machine -- existed twice, so a fix found by testing would have
had two places to land and no way to notice the second.
The field keeps its name; `Managed` takes the command, resolved against
the component's `cwd`.
The one thing the script said that the built-in cannot is kept, in
AGENTS.md rather than lost: Stop on this card takes down the server a
phone reaches through the tunnel, while Dev Updater itself is unaffected
because it uses its own port -- which is exactly what makes that button
easy to press and easy to regret.
NOT SAFE TO PULL BLIND. A managed service is named after the component, so
this one becomes `app-backend` while the installed one still has the name
the script gave it. Uninstall from the backend card *before* taking this
change, then Install after; pulling first orphans a service that stays
enabled and starts at boot with nothing pointing at it.
The five modules underneath this backend that were never about AI
sessions -- the pinned CA and leaf, QR enrollment and the bearer token,
wg0 binding and the certificate's SANs, owner-only files, and the RON
house rules -- were written twice, once here and once in dev-updater,
and had drifted. They now come from the submodule, as a path dependency
so both projects stay locked to one commit.
What stayed is what makes this project itself: the routes, the drivers,
the config schema, and the auth middleware, which is generic over this
server's state. Sharing a transport is worth doing; sharing an API would
mean inventing a vocabulary neither project wants.
Four dependencies go with the code -- rcgen, qrcode, subtle and if-addrs
are no longer named here at all -- and the three that remain are now
described by what still uses them rather than by what used to.
Verified by running it, not only by building: a fresh server generates
its CA, prints an `aiapp://enroll` QR with the scheme now passed as a
parameter, covers 127.0.0.1, 10.0.2.2 and wg0's 10.66.0.1 in the leaf,
answers an enrolled token and returns 401 without one, and writes
config.ron in the house rules with every file owner-only. 36 tests pass,
clippy is silent, rustfmt is clean.
The status section still said "server side" and "no app screen yet", both
of which stopped being true today. Also records the discovery trade that
would otherwise be rediscovered: command -v follows a non-interactive ssh
PATH, so llama.cpp unpacked into ~/.local/opt is invisible until it is
symlinked onto PATH.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
Phase 4 is no longer deferred and phase 5 is exercised, so the status
section says so. The two decisions worth not undoing by accident get
named: the conversation lives in the transcript rather than the driver,
and a llama session is refused on an ssh host rather than half-working.
Also the local testing recipe, including the trap that cost me twenty
minutes -- a 2-bit quant produces fluent nonsense that reads exactly like
a broken driver, and llama-cli on the same file is how to tell the two
apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
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
AGENTS.md's "Checking your work" listed a typecheck for the app and tests
plus clippy for the server. Neither half mentioned a formatter, and nothing
mentioned Android Lint at all -- which is how a linter that was in the
build the whole time went unrun long enough to accumulate a crash.
Both lines now say the whole thing, and the app's reads as the counterpart
of the server's rather than a shorter version of it. The note about lint is
there because it is the one step a build does not do for you: nothing fails
if you skip it, which is exactly why it needs writing down.
The command in the app bullet is the one I ran to verify this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
A doc comment is not compiled, so nothing catches one that has outlived
what it described. `cargo doc` does catch a subset, and it was failing:
three unresolved intra-doc links in config.rs, all mine from today -- two
naming `parse`/`render` from outside the module that defines them, and one
`format` that is ambiguous with the macro. Rustdoc now runs clean with
broken_intra_doc_links denied.
auth.rs's module doc pointed at a test called `token_is_never_logged`,
which no longer exists: it was folded into the combined gating+logging test
because tracing caches callsite interest process-wide. The doc now
describes the test that is there and says why it is one test.
AGENTS.md contradicted itself twice, both checked against a running server
rather than by reading. Its curl example passed `--cacert certs/ca.pem`, a
path relative to the checkout, while the bullet above it correctly says the
CA is generated under $XDG_CONFIG_HOME -- so the documented command fails
before it connects; with the real path it returns `[]`. And it said wg0
"doesn't exist on this machine yet" as the reason for --bind 127.0.0.1,
which the section below it already contradicts: wg0 is up at 10.66.0.1, and
the actual reason is that the emulator dials 10.0.2.2 and cannot reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
CMP 1.11 deprecating the `compose.*` accessors and AGP 9 refusing a
`Provider` in the source-set API are facts about those versions, not about
this app -- they would bite any Android project on this machine, and the
one that hits them next is unlikely to be reading ai-app's notes. They now
live in ~/.claude/TOOLCHAIN.md, each stated against the version it was
learned on so it can be deleted when nothing here is on that version.
What is left under "Things that have bitten" is genuinely this project's,
and the section now says so, with the two global homes named -- so the next
lesson lands in the right one instead of here by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
AGENTS.md had grown a description of the machine these sessions run on --
the two boxes, the qemu networking, the shared ~/repos mount, gitea and the
push permission, the Android SDK path, the shared emulator, how to detach a
server. None of that is about ai-app, and all of it is equally true in the
sibling repos, so each copy was a place for the truth to drift: the push
note in this one had been wrong since the bot key was authorized, and the
checkout path it named had moved.
It now lives once in ~/.claude/MACHINE.md, which every session already
reads. What stays here is only what the machine means for this project: that
ai-server belongs on the host because WireGuard terminates there, that the
tunnel can never terminate in the VM, that the claude CLI being VM-only
makes it a remote to the backend, and where this project's secrets live
instead of the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The note saying otherwise predates the iris-ai bot account being authorized
for this repository, and a stale "you cannot do this" is worse than no note
at all -- it sends the next reader looking for a workaround that isn't
needed. The checkout's path had drifted in the same sentence: it is
~/repos/ai-app, not ~/host/repos/ai-app.
What the correction does not change is the posture. The host builds and
runs from its own clone outside the shared mount, so a deliberate pull
there is still what stands between this VM and the host -- it just isn't a
push permission doing that job any more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The same move, for the same reason: this file is written and read by hand,
and JSON has no comments to say why a host is configured the way it is.
Both house rules come across with it, in config.rs's `format` module and
nowhere else -- a file is the *body* of the config, so no outer parentheses
and nothing indented for them, and `Some` is implicit, which is what makes
`skip_serializing_if` on every optional field load-bearing rather than
tidiness.
The switch is outright: there is no reader for the old format. That is
invisible everywhere except here, because this file holds the enrolled
token hashes -- starting empty leaves the phone unable to talk to the
server and looks, from the phone, like the config having been lost. So a
config.json left beside the new file is named in the log and left alone,
rather than read or deleted.
One wart, documented at DriverKind: the kebab-case spelling is the string
the phone compares against, so it stays, and the file pays for it with
`kind: r#claude-cli` -- a hyphen is not a RON identifier. Renaming the
variant would change what an already-installed build is talking to.
Verified: cargo test, cargo clippy --all-targets, and a real start against
a scratch state directory -- a hand-typed config with comments and a bare
`port: 2222` loads, and what the server writes back sits at column 0 with
no Some(...) in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The library's CaptureActivity has three defaults that put work on the
person holding the phone, so the scan now goes through our own subclass
instead:
- It decodes only the framing rectangle, inset 10% from every edge. A code
filling the viewfinder still decodes -- measured against the library's
own decoder rather than assumed -- but only by spending its quiet zone
on the crop, and anything further out is never looked at. The whole
preview is decoded now, so framing is not something to get right.
- It draws a red laser line and scatters dots wherever the detector finds
a candidate pattern. That is the library's house style, and it looks
like a fault. The preview is plain now.
- Its manifest entry pins the activity to landscape. The code being
scanned is usually on a monitor in front of someone holding the phone
upright, so ours follows the sensor.
Verified on the emulator: the scanner opens on a bare preview with no
laser, no dots and no status line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
ai-server prints the QR as block characters, which take the terminal's
foreground colour. On a dark-themed terminal that comes out as a
photographic negative, and ZXing looks only for a dark code on a light
ground, so the in-app scanner silently never matched it -- while the
phone's own camera app, which tries both, did.
Fixing it in the scanner rather than by having the server force its
colours means however the code gets displayed stops mattering: a light
terminal, a dark one, a screenshot someone inverted, a printout.
MIXED_SCAN alternates normal and inverted frames, so the cost is half the
frame rate at each polarity. Checked against the library's own
MixedDecoder off-device: the plain decoder reads the normal image and
returns nothing for the inverted one, and the mixed decoder reads both
within two frames.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
It is no longer "local" -- it serves over WireGuard rather than the LAN --
and it is specifically for developing new apps. Renaming the references
here at the same time keeps one name to search for across both repos.
Also drops the last references to gen-dev-cert.sh, which the in-process
certificate generation replaced: the build script and the Gradle task now
say to start the server once, and test-wg-tunnel.sh reads the
certificates from the XDG directory rather than the repo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
gen-dev-cert.sh is gone. The server ensures its own certificates on start,
which removes a setup step to remember, a dependency on whatever openssl
was installed, and a second place for the "which addresses?" answer to
live -- the leaf now covers every local IPv4 plus loopback and the
emulator's host alias, so nobody maintains a hardcoded IP.
The split that mattered in the script is kept and now enforced by tests:
the CA is generated once and left alone, because the app pins it and
replacing it strands every installed copy; the leaf is cheap and reissued
every start, so covering a new address is a restart. Both are written
owner-only into a directory outside the repo.
Two things the tests caught. DirBuilder's mode applies only when the
directory is created, so a directory that already existed kept whatever
permissions it had while holding a private key -- the mode is now set
explicitly, in the session directories too. And loading the leaf into the
real RustlsConfig needs the crypto provider installed, which main does but
tests don't.
Verified end to end: deleted the certs, started the server, watched it
generate a CA and warn that installed apps now pin the wrong one, rebuilt
the APK against the new CA, and reinstalled -- the emulator connects over
a certificate that never existed as a pasted constant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
PinnedCert.kt no longer carries a pasted certificate. The build reads
$XDG_CONFIG_HOME/ai-app/certs/ca.pem (AI_APP_CA overrides) and generates
the constant, so the trust anchor follows the build machine: an APK built
on the backend host pins that host, and one built in the dev VM pins the
VM's throwaway CA and is good only for its emulator.
That removes the reason to add a second trust anchor for development --
there is nothing to add and then forget to remove -- and it means the
private key never has to exist near this repo, which the VM can write.
Regenerating a CA now needs a rebuild instead of a paste, so a stale
constant can't quietly disagree with the server.
build-apk.sh is the missing counterpart to run-android.sh: it produces
the APK to install through Local Updater and touches no emulator. It
finds the SDK from ANDROID_HOME/ANDROID_SDK_ROOT before falling back to
~/Android/Sdk, since the host doesn't share the VM's layout, and prints
the fingerprint of the CA being pinned so a wrong one is visible there
rather than as a handshake failure on the phone.
Verified end to end on the emulator against a server using a freshly
generated CA -- which is how the first attempt was caught: the generated
constant began with a newline, so CertificateFactory lost the "-----BEGIN"
sniff, tried DER, and failed at runtime with an ASN.1 decode error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
Nothing has run against the host's backend yet, so there is no old config
or transcript to keep working -- the XDG paths are simply where state
lives. Removing the fallback takes repo_root() with it, since finding the
repo from the running executable existed only to locate that legacy state.
AGENTS.md gets the arrangement that replaced it: the host builds and runs
from its own clone outside the shared mount, and code reaches it by push
to gitea, which this VM's key is not authorized for.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
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
A session now names a provider (what: driver kind, command, models) and
optionally a host (where: an ssh target). Keeping them independent is
what the real setup needs -- the backend runs where the phone can reach
it, which isn't where the CLI is installed -- and it means any provider
can be sent to any host rather than a machine being baked into one.
The first provider is claude-cli, named for the CLI rather than bare
"claude", which would suggest the credit-billed API. A fresh config is
seeded with it so a new install has something to spawn and a worked
example to edit; echo stays a built-in provider needing no config.
ssh.rs builds the child process either way: locally, or `ssh -T` with
BatchMode and keepalives, every argument single-quoted for the remote
shell (a working directory that tries to close the quote and start a
command is covered by a test), and `exec` so dropping the connection
takes the CLI down instead of orphaning it.
App: the spawn screen reads /providers and /hosts instead of hardcoded
lists, so config changes need no rebuild. Chip rows are FlowRow, fixing
the reported bug where a row of models that didn't fit wrapped *inside*
each chip -- one letter of "haiku" per line -- rather than onto a second
line.
Verified: 29 tests, clippy clean; the same claude-cli provider run once
locally and once over ssh, with the remote one visibly in a different
environment; an unknown host name refused with the configured list; and
the spawn screen on the emulator showing server-driven providers, hosts,
and models that wrap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
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
PLAN.md: UserMessage/Answered events in the common model, the --bind dev
override (fail-closed default untouched), enrollment via the aiapp://
intent filter, Keystore-sealed token storage; phase 1 marked done with
what was verified. AGENTS.md: real layout, commands, and the lessons
that bit (tracing callsite cache in tests, adjustResize, CMP accessor
deprecation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw