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:
parent
d7769340a3
commit
a310e178db
16 changed files with 372 additions and 83 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {}",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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| {
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)"),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue