diff --git a/AGENTS.md b/AGENTS.md index abe524d..9b75d4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,26 +37,21 @@ repo is in PLAN.md's "Backend layout" section. same path with the token written up front rather than a second way to start a session. The phone picks an **id**, never a path: the server resolves which file that is, so an enrolled token cannot become "read me - an arbitrary file", the same rule that keeps a command out of + an arbitrary file" — the same rule that keeps a command out of `POST /setups`. Only the tail is replayed (`REPLAY_LINES`) because these files reach tens of megabytes and the CLI reads the real one itself; what crosses the tunnel is what a person reads, not what the model is given. Images in the replayed tail are written into the session's `files/` by - the same function the live translator uses, and referenced -- so a - screenshot looks the same whether it was watched happening or replayed - afterwards, the phone fetches the bytes only when it draws one, and none - of it goes anywhere near the CLI, which reads its own file. + the same function the live translator uses, so a screenshot looks the + same whether it was watched happening or replayed afterwards, and the + phone fetches the bytes only when it draws one. An imported session then **keeps itself level with that file**, so work - done at a terminal appears without anyone pressing anything. `--resume` - appends to the same transcript rather than forking — measured, but - **against a session nothing else had open**; against a live one it - duplicates the whole conversation into the file, which is why an open - session cannot be imported at all (see below). So the - only hard question is which new lines came from *here* — answered by - counting the events this session has recorded, not by looking at its - status. Status is the obvious signal and is wrong: a turn that starts and - finishes between two polls reads as idle at both, and its own output gets - replayed on top of itself. That bug was visible on screen as `donedone`. + done at a terminal appears without anyone pressing anything. Which new + lines came from *here* is answered by counting the events this session + has recorded, **not** by looking at its status — a turn that starts and + finishes between two polls reads as idle at both, and its own output + gets replayed on top of itself. That bug was visible on screen as + `donedone`. - `server/src/usage.rs` — rate-limit windows, asked **of each machine that can run Claude**, not of the backend. Credentials are read through the session `Transport`, so a remote setup is an ssh round trip and the local @@ -101,62 +96,55 @@ repo is in PLAN.md's "Backend layout" section. dev-updater also needed: the pinned CA and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 binding and the certificate's SANs (`netif`), owner-only files (`private`), and the RON house rules - (`format`). Both projects had written all five, and they had drifted; - see that repo's `README.md` for the diff that decided each one and the - permissions bug the extraction found. Clone with + (`format`). Both projects had written all five and they had drifted; see + that repo's `README.md` for the diff that decided each one. Clone with `git clone --recurse-submodules`, or `git submodule update --init` in an existing checkout — `server/` will not build without it, since it is a path dependency rather than a registry one, which is what keeps the two projects version-locked to the commit this repo pins. - The certificates are still the one-way door: the CA is generated once on - first start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it - strands the installed app. + The certificates are the one-way door: the CA is generated once on first + start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it strands + the installed app. What deliberately did **not** move is the API surface and the config *schema* — routes, drivers, sessions and setups are what makes this - project itself, and a shared vocabulary for them would be inventing one - neither project wants. - + project itself. ## 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. +Phases 1–3 done 2026-08-24 (PLAN.md's phase list says 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 5 (SSH)** is written and 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) works end to end, phone included** (2026-08-28). Models are -browsed and downloaded from HuggingFace (`models.rs`, resumable and -verified), and `session::llama` runs one through `llama-server`, talking -to its OpenAI-compatible streaming endpoint. Two things about it are -deliberate and easy to undo by accident: the conversation is rebuilt from -the **transcript** rather than kept in the driver, because driver memory -is invisible to a second device; and a llama session is refused on an ssh +**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28). +Models are browsed and downloaded from HuggingFace (`models.rs`, resumable +and verified), and `session::llama` runs one through `llama-server` over +its OpenAI-compatible streaming endpoint. Two things are deliberate and +easy to undo by accident: the conversation is rebuilt from the +**transcript** rather than kept in the driver, because driver memory is +invisible to a second device; and a llama session is refused on an ssh host, because the model is reached over HTTP and forwarding that port is not built. -Models are browsed, downloaded and deleted from the app, and a llama -session picks one of them plus a context size and temperature. Setups — -machines, each carrying what it can run — are added, renamed, re-probed -and removed from the app too; providers are **discovered by asking the -machine**, never typed, so the enrolled token cannot introduce a command. - -What is left is real-phone/WireGuard bring-up, which is operational rather -than code. +Setups — machines, each carrying what it can run — are added, renamed, +re-probed and removed from the app; providers are **discovered by asking +the machine**, never typed, so the enrolled token cannot introduce a +command. What is left is real-phone/WireGuard bring-up, which is +operational rather than code. **`command -v` follows PATH under a non-interactive ssh session**, which is not the PATH a login shell shows, so a binary somewhere unusual is invisible to discovery — llama.cpp unpacked into `~/.local/opt` needs a symlink into `~/.local/bin` before a setup finds it. The escape hatch for -anything odder is editing `config.ron` on the backend, which is -deliberately the one authority the phone does not have. +anything odder is editing `config.ron` on the backend, deliberately the one +authority the phone does not have. **Testing llama.cpp here:** the prebuilt CPU build lives outside the repo -at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset — no -compiling, and it runs fine on Arch). It needs its own directory on -`LD_LIBRARY_PATH`, so start the server as +at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It +needs its own directory on `LD_LIBRARY_PATH`, so start the server as `LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's `command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the @@ -172,18 +160,11 @@ itself. Generate a throwaway key, append the public half to 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. +tokens. **Take the key back out afterwards.** Note the remote login shell +here is **fish**; the remote script (`cd '…' && exec '…'`) and `ssh.rs`'s +POSIX quoting happen to mean the same thing in both, but that is luck +rather than design, and a shell that isn't either is the thing to suspect +first if a remote spawn ever mangles an argument. ## Checking your work @@ -222,10 +203,9 @@ mangles an argument. ## 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. +Established 2026-08-25. The machine itself — the two boxes, the shared +`~/repos` mount, and why the VM is untrusted — is described once in +`~/.claude/MACHINE.md`; what follows is only what that means here. - **`ai-server` belongs on the host in production.** That is where the LAN address the phone can reach is, and where WireGuard terminates. @@ -234,33 +214,30 @@ follows is only what that means for **this** project. - **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 — +- `wg0` (10.66.0.1) 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 + no reachable peer and doesn't need one. 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. + 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 how 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. + The backend reaches it as it would any other machine: a configured host, + and a session that names it. - **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` + section). 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. + once and 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 — 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=…`. + `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`. ## Sessions outlive the backend