Skip to main content

zagens_runtime/runtime_serve/
mod.rs

1//! HTTP sidecar entry for Zagens and headless hosts (D6 `runtime-server`).
2
3mod http;
4
5use std::path::PathBuf;
6
7use anyhow::{Context, Result};
8use clap::Parser;
9
10use crate::cli::configure_windows_console_utf8;
11use crate::config::Config;
12
13pub use http::{RuntimeApiOptions, run_http_server};
14
15/// CLI for the `zagens-runtime` sidecar binary (HTTP only — no ratatui / full CLI).
16#[derive(Parser, Debug)]
17#[command(
18    name = "zagens-runtime",
19    about = "DeepSeek runtime HTTP/SSE sidecar (Zagens desktop; no TUI)",
20    version
21)]
22pub struct RuntimeServeCli {
23    /// Config file path (default: ~/.zagens/config.toml)
24    #[arg(short, long)]
25    pub config: Option<PathBuf>,
26    /// Config profile name
27    #[arg(long)]
28    pub profile: Option<String>,
29    /// Workspace root for tool execution
30    #[arg(short, long)]
31    pub workspace: Option<PathBuf>,
32    /// Bind host (default localhost)
33    #[arg(long, default_value = "127.0.0.1")]
34    pub host: String,
35    /// Bind port (`0` = ephemeral)
36    #[arg(long, default_value_t = 7878)]
37    pub port: u16,
38    /// Background task worker count (1-16)
39    #[arg(long, default_value_t = 8)]
40    pub workers: usize,
41    /// Additional CORS origin (repeatable)
42    #[arg(long = "cors-origin", value_name = "URL")]
43    pub cors_origin: Vec<String>,
44    /// Bearer token for `/v1/*` (also reads `DEEPSEEK_RUNTIME_TOKEN`)
45    #[arg(long = "auth-token", value_name = "TOKEN")]
46    pub auth_token: Option<String>,
47    /// Verbose logging
48    #[arg(short, long)]
49    pub verbose: bool,
50}
51
52/// Resolve additional CORS origins from flags, env, and config.
53pub fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
54    let mut out: Vec<String> = Vec::new();
55    let mut push = |raw: &str| {
56        let trimmed = raw.trim();
57        if trimmed.is_empty() {
58            return;
59        }
60        if !out.iter().any(|existing| existing == trimmed) {
61            out.push(trimmed.to_string());
62        }
63    };
64    for o in flag_origins {
65        push(o);
66    }
67    if let Ok(env_value) = std::env::var("DEEPSEEK_CORS_ORIGINS") {
68        for piece in env_value.split(',') {
69            push(piece);
70        }
71    }
72    if let Some(rt) = &config.runtime_api
73        && let Some(list) = &rt.cors_origins
74    {
75        for o in list {
76            push(o);
77        }
78    }
79    out
80}
81
82fn load_config(cli: &RuntimeServeCli) -> Result<Config> {
83    let profile = cli
84        .profile
85        .clone()
86        .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok());
87    Config::load(cli.config.clone(), profile.as_deref()).context("load config")
88}
89
90fn resolve_workspace(cli: &RuntimeServeCli) -> PathBuf {
91    if let Some(ws) = cli.workspace.clone() {
92        return ws;
93    }
94    // Sidecar spawn cwd is often `$HOME` / `%USERPROFILE%` — never treat that as the tool workspace.
95    if let Some(docs) = dirs::document_dir() {
96        let zagens = docs.join("Zagens");
97        if zagens.is_dir() {
98            return zagens;
99        }
100        if std::fs::create_dir_all(&zagens).is_ok() {
101            return zagens;
102        }
103    }
104    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
105}
106
107/// Run the HTTP runtime sidecar until shutdown.
108pub async fn run(cli: RuntimeServeCli) -> Result<()> {
109    configure_windows_console_utf8();
110    crate::logging::set_verbose(cli.verbose || crate::logging::env_requests_verbose_logging());
111
112    if cli.host != "127.0.0.1" && cli.host != "localhost" {
113        eprintln!(
114            "⚠ deepseek-runtime is binding to {} (not localhost).\n\
115             The runtime API will be reachable from other machines on the network.\n\
116             Make sure you have set --auth-token (or DEEPSEEK_RUNTIME_TOKEN) and\n\
117             configured restrictive CORS origins via --cors-origin or config.toml.",
118            cli.host,
119        );
120    }
121
122    let _ = crate::config::ensure_config_file_exists(cli.config.clone());
123
124    let config = load_config(&cli)?;
125    let workspace = resolve_workspace(&cli);
126    crate::symbol_index::warmup_if_needed(&workspace);
127    let skills_dir = config.skills_dir();
128    tokio::spawn(async move {
129        if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
130            crate::logging::warn(format!("Failed to install system skills: {e}"));
131        }
132    });
133
134    let cors_origins = resolve_cors_origins(&config, &cli.cors_origin);
135    run_http_server(
136        config,
137        workspace,
138        RuntimeApiOptions {
139            host: cli.host,
140            port: cli.port,
141            workers: cli.workers.clamp(1, 16),
142            cors_origins,
143            auth_token: cli.auth_token,
144        },
145    )
146    .await
147}
148
149/// Binary `main` helper — maps clean shutdown to exit code 0.
150pub async fn run_or_exit(cli: RuntimeServeCli) -> ! {
151    match run(cli).await {
152        Ok(()) => {
153            eprintln!("[deepseek-runtime] server shut down cleanly, exiting");
154            std::process::exit(0);
155        }
156        Err(e) => {
157            eprintln!("[deepseek-runtime] fatal: {:#}", e);
158            std::process::exit(1);
159        }
160    }
161}
162
163/// Parse argv and run — used by the `deepseek-runtime` binary.
164pub async fn run_from_args<I, T>(args: I) -> !
165where
166    I: IntoIterator<Item = T>,
167    T: Into<std::ffi::OsString> + Clone,
168{
169    let cli = RuntimeServeCli::parse_from(args);
170    run_or_exit(cli).await
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn resolve_cors_dedupes() {
179        let config = Config::default();
180        let out = resolve_cors_origins(&config, &["http://a".into(), "http://a".into()]);
181        assert_eq!(out, vec!["http://a".to_string()]);
182    }
183}