From ce349af414367d1ef273b5d33b1a85027aa71c2d Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 25 Aug 2026 04:01:37 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- AGENTS.md | 25 ++++++------ PLAN.md | 4 +- server/src/main.rs | 96 ++-------------------------------------------- 3 files changed, 19 insertions(+), 106 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe585a1..73f0fe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/PLAN.md b/PLAN.md index f038b32..fc0d283 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/server/src/main.rs b/server/src/main.rs index 1f88433..4ba52f2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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 = 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