Drop the in-repo state fallback

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
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 04:01:37 -04:00
1 parent d2d2832ec8
commit ce349af414
3 files changed
+19 -106

No files matched your search

+14 -11
View File
@@ -83,14 +83,19 @@ Established 2026-08-25, and it decides more than it looks like:
(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.
- **Code reaches the host through gitea, not the shared mount.** This VM's
checkout (`~/host/repos/ai-app`, a virtiofs mount the host also sees at
`~/stuff/vm/ai/repos/ai-app`) is a working copy only: its `origin` is
`git@git.arirex.me:iris/ai-app`, and the VM's key is **not** authorized
for it — pushing from here fails with `Permission denied (publickey)`.
The host pushes, and its own separate clone — outside the shared mount —
is what gets built and run. So the review at push time, not a filesystem
permission, is what keeps VM-authored code off the host.
- Consequence for building here: `server/target/` is shared with the host's
view of *this* checkout, so if anything on the host ever builds from the
shared path, the two `cargo build`s replace each other's binary and each
rebuilds from scratch. Building on the host from its own clone avoids it
entirely.
- `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
@@ -109,9 +114,7 @@ Established 2026-08-25, and it decides more than it looks like:
- **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.
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
- 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
+1 -3
View File
@@ -255,9 +255,7 @@ GET/PUT /hosts, /models config editing from the phone
Sessions live in `config.json` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session
directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl,
attachments, produced images), owner-only. Deleting a session is the
complete path out of everything spawning one created. State that predates
the move is still read from the repo, loudly, so an existing install keeps
working until it is moved by hand.
complete path out of everything spawning one created.
### Security
+4 -92
View File
@@ -21,7 +21,7 @@ mod ssh;
mod usage;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
@@ -55,59 +55,6 @@ fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
.join("ai-app")
}
/// Picks `preferred`, unless only the older in-repo `legacy` path exists --
/// in which case it is used, loudly.
///
/// State used to live in the repo, which is wrong for two reasons: a
/// `git clean -xdf` would take the enrollment and every transcript with
/// it, and this repo is a virtiofs mount shared with a machine that is not
/// trusted with them (see PLAN.md's security section). Moving is left to a
/// person rather than done silently, because relocating a live config out
/// from under a running backend is not the kind of thing a program should
/// decide to do on its own.
fn prefer_xdg(preferred: PathBuf, legacy: PathBuf, what: &str) -> PathBuf {
if !preferred.exists() && legacy.exists() {
tracing::warn!(
"using {} from {} -- move it to {} when convenient; the repo is shared with the VM \
and is not a good home for {what}",
what,
legacy.display(),
preferred.display(),
);
return legacy;
}
preferred
}
/// The repo root, one level above this crate. Only used now to find the
/// legacy in-repo state above; everything the server reads by default
/// resolves from the XDG directories instead.
///
/// Found from the running executable first, and only then from the path
/// compiled in. This repo is shared between a VM and its host over
/// virtiofs at *different* absolute paths (`~/host/repos/ai-app` vs
/// `~/stuff/vm/ai/repos/ai-app`), and `target/` is shared along with it --
/// so a binary built on one side and run on the other would otherwise look
/// for its config under a path that doesn't exist there, which is exactly
/// what happened once. Where both agree (the ordinary case) the answer is
/// identical either way; the flags below override it regardless.
fn repo_root() -> &'static Path {
static ROOT: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
ROOT.get_or_init(|| {
// target/{debug,release}/ai-server -> four levels up is the root.
let from_exe = std::env::current_exe().ok().and_then(|exe| {
let root = exe.ancestors().nth(4)?.to_path_buf();
root.join("server/Cargo.toml").is_file().then_some(root)
});
from_exe.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("CARGO_MANIFEST_DIR has a repo-root parent")
.to_path_buf()
})
})
}
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app.
#[derive(Parser)]
struct Args {
@@ -193,16 +140,8 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt().with_env_filter("info").init();
let args = Args::parse();
let config_path = args.config.unwrap_or_else(|| {
prefer_xdg(
config_home().join("config.json"),
repo_root().join("config.json"),
"the config",
)
});
let data_dir = args.data_dir.unwrap_or_else(|| {
prefer_xdg(data_home().join("sessions"), repo_root().join("sessions"), "transcripts")
});
let config_path = args.config.unwrap_or_else(|| config_home().join("config.json"));
let data_dir = args.data_dir.unwrap_or_else(|| data_home().join("sessions"));
let manager = Arc::new(
SessionManager::new(config_path.clone(), data_dir)
.with_context(|| format!("failed to load {}", config_path.display()))?,
@@ -244,9 +183,7 @@ async fn main() -> Result<()> {
print_enrollment(bind_ip, args.port, &token)?;
}
let certs_dir = args
.certs
.unwrap_or_else(|| prefer_xdg(config_home().join("certs"), repo_root().join("certs"), "certificates"));
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
let leaf_cert = certs_dir.join("leaf.pem");
let leaf_key = certs_dir.join("leaf-key.pem");
if !leaf_cert.is_file() || !leaf_key.is_file() {
@@ -292,31 +229,6 @@ async fn main() -> Result<()> {
mod tests {
use super::*;
/// Choosing the wrong file here is silent -- the server would come up
/// happily on the wrong tokens and the wrong session list -- so the
/// precedence is worth pinning down.
#[test]
fn state_prefers_xdg_and_falls_back_to_the_repo_only_when_it_must() {
let dir = tempfile::tempdir().expect("tempdir");
let xdg = dir.path().join("xdg/config.json");
let legacy = dir.path().join("repo/config.json");
std::fs::create_dir_all(xdg.parent().unwrap()).expect("mkdir");
std::fs::create_dir_all(legacy.parent().unwrap()).expect("mkdir");
// Neither exists: the XDG path, which is where a first run writes.
assert_eq!(prefer_xdg(xdg.clone(), legacy.clone(), "the config"), xdg);
// Only the old in-repo one exists: keep using it rather than
// silently starting empty and losing the enrollment.
std::fs::write(&legacy, "{}").expect("write");
assert_eq!(prefer_xdg(xdg.clone(), legacy.clone(), "the config"), legacy);
// Both exist: the moved one wins, so finishing a migration takes
// effect even if the old file was left behind.
std::fs::write(&xdg, "{}").expect("write");
assert_eq!(prefer_xdg(xdg.clone(), legacy, "the config"), xdg);
}
#[test]
fn xdg_dirs_respect_the_environment_and_are_namespaced() {
// Relative values are ignored per the spec, rather than resolving