Skip to main content

gaze_proxy/
daemon.rs

1use std::ffi::OsString;
2use std::fs::{self, File, OpenOptions};
3use std::io::Read;
4use std::net::SocketAddr;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, Stdio};
7use std::time::{Duration, Instant};
8
9use chrono::{DateTime, Utc};
10use fs2::FileExt;
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::error::ProxyError;
15
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct DaemonPaths {
19    pub pidfile: PathBuf,
20    pub config: PathBuf,
21    pub log_file: PathBuf,
22    pub stderr_file: PathBuf,
23}
24
25impl DaemonPaths {
26    pub fn new(pidfile: PathBuf, config: PathBuf, log_file: PathBuf, stderr_file: PathBuf) -> Self {
27        Self {
28            pidfile,
29            config,
30            log_file,
31            stderr_file,
32        }
33    }
34
35    pub fn resolve() -> Result<Self, ProxyError> {
36        let data_dir = dirs::data_local_dir()
37            .ok_or_else(|| ProxyError::DaemonConfig {
38                detail: "could not resolve local data directory".to_string(),
39            })?
40            .join("gaze");
41        let config_dir = dirs::config_dir()
42            .ok_or_else(|| ProxyError::DaemonConfig {
43                detail: "could not resolve config directory".to_string(),
44            })?
45            .join("gaze");
46        let log_dir = if cfg!(target_os = "macos") {
47            dirs::home_dir()
48                .ok_or_else(|| ProxyError::DaemonConfig {
49                    detail: "could not resolve home directory".to_string(),
50                })?
51                .join("Library/Logs/gaze")
52        } else {
53            data_dir.join("Logs")
54        };
55        Ok(Self {
56            pidfile: data_dir.join("proxy.pid"),
57            config: config_dir.join("proxy.toml"),
58            log_file: log_dir.join("proxy.log"),
59            stderr_file: log_dir.join("proxy-stderr.log"),
60        })
61    }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[non_exhaustive]
66pub struct DaemonConfig {
67    pub bind: SocketAddr,
68    pub session_ttl: String,
69    pub policy: Option<PathBuf>,
70    pub rulepack: Option<String>,
71    pub adapters: AdapterConfig,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[non_exhaustive]
76pub struct AdapterConfig {
77    pub openai: ProviderConfig,
78    pub anthropic: ProviderConfig,
79    pub gemini: ProviderConfig,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct ProviderConfig {
85    pub upstream: Url,
86}
87
88impl ProviderConfig {
89    pub fn new(upstream: Url) -> Self {
90        Self { upstream }
91    }
92}
93
94impl AdapterConfig {
95    pub fn new(openai: Url, anthropic: Url, gemini: Url) -> Self {
96        Self {
97            openai: ProviderConfig::new(openai),
98            anthropic: ProviderConfig::new(anthropic),
99            gemini: ProviderConfig::new(gemini),
100        }
101    }
102}
103
104impl Default for AdapterConfig {
105    fn default() -> Self {
106        Self {
107            openai: ProviderConfig {
108                upstream: Url::parse("https://api.openai.com").expect("static url"),
109            },
110            anthropic: ProviderConfig {
111                upstream: Url::parse("https://api.anthropic.com").expect("static url"),
112            },
113            gemini: ProviderConfig {
114                upstream: Url::parse("https://generativelanguage.googleapis.com")
115                    .expect("static url"),
116            },
117        }
118    }
119}
120
121impl Default for DaemonConfig {
122    fn default() -> Self {
123        Self {
124            bind: "127.0.0.1:8787".parse().expect("static bind addr"),
125            session_ttl: "30m".to_string(),
126            policy: None,
127            rulepack: Some("core".to_string()),
128            adapters: AdapterConfig::default(),
129        }
130    }
131}
132
133#[derive(Debug, Clone)]
134#[non_exhaustive]
135pub struct StartOptions {
136    pub paths: DaemonPaths,
137    pub config: DaemonConfig,
138    pub extra_args: Vec<String>,
139}
140
141impl StartOptions {
142    pub fn new(paths: DaemonPaths, config: DaemonConfig) -> Self {
143        Self {
144            paths,
145            config,
146            extra_args: Vec::new(),
147        }
148    }
149
150    pub fn with_extra_args(mut self, extra_args: Vec<String>) -> Self {
151        self.extra_args = extra_args;
152        self
153    }
154}
155
156#[derive(Debug, Clone)]
157#[non_exhaustive]
158pub struct StopOptions {
159    pub paths: DaemonPaths,
160    pub timeout: Duration,
161    pub force: bool,
162}
163
164impl StopOptions {
165    pub fn new(paths: DaemonPaths, timeout: Duration, force: bool) -> Self {
166        Self {
167            paths,
168            timeout,
169            force,
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175#[non_exhaustive]
176pub struct DaemonStatus {
177    pub pid: u32,
178    pub bind: Option<String>,
179    pub started_at: Option<String>,
180    pub running: bool,
181}
182
183pub fn read_or_default_config(paths: &DaemonPaths) -> Result<DaemonConfig, ProxyError> {
184    if !paths.config.exists() {
185        return Ok(DaemonConfig::default());
186    }
187    let text = fs::read_to_string(&paths.config).map_err(|source| ProxyError::DaemonIo {
188        path: paths.config.clone(),
189        source,
190    })?;
191    toml::from_str(&text).map_err(|err| ProxyError::DaemonConfig {
192        detail: err.to_string(),
193    })
194}
195
196pub fn write_config(paths: &DaemonPaths, config: &DaemonConfig) -> Result<(), ProxyError> {
197    if let Some(parent) = paths.config.parent() {
198        fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
199            path: parent.to_path_buf(),
200            source,
201        })?;
202    }
203    let text = toml::to_string_pretty(config).map_err(|err| ProxyError::DaemonConfig {
204        detail: err.to_string(),
205    })?;
206    fs::write(&paths.config, text).map_err(|source| ProxyError::DaemonIo {
207        path: paths.config.clone(),
208        source,
209    })
210}
211
212/// Rebuilds the daemon's configured surface as `gaze proxy serve` flags.
213///
214/// The persisted [`DaemonConfig`] is the single source of truth for a detached
215/// proxy: `start` and `restart` write it, and the child is handed nothing but
216/// what this derives from it, so `serve` keeps exactly one policy input whether
217/// it runs in the foreground or as the daemon child. The exhaustive
218/// destructuring is load-bearing — a new `DaemonConfig` field stops compiling
219/// here instead of silently never reaching the daemon, which is how `--policy`,
220/// `--rulepack`, and the adapter upstreams went missing (solo todo #2965).
221fn serve_args(config: &DaemonConfig) -> Vec<OsString> {
222    let DaemonConfig {
223        bind,
224        session_ttl,
225        policy,
226        rulepack,
227        adapters,
228    } = config;
229    let AdapterConfig {
230        openai,
231        anthropic,
232        gemini,
233    } = adapters;
234
235    let mut args: Vec<OsString> = vec![
236        "--bind".into(),
237        bind.to_string().into(),
238        "--session-ttl".into(),
239        session_ttl.into(),
240    ];
241    // The policy path travels, not its resolved contents: the child loads it
242    // through the same sequence as `gaze clean`, and a path that has moved since
243    // `start` fails the child closed instead of quietly demoting the chokepoint
244    // to the bundled floor. Serializing resolved inputs into the config would
245    // make that file a second, staleable copy of the policy.
246    if let Some(policy) = policy {
247        args.push("--policy".into());
248        args.push(policy.into());
249    }
250    if let Some(rulepack) = rulepack {
251        args.push("--rulepack".into());
252        args.push(rulepack.into());
253    }
254    for (flag, ProviderConfig { upstream }) in [
255        ("--upstream-openai", openai),
256        ("--upstream-anthropic", anthropic),
257        ("--upstream-gemini", gemini),
258    ] {
259        args.push(flag.into());
260        args.push(upstream.to_string().into());
261    }
262    args
263}
264
265pub fn start(options: StartOptions) -> Result<u32, ProxyError> {
266    cleanup_stale(&options.paths)?;
267    if let Some(status) = status(&options.paths)? {
268        if status.running {
269            return Err(ProxyError::DaemonAlreadyRunning {
270                pid: status.pid,
271                pidfile: options.paths.pidfile,
272            });
273        }
274    }
275    write_config(&options.paths, &options.config)?;
276    create_parent(&options.paths.pidfile)?;
277    create_parent(&options.paths.log_file)?;
278    let lock = lock_pidfile(&options.paths)?;
279
280    let stdout = OpenOptions::new()
281        .create(true)
282        .append(true)
283        .open(&options.paths.log_file)
284        .map_err(|source| ProxyError::DaemonIo {
285            path: options.paths.log_file.clone(),
286            source,
287        })?;
288    let stderr = OpenOptions::new()
289        .create(true)
290        .append(true)
291        .open(&options.paths.stderr_file)
292        .map_err(|source| ProxyError::DaemonIo {
293            path: options.paths.stderr_file.clone(),
294            source,
295        })?;
296    let mut command =
297        Command::new(
298            std::env::current_exe().map_err(|source| ProxyError::DaemonIo {
299                path: PathBuf::from("current_exe"),
300                source,
301            })?,
302        );
303    command
304        .args(["proxy", "serve", "--_foreground-daemon"])
305        .args(serve_args(&options.config))
306        .args(options.extra_args)
307        .stdin(Stdio::null())
308        .stdout(Stdio::from(stdout))
309        .stderr(Stdio::from(stderr));
310    drop(lock);
311    let mut child = command.spawn().map_err(|source| ProxyError::DaemonIo {
312        path: PathBuf::from("gaze proxy serve"),
313        source,
314    })?;
315    confirm_started(&mut child, &options.paths)
316}
317
318/// Confirms the spawned child is still alive after a short probe window and
319/// returns its pid.
320///
321/// The child resolves the configured policy, so a fail-closed load error
322/// surfaces as an immediate exit. `kill(pid, 0)` cannot see that: an unreaped
323/// child is a zombie and still answers as alive, which reported a dead daemon as
324/// started (solo todo #2965). `try_wait` reaps and reports the real status
325/// within the same 250ms budget, returning early on failure.
326///
327/// A failure slower than the probe window still reports success; that daemon is
328/// dead rather than serving unprotected, and `gaze proxy status` shows it.
329fn confirm_started(child: &mut Child, paths: &DaemonPaths) -> Result<u32, ProxyError> {
330    let deadline = Instant::now() + Duration::from_millis(250);
331    loop {
332        match child.try_wait() {
333            Ok(Some(status)) => {
334                // The parent created this pidfile before spawning and the child
335                // never filled it in; leaving the empty file behind would fail
336                // every later start as stale.
337                let _ = fs::remove_file(&paths.pidfile);
338                return Err(ProxyError::DaemonExitedEarly {
339                    code: status.code(),
340                    stderr_file: paths.stderr_file.clone(),
341                });
342            }
343            Ok(None) if Instant::now() >= deadline => return Ok(child.id()),
344            Ok(None) => std::thread::sleep(Duration::from_millis(25)),
345            Err(source) => {
346                return Err(ProxyError::DaemonIo {
347                    path: PathBuf::from("gaze proxy serve"),
348                    source,
349                })
350            }
351        }
352    }
353}
354
355pub fn init_foreground_daemon(paths: &DaemonPaths, bind: SocketAddr) -> Result<(), ProxyError> {
356    create_parent(&paths.pidfile)?;
357    create_parent(&paths.log_file)?;
358    let _lock = lock_pidfile(paths)?;
359    write_pidfile(&paths.pidfile, std::process::id(), bind, Utc::now())
360}
361
362pub fn stop(options: StopOptions) -> Result<(), ProxyError> {
363    cleanup_stale(&options.paths)?;
364    let Some(status) = status(&options.paths)? else {
365        return Err(ProxyError::DaemonNotRunning);
366    };
367    terminate(status.pid, false)?;
368    let started = Instant::now();
369    while started.elapsed() < options.timeout {
370        if !process_exists(status.pid) {
371            let _ = fs::remove_file(&options.paths.pidfile);
372            return Ok(());
373        }
374        std::thread::sleep(Duration::from_millis(100));
375    }
376    if options.force {
377        terminate(status.pid, true)?;
378        let _ = fs::remove_file(&options.paths.pidfile);
379        Ok(())
380    } else {
381        Err(ProxyError::DaemonAlreadyRunning {
382            pid: status.pid,
383            pidfile: options.paths.pidfile,
384        })
385    }
386}
387
388pub fn restart(options: StartOptions, timeout: Duration, force: bool) -> Result<u32, ProxyError> {
389    let stop_result = stop(StopOptions {
390        paths: options.paths.clone(),
391        timeout,
392        force,
393    });
394    if !matches!(stop_result, Ok(()) | Err(ProxyError::DaemonNotRunning)) {
395        cleanup_stale(&options.paths)?;
396    }
397    start(options)
398}
399
400pub fn status(paths: &DaemonPaths) -> Result<Option<DaemonStatus>, ProxyError> {
401    if !paths.pidfile.exists() {
402        return Ok(None);
403    }
404    let record = read_pidfile(&paths.pidfile)?;
405    Ok(Some(DaemonStatus {
406        running: process_exists(record.pid),
407        pid: record.pid,
408        bind: record.bind,
409        started_at: record.started_at,
410    }))
411}
412
413pub fn cleanup_stale(paths: &DaemonPaths) -> Result<(), ProxyError> {
414    if let Some(status) = status(paths)? {
415        if !status.running {
416            fs::remove_file(&paths.pidfile).map_err(|source| ProxyError::DaemonIo {
417                path: paths.pidfile.clone(),
418                source,
419            })?;
420        }
421    }
422    Ok(())
423}
424
425pub fn logs(paths: &DaemonPaths, follow: bool) -> Result<(), ProxyError> {
426    if follow {
427        Command::new("tail")
428            .arg("-F")
429            .arg(&paths.log_file)
430            .status()
431            .map_err(|source| ProxyError::DaemonIo {
432                path: paths.log_file.clone(),
433                source,
434            })?;
435    } else if paths.log_file.exists() {
436        let text = fs::read_to_string(&paths.log_file).map_err(|source| ProxyError::DaemonIo {
437            path: paths.log_file.clone(),
438            source,
439        })?;
440        print!("{text}");
441    }
442    Ok(())
443}
444
445fn create_parent(path: &Path) -> Result<(), ProxyError> {
446    if let Some(parent) = path.parent() {
447        fs::create_dir_all(parent).map_err(|source| ProxyError::DaemonIo {
448            path: parent.to_path_buf(),
449            source,
450        })?;
451    }
452    Ok(())
453}
454
455fn lock_pidfile(paths: &DaemonPaths) -> Result<File, ProxyError> {
456    let file = OpenOptions::new()
457        .create(true)
458        .truncate(false)
459        .read(true)
460        .write(true)
461        .open(&paths.pidfile)
462        .map_err(|source| ProxyError::DaemonIo {
463            path: paths.pidfile.clone(),
464            source,
465        })?;
466    file.try_lock_exclusive()
467        .map_err(|source| ProxyError::DaemonIo {
468            path: paths.pidfile.clone(),
469            source,
470        })?;
471    Ok(file)
472}
473
474fn write_pidfile(
475    path: &Path,
476    pid: u32,
477    bind: SocketAddr,
478    started_at: DateTime<Utc>,
479) -> Result<(), ProxyError> {
480    let contents = format!(
481        "{pid}\nbind={bind}\nstarted_at={}\n",
482        started_at.to_rfc3339()
483    );
484    if contents.len() > 200 {
485        return Err(ProxyError::DaemonConfig {
486            detail: "pidfile content exceeds 200 bytes".to_string(),
487        });
488    }
489    fs::write(path, contents).map_err(|source| ProxyError::DaemonIo {
490        path: path.to_path_buf(),
491        source,
492    })
493}
494
495struct PidRecord {
496    pid: u32,
497    bind: Option<String>,
498    started_at: Option<String>,
499}
500
501fn read_pidfile(path: &Path) -> Result<PidRecord, ProxyError> {
502    let mut text = String::new();
503    File::open(path)
504        .and_then(|mut file| file.read_to_string(&mut text))
505        .map_err(|source| ProxyError::DaemonIo {
506            path: path.to_path_buf(),
507            source,
508        })?;
509    let mut lines = text.lines();
510    let pid = lines
511        .next()
512        .ok_or_else(|| ProxyError::DaemonPidfileStale {
513            pidfile: path.to_path_buf(),
514        })?
515        .parse::<u32>()
516        .map_err(|_| ProxyError::DaemonPidfileStale {
517            pidfile: path.to_path_buf(),
518        })?;
519    let mut bind = None;
520    let mut started_at = None;
521    for line in lines {
522        if let Some(rest) = line.strip_prefix("bind=") {
523            bind = Some(rest.to_string());
524        } else if let Some(rest) = line.strip_prefix("started_at=") {
525            started_at = Some(rest.to_string());
526        }
527    }
528    Ok(PidRecord {
529        pid,
530        bind,
531        started_at,
532    })
533}
534
535#[cfg(unix)]
536fn process_exists(pid: u32) -> bool {
537    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
538}
539
540#[cfg(not(unix))]
541fn process_exists(_pid: u32) -> bool {
542    false
543}
544
545#[cfg(unix)]
546fn terminate(pid: u32, force: bool) -> Result<(), ProxyError> {
547    let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
548    let rc = unsafe { libc::kill(pid as libc::pid_t, signal) };
549    if rc == 0 {
550        Ok(())
551    } else {
552        Err(ProxyError::DaemonNotRunning)
553    }
554}
555
556#[cfg(not(unix))]
557fn terminate(_pid: u32, _force: bool) -> Result<(), ProxyError> {
558    Err(ProxyError::DaemonConfig {
559        detail: "daemon stop is not implemented on this platform".to_string(),
560    })
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn temp_paths(dir: &tempfile::TempDir) -> DaemonPaths {
568        DaemonPaths {
569            pidfile: dir.path().join("proxy.pid"),
570            config: dir.path().join("proxy.toml"),
571            log_file: dir.path().join("proxy.log"),
572            stderr_file: dir.path().join("proxy-stderr.log"),
573        }
574    }
575
576    #[test]
577    fn pidfile_round_trip_includes_bind_and_started_at() {
578        let dir = tempfile::tempdir().unwrap();
579        let paths = temp_paths(&dir);
580        write_pidfile(
581            &paths.pidfile,
582            123,
583            "127.0.0.1:8787".parse().unwrap(),
584            DateTime::parse_from_rfc3339("2026-05-14T00:00:00Z")
585                .unwrap()
586                .with_timezone(&Utc),
587        )
588        .unwrap();
589        let status = status(&paths).unwrap().unwrap();
590        assert_eq!(status.pid, 123);
591        assert_eq!(status.bind.as_deref(), Some("127.0.0.1:8787"));
592        assert!(!status.running);
593    }
594
595    #[test]
596    fn cleanup_stale_unlinks_dead_pidfile() {
597        let dir = tempfile::tempdir().unwrap();
598        let paths = temp_paths(&dir);
599        write_pidfile(
600            &paths.pidfile,
601            999_999,
602            "127.0.0.1:8787".parse().unwrap(),
603            Utc::now(),
604        )
605        .unwrap();
606        cleanup_stale(&paths).unwrap();
607        assert!(!paths.pidfile.exists());
608    }
609
610    // solo todo #2965: every field the adopter can configure has to reach the
611    // detached child, because the child's argv is all it ever sees.
612    #[test]
613    fn serve_args_forward_every_configured_field_to_the_daemon_child() {
614        let config = DaemonConfig {
615            policy: Some(PathBuf::from("/etc/gaze/prod.toml")),
616            rulepack: Some("core-extended".to_string()),
617            adapters: AdapterConfig::new(
618                Url::parse("http://127.0.0.1:4001").unwrap(),
619                Url::parse("http://127.0.0.1:4002").unwrap(),
620                Url::parse("http://127.0.0.1:4003").unwrap(),
621            ),
622            ..Default::default()
623        };
624
625        assert_eq!(
626            serve_args(&config),
627            [
628                "--bind",
629                "127.0.0.1:8787",
630                "--session-ttl",
631                "30m",
632                "--policy",
633                "/etc/gaze/prod.toml",
634                "--rulepack",
635                "core-extended",
636                "--upstream-openai",
637                "http://127.0.0.1:4001/",
638                "--upstream-anthropic",
639                "http://127.0.0.1:4002/",
640                "--upstream-gemini",
641                "http://127.0.0.1:4003/",
642            ]
643            .map(OsString::from)
644        );
645    }
646
647    #[test]
648    fn serve_args_omit_policy_when_the_daemon_has_none() {
649        let args = serve_args(&DaemonConfig::default());
650
651        assert!(!args.contains(&OsString::from("--policy")));
652        assert!(args
653            .windows(2)
654            .any(|pair| pair == ["--rulepack", "core"].map(OsString::from)));
655    }
656
657    #[test]
658    fn config_round_trip() {
659        let dir = tempfile::tempdir().unwrap();
660        let paths = temp_paths(&dir);
661        let config = DaemonConfig::default();
662        write_config(&paths, &config).unwrap();
663        let loaded = read_or_default_config(&paths).unwrap();
664        assert_eq!(loaded.bind, config.bind);
665        assert_eq!(
666            loaded.adapters.openai.upstream,
667            config.adapters.openai.upstream
668        );
669    }
670}