diff --git a/Cargo.lock b/Cargo.lock index e19c5c9..b6b9ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,9 +498,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -508,9 +508,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstream", "anstyle", @@ -3790,9 +3790,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.1+spec-1.1.0" +version = "1.0.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220" +checksum = "d1dfefef6a142e93f346b64c160934eb13b5594b84ab378133ac6815cb2bd57f" dependencies = [ "indexmap", "serde_core", @@ -3835,9 +3835,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.8+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -4929,6 +4929,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest", + "ring", "rppal", "rusqlite", "rustls", diff --git a/Dockerfile b/Dockerfile index 693e4de..6c38785 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,7 +90,7 @@ WORKDIR /zeroclaw-data USER 65534:65534 EXPOSE 3000 ENTRYPOINT ["zeroclaw"] -CMD ["gateway", "--port", "3000", "--host", "[::]"] +CMD ["gateway"] # ── Stage 4: Production Runtime (Distroless) ───────────────── FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7 AS release @@ -112,4 +112,4 @@ WORKDIR /zeroclaw-data USER 65534:65534 EXPOSE 3000 ENTRYPOINT ["zeroclaw"] -CMD ["gateway", "--port", "3000", "--host", "[::]"] +CMD ["gateway"] diff --git a/docker-compose.yml b/docker-compose.yml index 3e85171..d60274d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,14 @@ # # Quick start: # 1. Copy this file and set your API key -# 2. Run: docker-compose up -d +# 2. Run: docker compose up -d # 3. Access gateway at http://localhost:3000 # -# For more info: https://github.com/theonlyhennygod/zeroclaw +# For more info: https://github.com/zeroclaw-labs/zeroclaw services: zeroclaw: - image: ghcr.io/theonlyhennygod/zeroclaw:latest + image: ghcr.io/zeroclaw-labs/zeroclaw:latest # Or build locally: # build: . container_name: zeroclaw diff --git a/src/config/schema.rs b/src/config/schema.rs index 41e556d..0acec6e 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -2003,8 +2003,8 @@ impl Config { } } - // Model: ZEROCLAW_MODEL - if let Ok(model) = std::env::var("ZEROCLAW_MODEL") { + // Model: ZEROCLAW_MODEL or MODEL + if let Ok(model) = std::env::var("ZEROCLAW_MODEL").or_else(|_| std::env::var("MODEL")) { if !model.is_empty() { self.default_model = Some(model); } @@ -3292,6 +3292,22 @@ default_temperature = 0.7 std::env::remove_var("ZEROCLAW_MODEL"); } + #[test] + fn env_override_model_fallback() { + let _env_guard = env_override_test_guard(); + let mut config = Config::default(); + + std::env::remove_var("ZEROCLAW_MODEL"); + std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet"); + config.apply_env_overrides(); + assert_eq!( + config.default_model.as_deref(), + Some("anthropic/claude-3.5-sonnet") + ); + + std::env::remove_var("MODEL"); + } + #[test] fn env_override_workspace() { let _env_guard = env_override_test_guard(); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 988b780..3eb795e 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -16,7 +16,7 @@ use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard}; use crate::security::SecurityPolicy; use crate::tools; use crate::util::truncate_with_ellipsis; -use anyhow::Result; +use anyhow::{Context, Result}; use axum::{ body::Bytes, extract::{Query, State}, @@ -176,6 +176,7 @@ fn client_key_from_headers(headers: &HeaderMap) -> String { /// Shared state for all axum handlers #[derive(Clone)] pub struct AppState { + pub config: Arc>, pub provider: Arc, pub model: String, pub temperature: f64, @@ -203,6 +204,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { [gateway] allow_public_bind = true in config.toml (NOT recommended)." ); } + let config_state = Arc::new(Mutex::new(config.clone())); let addr: SocketAddr = format!("{host}:{port}").parse()?; let listener = tokio::net::TcpListener::bind(addr).await?; @@ -355,6 +357,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { // Build shared state let state = AppState { + config: config_state, provider, model, temperature, @@ -422,8 +425,20 @@ async fn handle_pair(State(state): State, headers: HeaderMap) -> impl match state.pairing.try_pair(code) { Ok(Some(token)) => { tracing::info!("🔐 New client paired successfully"); + if let Err(err) = persist_pairing_tokens(&state.config, &state.pairing) { + tracing::error!("🔐 Pairing succeeded but token persistence failed: {err:#}"); + let body = serde_json::json!({ + "paired": true, + "persisted": false, + "token": token, + "message": "Paired for this process, but failed to persist token to config.toml. Check config path and write permissions.", + }); + return (StatusCode::OK, Json(body)); + } + let body = serde_json::json!({ "paired": true, + "persisted": true, "token": token, "message": "Save this token — use it as Authorization: Bearer " }); @@ -447,6 +462,14 @@ async fn handle_pair(State(state): State, headers: HeaderMap) -> impl } } +fn persist_pairing_tokens(config: &Arc>, pairing: &PairingGuard) -> Result<()> { + let paired_tokens = pairing.tokens(); + let mut cfg = config.lock(); + cfg.gateway.paired_tokens = paired_tokens; + cfg.save() + .context("Failed to persist paired tokens to config.toml") +} + /// Webhook request body #[derive(serde::Deserialize)] pub struct WebhookBody { @@ -836,6 +859,33 @@ mod tests { assert!(store.record_if_new("req-2")); } + #[test] + fn persist_pairing_tokens_writes_config_tokens() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("config.toml"); + let workspace_path = temp.path().join("workspace"); + + let mut config = Config::default(); + config.config_path = config_path.clone(); + config.workspace_dir = workspace_path; + config.save().unwrap(); + + let guard = PairingGuard::new(true, &[]); + let code = guard.pairing_code().unwrap(); + let token = guard.try_pair(&code).unwrap().unwrap(); + assert!(guard.is_authenticated(&token)); + + let shared_config = Arc::new(Mutex::new(config)); + persist_pairing_tokens(&shared_config, &guard).unwrap(); + + let saved = std::fs::read_to_string(config_path).unwrap(); + let parsed: Config = toml::from_str(&saved).unwrap(); + assert_eq!(parsed.gateway.paired_tokens.len(), 1); + let persisted = &parsed.gateway.paired_tokens[0]; + assert_eq!(persisted.len(), 64); + assert!(persisted.chars().all(|c| c.is_ascii_hexdigit())); + } + #[test] fn webhook_memory_key_is_unique() { let key1 = webhook_memory_key(); @@ -997,6 +1047,7 @@ mod tests { let memory: Arc = Arc::new(MockMemory); let state = AppState { + config: Arc::new(Mutex::new(Config::default())), provider, model: "test-model".into(), temperature: 0.0, @@ -1045,6 +1096,7 @@ mod tests { let memory: Arc = tracking_impl.clone(); let state = AppState { + config: Arc::new(Mutex::new(Config::default())), provider, model: "test-model".into(), temperature: 0.0, @@ -1102,6 +1154,7 @@ mod tests { let memory: Arc = Arc::new(MockMemory); let state = AppState { + config: Arc::new(Mutex::new(Config::default())), provider, model: "test-model".into(), temperature: 0.0, @@ -1136,6 +1189,7 @@ mod tests { let memory: Arc = Arc::new(MockMemory); let state = AppState { + config: Arc::new(Mutex::new(Config::default())), provider, model: "test-model".into(), temperature: 0.0, @@ -1173,6 +1227,7 @@ mod tests { let memory: Arc = Arc::new(MockMemory); let state = AppState { + config: Arc::new(Mutex::new(Config::default())), provider, model: "test-model".into(), temperature: 0.0, diff --git a/src/service/mod.rs b/src/service/mod.rs index 287f446..c9907d7 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -115,9 +115,8 @@ fn status(config: &Config) -> Result<()> { if cfg!(target_os = "windows") { let _ = config; let task_name = windows_task_name(); - let out = run_capture( - Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"]), - ); + let out = + run_capture(Command::new("schtasks").args(["/Query", "/TN", task_name, "/FO", "LIST"])); match out { Ok(text) => { let running = text.contains("Running"); @@ -167,9 +166,7 @@ fn uninstall(config: &Config) -> Result<()> { if cfg!(target_os = "windows") { let task_name = windows_task_name(); - let _ = run_checked( - Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"]), - ); + let _ = run_checked(Command::new("schtasks").args(["/Delete", "/TN", task_name, "/F"])); // Remove the wrapper script let wrapper = config .config_path @@ -288,20 +285,18 @@ fn install_windows(config: &Config) -> Result<()> { .args(["/Delete", "/TN", task_name, "/F"]) .output(); - run_checked( - Command::new("schtasks").args([ - "/Create", - "/TN", - task_name, - "/SC", - "ONLOGON", - "/TR", - &format!("\"{}\"", wrapper.display()), - "/RL", - "HIGHEST", - "/F", - ]), - )?; + run_checked(Command::new("schtasks").args([ + "/Create", + "/TN", + task_name, + "/SC", + "ONLOGON", + "/TR", + &format!("\"{}\"", wrapper.display()), + "/RL", + "HIGHEST", + "/F", + ]))?; println!("✅ Installed Windows scheduled task: {}", task_name); println!(" Wrapper: {}", wrapper.display());