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:
1 parent
d2d2832ec8
commit
ce349af414
3 files changed
+19
-106
No files matched your search
@@ -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.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**,
|
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.
|
so the tunnel and the real phone can never terminate here.
|
||||||
- The repo is the *same files* on both sides over virtiofs, at different
|
- **Code reaches the host through gitea, not the shared mount.** This VM's
|
||||||
absolute paths: `~/host/repos/ai-app` in the VM,
|
checkout (`~/host/repos/ai-app`, a virtiofs mount the host also sees at
|
||||||
`~/stuff/vm/ai/repos/ai-app` on the host. `server/target/` is shared
|
`~/stuff/vm/ai/repos/ai-app`) is a working copy only: its `origin` is
|
||||||
along with it, so **a `cargo build` on one side replaces the other's
|
`git@git.arirex.me:iris/ai-app`, and the VM's key is **not** authorized
|
||||||
binary** (and each rebuilds from scratch after the other). `repo_root()`
|
for it — pushing from here fails with `Permission denied (publickey)`.
|
||||||
resolves from the running executable for exactly this reason — a
|
The host pushes, and its own separate clone — outside the shared mount —
|
||||||
host-built binary run in the VM used to look for its config under a path
|
is what gets built and run. So the review at push time, not a filesystem
|
||||||
that doesn't exist here.
|
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 —
|
- `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
|
`ai-server` with no `--bind` — is exercisable during development. It has
|
||||||
no reachable peer and doesn't need one; the interface existing is what
|
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
|
- **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
|
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`
|
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
|
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
|
||||||
still reads a pre-move `config.json`/`sessions/` from the repo, with a
|
|
||||||
warning, so an old install keeps working.
|
|
||||||
- Certificates are generated **on the machine that serves them**
|
- Certificates are generated **on the machine that serves them**
|
||||||
(`./gen-dev-cert.sh`, honours `AI_APP_CERTS`). Running it in the VM makes
|
(`./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
|
a separate throwaway dev CA for emulator work — never install a build
|
||||||
|
|||||||
@@ -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
|
Sessions live in `config.json` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session
|
||||||
directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl,
|
directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl,
|
||||||
attachments, produced images), owner-only. Deleting a session is the
|
attachments, produced images), owner-only. Deleting a session is the
|
||||||
complete path out of everything spawning one created. State that predates
|
complete path out of everything spawning one created.
|
||||||
the move is still read from the repo, loudly, so an existing install keeps
|
|
||||||
working until it is moved by hand.
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
+4
-92
@@ -21,7 +21,7 @@ mod ssh;
|
|||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
@@ -55,59 +55,6 @@ fn xdg_dir(var: &str, fallback: &str) -> PathBuf {
|
|||||||
.join("ai-app")
|
.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.
|
/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app.
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct Args {
|
struct Args {
|
||||||
@@ -193,16 +140,8 @@ async fn main() -> Result<()> {
|
|||||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
let config_path = args.config.unwrap_or_else(|| {
|
let config_path = args.config.unwrap_or_else(|| config_home().join("config.json"));
|
||||||
prefer_xdg(
|
let data_dir = args.data_dir.unwrap_or_else(|| data_home().join("sessions"));
|
||||||
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 manager = Arc::new(
|
let manager = Arc::new(
|
||||||
SessionManager::new(config_path.clone(), data_dir)
|
SessionManager::new(config_path.clone(), data_dir)
|
||||||
.with_context(|| format!("failed to load {}", config_path.display()))?,
|
.with_context(|| format!("failed to load {}", config_path.display()))?,
|
||||||
@@ -244,9 +183,7 @@ async fn main() -> Result<()> {
|
|||||||
print_enrollment(bind_ip, args.port, &token)?;
|
print_enrollment(bind_ip, args.port, &token)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let certs_dir = args
|
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
|
||||||
.certs
|
|
||||||
.unwrap_or_else(|| prefer_xdg(config_home().join("certs"), repo_root().join("certs"), "certificates"));
|
|
||||||
let leaf_cert = certs_dir.join("leaf.pem");
|
let leaf_cert = certs_dir.join("leaf.pem");
|
||||||
let leaf_key = certs_dir.join("leaf-key.pem");
|
let leaf_key = certs_dir.join("leaf-key.pem");
|
||||||
if !leaf_cert.is_file() || !leaf_key.is_file() {
|
if !leaf_cert.is_file() || !leaf_key.is_file() {
|
||||||
@@ -292,31 +229,6 @@ async fn main() -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn xdg_dirs_respect_the_environment_and_are_namespaced() {
|
fn xdg_dirs_respect_the_environment_and_are_namespaced() {
|
||||||
// Relative values are ignored per the spec, rather than resolving
|
// Relative values are ignored per the spec, rather than resolving
|
||||||
|
|||||||
Reference in new issue
Block a user