Skip to main content

nyx_agent_core/
state.rs

1//! Filesystem layout for the agent's persistent state.
2//!
3//! Creates `~/.local/share/nyx-agent/{runs,repos,findings,logs,cache,bundles,secrets}`
4//! on first use. On Unix the root directory and its subdirectories are
5//! restricted to mode `0700` so other local users cannot read run state.
6
7use std::path::{Path, PathBuf};
8
9use thiserror::Error;
10
11const SUBDIRS: &[&str] =
12    &["runs", "repos", "findings", "logs", "cache", "bundles", "secrets", "traces"];
13
14#[derive(Debug, Error)]
15pub enum StateError {
16    #[error("could not resolve user data directory (HOME/XDG_DATA_HOME unset?)")]
17    NoDataDir,
18    #[error("failed to create {path}: {source}")]
19    Create {
20        path: PathBuf,
21        #[source]
22        source: std::io::Error,
23    },
24    #[error("failed to set permissions on {path}: {source}")]
25    Permissions {
26        path: PathBuf,
27        #[source]
28        source: std::io::Error,
29    },
30}
31
32#[derive(Debug, Clone)]
33pub struct StateDir {
34    root: PathBuf,
35}
36
37impl StateDir {
38    pub fn default_root() -> Result<PathBuf, StateError> {
39        let base = dirs::data_dir().ok_or(StateError::NoDataDir)?;
40        Ok(base.join("nyx-agent"))
41    }
42
43    pub fn at(root: impl Into<PathBuf>) -> Self {
44        Self { root: root.into() }
45    }
46
47    pub fn discover() -> Result<Self, StateError> {
48        Ok(Self::at(Self::default_root()?))
49    }
50
51    pub fn root(&self) -> &Path {
52        &self.root
53    }
54
55    pub fn runs(&self) -> PathBuf {
56        self.root.join("runs")
57    }
58
59    pub fn repos(&self) -> PathBuf {
60        self.root.join("repos")
61    }
62
63    /// Project-scoped repos directory at
64    /// `<root>/projects/<project_id>/repos`. Ingestion writes per-repo
65    /// workspace subdirs under this path so two repos with the same
66    /// name in different projects never collide.
67    pub fn project_repos(&self, project_id: &str) -> PathBuf {
68        self.root.join("projects").join(project_id).join("repos")
69    }
70
71    pub fn findings(&self) -> PathBuf {
72        self.root.join("findings")
73    }
74
75    pub fn logs(&self) -> PathBuf {
76        self.root.join("logs")
77    }
78
79    pub fn cache(&self) -> PathBuf {
80        self.root.join("cache")
81    }
82
83    /// Per-finding repro bundle output directory (`<state>/bundles`).
84    /// One tarball per finding is written here when the operator
85    /// requests a repro bundle.
86    pub fn bundles(&self) -> PathBuf {
87        self.root.join("bundles")
88    }
89
90    /// Per-AI-task trace artefact directory (`<state>/traces`). The
91    /// exploration pass writes one `<task_id>.jsonl` per run under
92    /// [`Self::traces_for_run`] and stamps the path on the matching
93    /// `agent_traces.conversation_jsonl_path` row.
94    pub fn traces(&self) -> PathBuf {
95        self.root.join("traces")
96    }
97
98    /// Per-run trace directory at `<state>/traces/<run_id>`. Created
99    /// on demand by the pass that writes into it; callers should
100    /// `std::fs::create_dir_all` before opening files.
101    pub fn traces_for_run(&self, run_id: &str) -> PathBuf {
102        self.traces().join(run_id)
103    }
104
105    /// Secrets directory at `<state>/secrets`, created with mode `0700`
106    /// by [`Self::ensure`]. The env-builder reads
107    /// `<state>/secrets/test.env` (and the optional `test.env.allow`
108    /// sibling) from this path. Surfaced as a single canonical accessor
109    /// so the wizard, doctor, and env-builder name the same location.
110    pub fn secrets(&self) -> PathBuf {
111        self.root.join("secrets")
112    }
113
114    /// Path of the env-builder test secrets file
115    /// (`<state>/secrets/test.env`). Absent until the operator drops a
116    /// file at this path; the env-builder fails closed when it is
117    /// missing.
118    pub fn secrets_test_env_path(&self) -> PathBuf {
119        self.secrets().join("test.env")
120    }
121
122    /// Path of the optional env-builder secrets allowlist
123    /// (`<state>/secrets/test.env.allow`). Absent on a fresh install;
124    /// the env-builder treats a missing file as an empty allowlist.
125    pub fn secrets_test_env_allow_path(&self) -> PathBuf {
126        self.secrets().join("test.env.allow")
127    }
128
129    /// Bearer-token file consumed by the API auth middleware. Stored
130    /// at `<state>/auth_token` with mode `0600`. Absent before the
131    /// daemon's first launch.
132    pub fn auth_token_path(&self) -> PathBuf {
133        self.root.join("auth_token")
134    }
135
136    /// Create the root and every subdirectory if missing; idempotent. On
137    /// Unix every directory created or already present is forced to mode
138    /// `0700`.
139    #[tracing::instrument(skip_all, fields(root = %self.root.display()))]
140    pub fn ensure(&self) -> Result<(), StateError> {
141        create_secure_dir(&self.root)?;
142        for sub in SUBDIRS {
143            create_secure_dir(&self.root.join(sub))?;
144        }
145        Ok(())
146    }
147
148    /// Load the bearer token from `auth_token`, generating + persisting
149    /// a fresh one if absent. The minted file is `0o600` so a second
150    /// user on the box cannot read it. Callers that have already
151    /// invoked [`Self::ensure`] do not need to repeat it.
152    pub fn load_or_mint_auth_token(&self) -> Result<String, StateError> {
153        let path = self.auth_token_path();
154        if path.exists() {
155            let raw = std::fs::read_to_string(&path)
156                .map_err(|source| StateError::Create { path: path.clone(), source })?;
157            let trimmed = raw.trim().to_string();
158            if !trimmed.is_empty() {
159                return Ok(trimmed);
160            }
161        }
162        let token = mint_token();
163        write_secure_file(&path, token.as_bytes())?;
164        Ok(token)
165    }
166}
167
168/// Mint a 256-bit URL-safe token. Surfaced for tests; the daemon
169/// always goes through [`StateDir::load_or_mint_auth_token`].
170pub fn mint_token() -> String {
171    use rand::Rng;
172    let mut buf = [0u8; 32];
173    rand::rng().fill_bytes(&mut buf);
174    hex::encode(buf)
175}
176
177fn write_secure_file(path: &Path, bytes: &[u8]) -> Result<(), StateError> {
178    if let Some(parent) = path.parent() {
179        std::fs::create_dir_all(parent)
180            .map_err(|source| StateError::Create { path: parent.to_path_buf(), source })?;
181    }
182    write_with_mode(path, bytes)
183        .map_err(|source| StateError::Create { path: path.to_path_buf(), source })?;
184    Ok(())
185}
186
187#[cfg(unix)]
188fn write_with_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
189    use std::io::Write;
190    use std::os::unix::fs::OpenOptionsExt;
191    let mut file = std::fs::OpenOptions::new()
192        .write(true)
193        .create(true)
194        .truncate(true)
195        .mode(0o600)
196        .open(path)?;
197    file.write_all(bytes)?;
198    file.flush()
199}
200
201#[cfg(not(unix))]
202fn write_with_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
203    std::fs::write(path, bytes)
204}
205
206fn create_secure_dir(path: &Path) -> Result<(), StateError> {
207    std::fs::create_dir_all(path)
208        .map_err(|source| StateError::Create { path: path.to_path_buf(), source })?;
209    set_secure_perms(path)
210}
211
212#[cfg(unix)]
213fn set_secure_perms(path: &Path) -> Result<(), StateError> {
214    use std::os::unix::fs::PermissionsExt;
215    let perms = std::fs::Permissions::from_mode(0o700);
216    std::fs::set_permissions(path, perms)
217        .map_err(|source| StateError::Permissions { path: path.to_path_buf(), source })
218}
219
220#[cfg(not(unix))]
221fn set_secure_perms(_path: &Path) -> Result<(), StateError> {
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn tmp_root() -> tempfile::TempDir {
230        tempfile::tempdir().expect("tempdir")
231    }
232
233    #[test]
234    fn ensure_creates_all_subdirs() {
235        let tmp = tmp_root();
236        let sd = StateDir::at(tmp.path().join("nyx-agent"));
237        sd.ensure().expect("ensure once");
238        for sub in SUBDIRS {
239            let p = sd.root().join(sub);
240            assert!(p.is_dir(), "{} should exist", p.display());
241        }
242    }
243
244    #[test]
245    fn ensure_is_idempotent() {
246        let tmp = tmp_root();
247        let sd = StateDir::at(tmp.path().join("nyx-agent"));
248        sd.ensure().expect("first");
249        sd.ensure().expect("second");
250        sd.ensure().expect("third");
251    }
252
253    #[cfg(unix)]
254    #[test]
255    fn ensure_sets_0700_on_unix() {
256        use std::os::unix::fs::PermissionsExt;
257        let tmp = tmp_root();
258        let sd = StateDir::at(tmp.path().join("nyx-agent"));
259        sd.ensure().expect("ensure");
260        for p in std::iter::once(sd.root().to_path_buf())
261            .chain(SUBDIRS.iter().map(|s| sd.root().join(s)))
262        {
263            let mode = std::fs::metadata(&p).expect("meta").permissions().mode() & 0o777;
264            assert_eq!(mode, 0o700, "{} mode {:o}", p.display(), mode);
265        }
266    }
267
268    #[test]
269    fn paths_match_layout() {
270        let sd = StateDir::at("/var/state");
271        assert_eq!(sd.runs(), Path::new("/var/state/runs"));
272        assert_eq!(sd.repos(), Path::new("/var/state/repos"));
273        assert_eq!(sd.project_repos("acme"), Path::new("/var/state/projects/acme/repos"));
274        assert_eq!(sd.findings(), Path::new("/var/state/findings"));
275        assert_eq!(sd.logs(), Path::new("/var/state/logs"));
276        assert_eq!(sd.cache(), Path::new("/var/state/cache"));
277        assert_eq!(sd.bundles(), Path::new("/var/state/bundles"));
278        assert_eq!(sd.traces(), Path::new("/var/state/traces"));
279        assert_eq!(sd.traces_for_run("run-abc"), Path::new("/var/state/traces/run-abc"));
280        assert_eq!(sd.secrets(), Path::new("/var/state/secrets"));
281        assert_eq!(sd.secrets_test_env_path(), Path::new("/var/state/secrets/test.env"));
282        assert_eq!(
283            sd.secrets_test_env_allow_path(),
284            Path::new("/var/state/secrets/test.env.allow"),
285        );
286        assert_eq!(sd.auth_token_path(), Path::new("/var/state/auth_token"));
287    }
288
289    #[cfg(unix)]
290    #[test]
291    fn ensure_creates_secrets_dir_at_0700() {
292        use std::os::unix::fs::PermissionsExt;
293        let tmp = tmp_root();
294        let sd = StateDir::at(tmp.path().join("nyx-agent"));
295        sd.ensure().expect("ensure");
296        let secrets = sd.secrets();
297        assert!(secrets.is_dir(), "secrets dir should exist at {}", secrets.display());
298        let mode = std::fs::metadata(&secrets).unwrap().permissions().mode() & 0o777;
299        assert_eq!(mode, 0o700, "secrets dir mode {mode:o}");
300    }
301
302    #[test]
303    fn load_or_mint_auth_token_persists_idempotently() {
304        let tmp = tmp_root();
305        let sd = StateDir::at(tmp.path().join("nyx-agent"));
306        sd.ensure().expect("ensure");
307        let first = sd.load_or_mint_auth_token().expect("mint first");
308        assert_eq!(first.len(), 64, "32 random bytes -> 64 hex chars");
309        let second = sd.load_or_mint_auth_token().expect("mint second");
310        assert_eq!(first, second, "second call must return the persisted token");
311        let raw = std::fs::read_to_string(sd.auth_token_path()).expect("read file");
312        assert_eq!(raw.trim(), first);
313    }
314
315    #[cfg(unix)]
316    #[test]
317    fn auth_token_file_is_0600() {
318        use std::os::unix::fs::PermissionsExt;
319        let tmp = tmp_root();
320        let sd = StateDir::at(tmp.path().join("nyx-agent"));
321        sd.ensure().expect("ensure");
322        sd.load_or_mint_auth_token().expect("mint");
323        let mode = std::fs::metadata(sd.auth_token_path()).unwrap().permissions().mode() & 0o777;
324        assert_eq!(mode, 0o600, "token file mode {mode:o}");
325    }
326}