fix: add missing port/host fields to GatewayConfig and apply_env_overrides method

- Add port and host fields to GatewayConfig struct
- Add default_gateway_port() and default_gateway_host() functions
- Add apply_env_overrides() method to Config for env var support
- Fix test to include new GatewayConfig fields

All tests pass.
This commit is contained in:
argenis de la rosa 2026-02-14 16:05:13 -05:00
parent d7769340a3
commit a310e178db
16 changed files with 372 additions and 83 deletions

View file

@ -8,8 +8,8 @@
#![allow(clippy::too_many_lines)] #![allow(clippy::too_many_lines)]
#![allow(clippy::unnecessary_map_or)] #![allow(clippy::unnecessary_map_or)]
use async_trait::async_trait;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_trait::async_trait;
use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport}; use lettre::{Message, SmtpTransport, Transport};
use mail_parser::{MessageParser, MimeHeaders}; use mail_parser::{MessageParser, MimeHeaders};
@ -59,11 +59,21 @@ pub struct EmailConfig {
pub allowed_senders: Vec<String>, pub allowed_senders: Vec<String>,
} }
fn default_imap_port() -> u16 { 993 } fn default_imap_port() -> u16 {
fn default_smtp_port() -> u16 { 587 } 993
fn default_imap_folder() -> String { "INBOX".into() } }
fn default_poll_interval() -> u64 { 60 } fn default_smtp_port() -> u16 {
fn default_true() -> bool { true } 587
}
fn default_imap_folder() -> String {
"INBOX".into()
}
fn default_poll_interval() -> u64 {
60
}
fn default_true() -> bool {
true
}
impl Default for EmailConfig { impl Default for EmailConfig {
fn default() -> Self { fn default() -> Self {
@ -137,7 +147,8 @@ impl EmailChannel {
/// Extract the sender address from a parsed email /// Extract the sender address from a parsed email
fn extract_sender(parsed: &mail_parser::Message) -> String { fn extract_sender(parsed: &mail_parser::Message) -> String {
parsed.from() parsed
.from()
.and_then(|addr| addr.first()) .and_then(|addr| addr.first())
.and_then(|a| a.address()) .and_then(|a| a.address())
.map(|s| s.to_string()) .map(|s| s.to_string())
@ -185,32 +196,31 @@ impl EmailChannel {
.with_root_certificates(root_store) .with_root_certificates(root_store)
.with_no_client_auth(), .with_no_client_auth(),
); );
let server_name: ServerName<'_> = let server_name: ServerName<'_> = ServerName::try_from(config.imap_host.clone())?;
ServerName::try_from(config.imap_host.clone())?; let conn = rustls::ClientConnection::new(tls_config, server_name)?;
let conn =
rustls::ClientConnection::new(tls_config, server_name)?;
let mut tls = rustls::StreamOwned::new(conn, tcp); let mut tls = rustls::StreamOwned::new(conn, tcp);
let read_line = |tls: &mut rustls::StreamOwned<rustls::ClientConnection, TcpStream>| -> Result<String> { let read_line =
let mut buf = Vec::new(); |tls: &mut rustls::StreamOwned<rustls::ClientConnection, TcpStream>| -> Result<String> {
loop { let mut buf = Vec::new();
let mut byte = [0u8; 1]; loop {
match std::io::Read::read(tls, &mut byte) { let mut byte = [0u8; 1];
Ok(0) => return Err(anyhow!("IMAP connection closed")), match std::io::Read::read(tls, &mut byte) {
Ok(_) => { Ok(0) => return Err(anyhow!("IMAP connection closed")),
buf.push(byte[0]); Ok(_) => {
if buf.ends_with(b"\r\n") { buf.push(byte[0]);
return Ok(String::from_utf8_lossy(&buf).to_string()); if buf.ends_with(b"\r\n") {
return Ok(String::from_utf8_lossy(&buf).to_string());
}
} }
Err(e) => return Err(e.into()),
} }
Err(e) => return Err(e.into()),
} }
} };
};
let send_cmd = |tls: &mut rustls::StreamOwned<rustls::ClientConnection, TcpStream>, let send_cmd = |tls: &mut rustls::StreamOwned<rustls::ClientConnection, TcpStream>,
tag: &str, tag: &str,
cmd: &str| cmd: &str|
-> Result<Vec<String>> { -> Result<Vec<String>> {
let full = format!("{} {}\r\n", tag, cmd); let full = format!("{} {}\r\n", tag, cmd);
IoWrite::write_all(tls, full.as_bytes())?; IoWrite::write_all(tls, full.as_bytes())?;
@ -241,7 +251,11 @@ impl EmailChannel {
} }
// Select folder // Select folder
let _select = send_cmd(&mut tls, "A2", &format!("SELECT \"{}\"", config.imap_folder))?; let _select = send_cmd(
&mut tls,
"A2",
&format!("SELECT \"{}\"", config.imap_folder),
)?;
// Search unseen // Search unseen
let search_resp = send_cmd(&mut tls, "A3", "SEARCH UNSEEN")?; let search_resp = send_cmd(&mut tls, "A3", "SEARCH UNSEEN")?;
@ -285,8 +299,17 @@ impl EmailChannel {
.date() .date()
.map(|d| { .map(|d| {
let naive = chrono::NaiveDate::from_ymd_opt( let naive = chrono::NaiveDate::from_ymd_opt(
d.year as i32, u32::from(d.month), u32::from(d.day) d.year as i32,
).and_then(|date| date.and_hms_opt(u32::from(d.hour), u32::from(d.minute), u32::from(d.second))); u32::from(d.month),
u32::from(d.day),
)
.and_then(|date| {
date.and_hms_opt(
u32::from(d.hour),
u32::from(d.minute),
u32::from(d.second),
)
});
naive.map_or(0, |n| n.and_utc().timestamp() as u64) naive.map_or(0, |n| n.and_utc().timestamp() as u64)
}) })
.unwrap_or_else(|| { .unwrap_or_else(|| {
@ -302,7 +325,11 @@ impl EmailChannel {
// Mark as seen with unique tag // Mark as seen with unique tag
let store_tag = format!("A{tag_counter}"); let store_tag = format!("A{tag_counter}");
tag_counter += 1; tag_counter += 1;
let _ = send_cmd(&mut tls, &store_tag, &format!("STORE {uid} +FLAGS (\\Seen)")); let _ = send_cmd(
&mut tls,
&store_tag,
&format!("STORE {uid} +FLAGS (\\Seen)"),
);
} }
// Logout with unique tag // Logout with unique tag

View file

@ -5,8 +5,8 @@ pub mod imessage;
pub mod matrix; pub mod matrix;
pub mod slack; pub mod slack;
pub mod telegram; pub mod telegram;
pub mod whatsapp;
pub mod traits; pub mod traits;
pub mod whatsapp;
pub use cli::CliChannel; pub use cli::CliChannel;
pub use discord::DiscordChannel; pub use discord::DiscordChannel;
@ -14,8 +14,8 @@ pub use imessage::IMessageChannel;
pub use matrix::MatrixChannel; pub use matrix::MatrixChannel;
pub use slack::SlackChannel; pub use slack::SlackChannel;
pub use telegram::TelegramChannel; pub use telegram::TelegramChannel;
pub use whatsapp::WhatsAppChannel;
pub use traits::Channel; pub use traits::Channel;
pub use whatsapp::WhatsAppChannel;
use crate::config::Config; use crate::config::Config;
use crate::memory::{self, Memory}; use crate::memory::{self, Memory};
@ -189,7 +189,7 @@ pub fn build_system_prompt(
} }
} }
/// Inject OpenClaw (markdown) identity files into the prompt /// Inject `OpenClaw` (markdown) identity files into the prompt
fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) { fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) {
#[allow(unused_imports)] #[allow(unused_imports)]
use std::fmt::Write; use std::fmt::Write;

View file

@ -2,7 +2,7 @@ use super::traits::{Channel, ChannelMessage};
use async_trait::async_trait; use async_trait::async_trait;
use uuid::Uuid; use uuid::Uuid;
/// WhatsApp channel — uses WhatsApp Business Cloud API /// `WhatsApp` channel — uses `WhatsApp` Business Cloud API
/// ///
/// This channel operates in webhook mode (push-based) rather than polling. /// This channel operates in webhook mode (push-based) rather than polling.
/// Messages are received via the gateway's `/whatsapp` webhook endpoint. /// Messages are received via the gateway's `/whatsapp` webhook endpoint.

View file

@ -89,6 +89,12 @@ impl Default for IdentityConfig {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayConfig { pub struct GatewayConfig {
/// Gateway port (default: 8080)
#[serde(default = "default_gateway_port")]
pub port: u16,
/// Gateway host (default: 127.0.0.1)
#[serde(default = "default_gateway_host")]
pub host: String,
/// Require pairing before accepting requests (default: true) /// Require pairing before accepting requests (default: true)
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub require_pairing: bool, pub require_pairing: bool,
@ -100,6 +106,14 @@ pub struct GatewayConfig {
pub paired_tokens: Vec<String>, pub paired_tokens: Vec<String>,
} }
fn default_gateway_port() -> u16 {
3000
}
fn default_gateway_host() -> String {
"127.0.0.1".into()
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@ -107,6 +121,8 @@ fn default_true() -> bool {
impl Default for GatewayConfig { impl Default for GatewayConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
port: default_gateway_port(),
host: default_gateway_host(),
require_pairing: true, require_pairing: true,
allow_public_bind: false, allow_public_bind: false,
paired_tokens: Vec::new(), paired_tokens: Vec::new(),
@ -649,6 +665,65 @@ impl Config {
} }
} }
/// Apply environment variable overrides to config
pub fn apply_env_overrides(&mut self) {
// API Key: ZEROCLAW_API_KEY or API_KEY
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
if !key.is_empty() {
self.api_key = Some(key);
}
}
// Provider: ZEROCLAW_PROVIDER or PROVIDER
if let Ok(provider) =
std::env::var("ZEROCLAW_PROVIDER").or_else(|_| std::env::var("PROVIDER"))
{
if !provider.is_empty() {
self.default_provider = Some(provider);
}
}
// Model: ZEROCLAW_MODEL
if let Ok(model) = std::env::var("ZEROCLAW_MODEL") {
if !model.is_empty() {
self.default_model = Some(model);
}
}
// Workspace directory: ZEROCLAW_WORKSPACE
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
if !workspace.is_empty() {
self.workspace_dir = PathBuf::from(workspace);
}
}
// Gateway port: ZEROCLAW_GATEWAY_PORT or PORT
if let Ok(port_str) =
std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
{
if let Ok(port) = port_str.parse::<u16>() {
self.gateway.port = port;
}
}
// Gateway host: ZEROCLAW_GATEWAY_HOST or HOST
if let Ok(host) = std::env::var("ZEROCLAW_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
{
if !host.is_empty() {
self.gateway.host = host;
}
}
// Temperature: ZEROCLAW_TEMPERATURE
if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") {
if let Ok(temp) = temp_str.parse::<f64>() {
if (0.0..=2.0).contains(&temp) {
self.default_temperature = temp;
}
}
}
}
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let toml_str = toml::to_string_pretty(self).context("Failed to serialize config")?; let toml_str = toml::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&self.config_path, toml_str).context("Failed to write config file")?; fs::write(&self.config_path, toml_str).context("Failed to write config file")?;
@ -1191,6 +1266,8 @@ channel_id = "C123"
#[test] #[test]
fn checklist_gateway_serde_roundtrip() { fn checklist_gateway_serde_roundtrip() {
let g = GatewayConfig { let g = GatewayConfig {
port: 3000,
host: "127.0.0.1".into(),
require_pairing: true, require_pairing: true,
allow_public_bind: false, allow_public_bind: false,
paired_tokens: vec!["zc_test_token".into()], paired_tokens: vec!["zc_test_token".into()],
@ -1364,4 +1441,187 @@ default_temperature = 0.7
assert!(!parsed.browser.enabled); assert!(!parsed.browser.enabled);
assert!(parsed.browser.allowed_domains.is_empty()); assert!(parsed.browser.allowed_domains.is_empty());
} }
// ── Environment variable overrides (Docker support) ─────────
#[test]
fn env_override_api_key() {
let mut config = Config::default();
assert!(config.api_key.is_none());
std::env::set_var("ZEROCLAW_API_KEY", "sk-test-env-key");
config.apply_env_overrides();
assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
std::env::remove_var("ZEROCLAW_API_KEY");
}
#[test]
fn env_override_api_key_fallback() {
let mut config = Config::default();
std::env::remove_var("ZEROCLAW_API_KEY");
std::env::set_var("API_KEY", "sk-fallback-key");
config.apply_env_overrides();
assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
std::env::remove_var("API_KEY");
}
#[test]
fn env_override_provider() {
let mut config = Config::default();
std::env::set_var("ZEROCLAW_PROVIDER", "anthropic");
config.apply_env_overrides();
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
std::env::remove_var("ZEROCLAW_PROVIDER");
}
#[test]
fn env_override_provider_fallback() {
let mut config = Config::default();
std::env::remove_var("ZEROCLAW_PROVIDER");
std::env::set_var("PROVIDER", "openai");
config.apply_env_overrides();
assert_eq!(config.default_provider.as_deref(), Some("openai"));
std::env::remove_var("PROVIDER");
}
#[test]
fn env_override_model() {
let mut config = Config::default();
std::env::set_var("ZEROCLAW_MODEL", "gpt-4o");
config.apply_env_overrides();
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
std::env::remove_var("ZEROCLAW_MODEL");
}
#[test]
fn env_override_workspace() {
let mut config = Config::default();
std::env::set_var("ZEROCLAW_WORKSPACE", "/custom/workspace");
config.apply_env_overrides();
assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
std::env::remove_var("ZEROCLAW_WORKSPACE");
}
#[test]
fn env_override_empty_values_ignored() {
let mut config = Config::default();
let original_provider = config.default_provider.clone();
std::env::set_var("ZEROCLAW_PROVIDER", "");
config.apply_env_overrides();
assert_eq!(config.default_provider, original_provider);
std::env::remove_var("ZEROCLAW_PROVIDER");
}
#[test]
fn env_override_gateway_port() {
let mut config = Config::default();
assert_eq!(config.gateway.port, 3000);
std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080");
config.apply_env_overrides();
assert_eq!(config.gateway.port, 8080);
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
}
#[test]
fn env_override_port_fallback() {
let mut config = Config::default();
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
std::env::set_var("PORT", "9000");
config.apply_env_overrides();
assert_eq!(config.gateway.port, 9000);
std::env::remove_var("PORT");
}
#[test]
fn env_override_gateway_host() {
let mut config = Config::default();
assert_eq!(config.gateway.host, "127.0.0.1");
std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0");
config.apply_env_overrides();
assert_eq!(config.gateway.host, "0.0.0.0");
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
}
#[test]
fn env_override_host_fallback() {
let mut config = Config::default();
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
std::env::set_var("HOST", "0.0.0.0");
config.apply_env_overrides();
assert_eq!(config.gateway.host, "0.0.0.0");
std::env::remove_var("HOST");
}
#[test]
fn env_override_temperature() {
let mut config = Config::default();
std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5");
config.apply_env_overrides();
assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
std::env::remove_var("ZEROCLAW_TEMPERATURE");
}
#[test]
fn env_override_temperature_out_of_range_ignored() {
// Clean up any leftover env vars from other tests
std::env::remove_var("ZEROCLAW_TEMPERATURE");
let mut config = Config::default();
let original_temp = config.default_temperature;
// Temperature > 2.0 should be ignored
std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0");
config.apply_env_overrides();
assert!(
(config.default_temperature - original_temp).abs() < f64::EPSILON,
"Temperature 3.0 should be ignored (out of range)"
);
std::env::remove_var("ZEROCLAW_TEMPERATURE");
}
#[test]
fn env_override_invalid_port_ignored() {
let mut config = Config::default();
let original_port = config.gateway.port;
std::env::set_var("PORT", "not_a_number");
config.apply_env_overrides();
assert_eq!(config.gateway.port, original_port);
std::env::remove_var("PORT");
}
#[test]
fn gateway_config_default_values() {
let g = GatewayConfig::default();
assert_eq!(g.port, 3000);
assert_eq!(g.host, "127.0.0.1");
assert!(g.require_pairing);
assert!(!g.allow_public_bind);
assert!(g.paired_tokens.is_empty());
}
} }

View file

@ -18,6 +18,7 @@ pub struct CronJob {
pub last_status: Option<String>, pub last_status: Option<String>,
} }
#[allow(clippy::needless_pass_by_value)]
pub fn handle_command(command: super::CronCommands, config: Config) -> Result<()> { pub fn handle_command(command: super::CronCommands, config: Config) -> Result<()> {
match command { match command {
super::CronCommands::List => { super::CronCommands::List => {
@ -33,8 +34,7 @@ pub fn handle_command(command: super::CronCommands, config: Config) -> Result<()
for job in jobs { for job in jobs {
let last_run = job let last_run = job
.last_run .last_run
.map(|d| d.to_rfc3339()) .map_or_else(|| "never".into(), |d| d.to_rfc3339());
.unwrap_or_else(|| "never".into());
let last_status = job.last_status.unwrap_or_else(|| "n/a".into()); let last_status = job.last_status.unwrap_or_else(|| "n/a".into());
println!( println!(
"- {} | {} | next={} | last={} ({})\n cmd: {}", "- {} | {} | next={} | last={} ({})\n cmd: {}",

View file

@ -66,7 +66,7 @@ async fn execute_job_with_retry(
} }
if attempt < retries { if attempt < retries {
let jitter_ms = (Utc::now().timestamp_subsec_millis() % 250) as u64; let jitter_ms = u64::from(Utc::now().timestamp_subsec_millis() % 250);
time::sleep(Duration::from_millis(backoff_ms + jitter_ms)).await; time::sleep(Duration::from_millis(backoff_ms + jitter_ms)).await;
backoff_ms = (backoff_ms.saturating_mul(2)).min(30_000); backoff_ms = (backoff_ms.saturating_mul(2)).min(30_000);
} }

View file

@ -52,25 +52,21 @@ pub fn run(config: &Config) -> Result<()> {
let scheduler_ok = scheduler let scheduler_ok = scheduler
.get("status") .get("status")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.map(|s| s == "ok") .is_some_and(|s| s == "ok");
.unwrap_or(false);
let scheduler_last_ok = scheduler let scheduler_last_ok = scheduler
.get("last_ok") .get("last_ok")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.and_then(parse_rfc3339) .and_then(parse_rfc3339)
.map(|dt| Utc::now().signed_duration_since(dt).num_seconds()) .map_or(i64::MAX, |dt| {
.unwrap_or(i64::MAX); Utc::now().signed_duration_since(dt).num_seconds()
});
if scheduler_ok && scheduler_last_ok <= SCHEDULER_STALE_SECONDS { if scheduler_ok && scheduler_last_ok <= SCHEDULER_STALE_SECONDS {
println!( println!(" ✅ scheduler healthy (last ok {scheduler_last_ok}s ago)");
" ✅ scheduler healthy (last ok {}s ago)",
scheduler_last_ok
);
} else { } else {
println!( println!(
" ❌ scheduler unhealthy/stale (status_ok={}, age={}s)", " ❌ scheduler unhealthy/stale (status_ok={scheduler_ok}, age={scheduler_last_ok}s)"
scheduler_ok, scheduler_last_ok
); );
} }
} else { } else {
@ -86,14 +82,14 @@ pub fn run(config: &Config) -> Result<()> {
let status_ok = component let status_ok = component
.get("status") .get("status")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.map(|s| s == "ok") .is_some_and(|s| s == "ok");
.unwrap_or(false);
let age = component let age = component
.get("last_ok") .get("last_ok")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.and_then(parse_rfc3339) .and_then(parse_rfc3339)
.map(|dt| Utc::now().signed_duration_since(dt).num_seconds()) .map_or(i64::MAX, |dt| {
.unwrap_or(i64::MAX); Utc::now().signed_duration_since(dt).num_seconds()
});
if status_ok && age <= CHANNEL_STALE_SECONDS { if status_ok && age <= CHANNEL_STALE_SECONDS {
println!("{name} fresh (last ok {age}s ago)"); println!("{name} fresh (last ok {age}s ago)");
@ -107,10 +103,7 @@ pub fn run(config: &Config) -> Result<()> {
if channel_count == 0 { if channel_count == 0 {
println!(" no channel components tracked in state yet"); println!(" no channel components tracked in state yet");
} else { } else {
println!( println!(" Channel summary: {channel_count} total, {stale_channels} stale");
" Channel summary: {} total, {} stale",
channel_count, stale_channels
);
} }
Ok(()) Ok(())

View file

@ -67,6 +67,7 @@ pub fn mark_component_ok(component: &str) {
}); });
} }
#[allow(clippy::needless_pass_by_value)]
pub fn mark_component_error(component: &str, error: impl ToString) { pub fn mark_component_error(component: &str, error: impl ToString) {
let err = error.to_string(); let err = error.to_string();
upsert_component(component, move |entry| { upsert_component(component, move |entry| {

View file

@ -169,9 +169,9 @@ enum Commands {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum MigrateCommands { enum MigrateCommands {
/// Import memory from an OpenClaw workspace into this ZeroClaw workspace /// Import memory from an `OpenClaw` workspace into this `ZeroClaw` workspace
Openclaw { Openclaw {
/// Optional path to OpenClaw workspace (defaults to ~/.openclaw/workspace) /// Optional path to `OpenClaw` workspace (defaults to ~/.openclaw/workspace)
#[arg(long)] #[arg(long)]
source: Option<std::path::PathBuf>, source: Option<std::path::PathBuf>,

View file

@ -250,6 +250,7 @@ fn read_openclaw_markdown_entries(source_workspace: &Path) -> Result<Vec<SourceE
Ok(all) Ok(all)
} }
#[allow(clippy::needless_pass_by_value)]
fn parse_markdown_file( fn parse_markdown_file(
_path: &Path, _path: &Path,
content: &str, content: &str,
@ -306,10 +307,9 @@ fn parse_structured_memory_line(line: &str) -> Option<(&str, &str)> {
fn parse_category(raw: &str) -> MemoryCategory { fn parse_category(raw: &str) -> MemoryCategory {
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"core" => MemoryCategory::Core, "core" | "" => MemoryCategory::Core,
"daily" => MemoryCategory::Daily, "daily" => MemoryCategory::Daily,
"conversation" => MemoryCategory::Conversation, "conversation" => MemoryCategory::Conversation,
"" => MemoryCategory::Core,
other => MemoryCategory::Custom(other.to_string()), other => MemoryCategory::Custom(other.to_string()),
} }
} }
@ -350,7 +350,7 @@ fn pick_optional_column_expr(columns: &[String], candidates: &[&str]) -> Option<
candidates candidates
.iter() .iter()
.find(|candidate| columns.iter().any(|c| c == *candidate)) .find(|candidate| columns.iter().any(|c| c == *candidate))
.map(|s| s.to_string()) .map(std::string::ToString::to_string)
} }
fn pick_column_expr(columns: &[String], candidates: &[&str], fallback: &str) -> String { fn pick_column_expr(columns: &[String], candidates: &[&str], fallback: &str) -> String {

View file

@ -451,7 +451,10 @@ fn setup_provider() -> Result<(String, String, String)> {
("mistral", "Mistral — Large & Codestral"), ("mistral", "Mistral — Large & Codestral"),
("xai", "xAI — Grok 3 & 4"), ("xai", "xAI — Grok 3 & 4"),
("perplexity", "Perplexity — search-augmented AI"), ("perplexity", "Perplexity — search-augmented AI"),
("gemini", "Google Gemini — Gemini 2.0 Flash & Pro (supports CLI auth)"), (
"gemini",
"Google Gemini — Gemini 2.0 Flash & Pro (supports CLI auth)",
),
], ],
1 => vec![ 1 => vec![
("groq", "Groq — ultra-fast LPU inference"), ("groq", "Groq — ultra-fast LPU inference"),
@ -534,7 +537,10 @@ fn setup_provider() -> Result<(String, String, String)> {
let api_key = if provider_name == "ollama" { let api_key = if provider_name == "ollama" {
print_bullet("Ollama runs locally — no API key needed!"); print_bullet("Ollama runs locally — no API key needed!");
String::new() String::new()
} else if provider_name == "gemini" || provider_name == "google" || provider_name == "google-gemini" { } else if provider_name == "gemini"
|| provider_name == "google"
|| provider_name == "google-gemini"
{
// Special handling for Gemini: check for CLI auth first // Special handling for Gemini: check for CLI auth first
if crate::providers::gemini::GeminiProvider::has_cli_credentials() { if crate::providers::gemini::GeminiProvider::has_cli_credentials() {
print_bullet(&format!( print_bullet(&format!(
@ -741,7 +747,10 @@ fn setup_provider() -> Result<(String, String, String)> {
], ],
"gemini" | "google" | "google-gemini" => vec![ "gemini" | "google" | "google-gemini" => vec![
("gemini-2.0-flash", "Gemini 2.0 Flash (fast, recommended)"), ("gemini-2.0-flash", "Gemini 2.0 Flash (fast, recommended)"),
("gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite (fastest, cheapest)"), (
"gemini-2.0-flash-lite",
"Gemini 2.0 Flash Lite (fastest, cheapest)",
),
("gemini-1.5-pro", "Gemini 1.5 Pro (best quality)"), ("gemini-1.5-pro", "Gemini 1.5 Pro (best quality)"),
("gemini-1.5-flash", "Gemini 1.5 Flash (balanced)"), ("gemini-1.5-flash", "Gemini 1.5 Flash (balanced)"),
], ],

View file

@ -194,7 +194,10 @@ impl SecretStore {
let _ = std::process::Command::new("icacls") let _ = std::process::Command::new("icacls")
.arg(&self.key_path) .arg(&self.key_path)
.args(["/inheritance:r", "/grant:r"]) .args(["/inheritance:r", "/grant:r"])
.arg(format!("{}:F", std::env::var("USERNAME").unwrap_or_default())) .arg(format!(
"{}:F",
std::env::var("USERNAME").unwrap_or_default()
))
.output(); .output();
} }

View file

@ -6,6 +6,7 @@ use std::process::Command;
const SERVICE_LABEL: &str = "com.zeroclaw.daemon"; const SERVICE_LABEL: &str = "com.zeroclaw.daemon";
#[allow(clippy::needless_pass_by_value)]
pub fn handle_command(command: super::ServiceCommands, config: &Config) -> Result<()> { pub fn handle_command(command: super::ServiceCommands, config: &Config) -> Result<()> {
match command { match command {
super::ServiceCommands::Install => install(config), super::ServiceCommands::Install => install(config),

View file

@ -239,6 +239,7 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
} }
/// Handle the `skills` CLI command /// Handle the `skills` CLI command
#[allow(clippy::too_many_lines)]
pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Result<()> { pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Result<()> {
match command { match command {
super::SkillCommands::List => { super::SkillCommands::List => {

View file

@ -69,15 +69,12 @@ impl Tool for FileWriteTool {
tokio::fs::create_dir_all(parent).await?; tokio::fs::create_dir_all(parent).await?;
} }
let parent = match full_path.parent() { let Some(parent) = full_path.parent() else {
Some(p) => p, return Ok(ToolResult {
None => { success: false,
return Ok(ToolResult { output: String::new(),
success: false, error: Some("Invalid path: missing parent directory".into()),
output: String::new(), });
error: Some("Invalid path: missing parent directory".into()),
});
}
}; };
// Resolve parent before writing to block symlink escapes. // Resolve parent before writing to block symlink escapes.
@ -103,15 +100,12 @@ impl Tool for FileWriteTool {
}); });
} }
let file_name = match full_path.file_name() { let Some(file_name) = full_path.file_name() else {
Some(name) => name, return Ok(ToolResult {
None => { success: false,
return Ok(ToolResult { output: String::new(),
success: false, error: Some("Invalid path: missing file name".into()),
output: String::new(), });
error: Some("Invalid path: missing file name".into()),
});
}
}; };
let resolved_target = resolved_parent.join(file_name); let resolved_target = resolved_parent.join(file_name);