diff --git a/.github/workflows/cross-platform-build-manual.yml b/.github/workflows/cross-platform-build-manual.yml index 51140d6d6..8ada7952e 100644 --- a/.github/workflows/cross-platform-build-manual.yml +++ b/.github/workflows/cross-platform-build-manual.yml @@ -74,4 +74,4 @@ jobs: if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then export "${{ matrix.linker_env }}=${{ matrix.linker }}" fi - cargo build --release --locked --features channel-matrix --target ${{ matrix.target }} + cargo build --release --locked --features channel-matrix,channel-lark,memory-postgres --target ${{ matrix.target }} diff --git a/.github/workflows/pub-aur.yml b/.github/workflows/pub-aur.yml index 4ba1994a3..033824cfe 100644 --- a/.github/workflows/pub-aur.yml +++ b/.github/workflows/pub-aur.yml @@ -134,15 +134,27 @@ jobs: exit 1 fi + # Set up SSH key — normalize line endings and ensure trailing newline mkdir -p ~/.ssh - echo "$AUR_SSH_KEY" > ~/.ssh/aur + chmod 700 ~/.ssh + printf '%s\n' "$AUR_SSH_KEY" | tr -d '\r' > ~/.ssh/aur chmod 600 ~/.ssh/aur - cat >> ~/.ssh/config < ~/.ssh/config <<'SSH_CONFIG' Host aur.archlinux.org IdentityFile ~/.ssh/aur User aur StrictHostKeyChecking accept-new SSH_CONFIG + chmod 600 ~/.ssh/config + + # Verify key is valid and print fingerprint for debugging + echo "::group::SSH key diagnostics" + ssh-keygen -l -f ~/.ssh/aur || { echo "::error::AUR_SSH_KEY is not a valid SSH private key"; exit 1; } + echo "::endgroup::" + + # Test SSH connectivity before attempting clone + ssh -T -o BatchMode=yes -o ConnectTimeout=10 aur@aur.archlinux.org 2>&1 || true tmp_dir="$(mktemp -d)" git clone ssh://aur@aur.archlinux.org/zeroclaw.git "$tmp_dir/aur" diff --git a/.github/workflows/release-beta-on-push.yml b/.github/workflows/release-beta-on-push.yml index d97933051..6d38b05d8 100644 --- a/.github/workflows/release-beta-on-push.yml +++ b/.github/workflows/release-beta-on-push.yml @@ -16,7 +16,7 @@ env: CARGO_TERM_COLOR: always REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - RELEASE_CARGO_FEATURES: channel-matrix,memory-postgres + RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres jobs: version: diff --git a/.github/workflows/release-stable-manual.yml b/.github/workflows/release-stable-manual.yml index 72b810b3c..5e3a1ec81 100644 --- a/.github/workflows/release-stable-manual.yml +++ b/.github/workflows/release-stable-manual.yml @@ -20,7 +20,7 @@ env: CARGO_TERM_COLOR: always REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - RELEASE_CARGO_FEATURES: channel-matrix,memory-postgres + RELEASE_CARGO_FEATURES: channel-matrix,channel-lark,memory-postgres jobs: validate: diff --git a/Cargo.lock b/Cargo.lock index 41ed693de..1d74065f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9164,7 +9164,7 @@ dependencies = [ [[package]] name = "zeroclawlabs" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 31f4f97a7..22ed857f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [package] name = "zeroclawlabs" -version = "0.5.0" +version = "0.5.1" edition = "2021" authors = ["theonlyhennygod"] license = "MIT OR Apache-2.0" diff --git a/Dockerfile b/Dockerfile index 5d1dba679..717215cd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN npm run build FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder WORKDIR /app -ARG ZEROCLAW_CARGO_FEATURES="" +ARG ZEROCLAW_CARGO_FEATURES="memory-postgres" # Install build dependencies RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ diff --git a/Dockerfile.debian b/Dockerfile.debian index 167061492..00f7f492c 100644 --- a/Dockerfile.debian +++ b/Dockerfile.debian @@ -27,7 +27,7 @@ RUN npm run build FROM rust:1.94-bookworm AS builder WORKDIR /app -ARG ZEROCLAW_CARGO_FEATURES="" +ARG ZEROCLAW_CARGO_FEATURES="memory-postgres" # Install build dependencies RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ diff --git a/README.md b/README.md index 47ceb360f..f2172211d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- ZeroClaw + ZeroClaw

ZeroClaw 🦀

@@ -16,6 +16,7 @@ X: @zeroclawlabs Facebook Group Discord + Instagram: @therealzeroclaw TikTok: @zeroclawlabs RedNote Reddit: r/zeroclawlabs diff --git a/install.sh b/install.sh index c32a3bf11..f222ed572 100755 --- a/install.sh +++ b/install.sh @@ -448,46 +448,32 @@ bool_to_word() { fi } -guided_input_stream() { - # Some constrained containers report interactive stdin (-t 0) but deny - # opening /dev/stdin directly. Probe readability before selecting it. - if [[ -t 0 ]] && (: /dev/null; then - echo "/dev/stdin" +guided_open_input() { + # Use stdin directly when it is an interactive terminal (e.g. SSH into LXC). + # Subshell probing of /dev/stdin fails in some constrained containers even + # when FD 0 is perfectly usable, so skip the probe and trust -t 0. + if [[ -t 0 ]]; then + GUIDED_FD=0 return 0 fi - if [[ -t 0 ]] && (: /dev/null; then - echo "/proc/self/fd/0" - return 0 - fi - - if (: /dev/null; then - echo "/dev/tty" - return 0 - fi - - return 1 + # Non-interactive stdin: try to open /dev/tty as an explicit fd. + exec {GUIDED_FD}/dev/null || return 1 } guided_read() { local __target_var="$1" local __prompt="$2" local __silent="${3:-false}" - local __input_source="" local __value="" - if ! __input_source="$(guided_input_stream)"; then - return 1 - fi + [[ -n "${GUIDED_FD:-}" ]] || guided_open_input || return 1 if [[ "$__silent" == true ]]; then - if ! read -r -s -p "$__prompt" __value <"$__input_source"; then - return 1 - fi + read -r -s -u "$GUIDED_FD" -p "$__prompt" __value || return 1 + echo else - if ! read -r -p "$__prompt" __value <"$__input_source"; then - return 1 - fi + read -r -u "$GUIDED_FD" -p "$__prompt" __value || return 1 fi printf -v "$__target_var" '%s' "$__value" @@ -708,7 +694,7 @@ prompt_model() { run_guided_installer() { local os_name="$1" - if ! guided_input_stream >/dev/null; then + if ! guided_open_input >/dev/null; then error "guided installer requires an interactive terminal." error "Run from a terminal, or pass --no-guided with explicit flags." exit 1 diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 5442f05c5..2ce5cf3bd 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -8,7 +8,7 @@ use crate::providers::{ self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall, }; use crate::runtime; -use crate::security::SecurityPolicy; +use crate::security::{AutonomyLevel, SecurityPolicy}; use crate::tools::{self, Tool}; use crate::util::truncate_with_ellipsis; use anyhow::Result; @@ -2181,8 +2181,10 @@ pub(crate) async fn agent_turn( temperature: f64, silent: bool, channel_name: &str, + channel_reply_target: Option<&str>, multimodal_config: &crate::config::MultimodalConfig, max_tool_iterations: usize, + approval: Option<&ApprovalManager>, excluded_tools: &[String], dedup_exempt_tools: &[String], activated_tools: Option<&std::sync::Arc>>, @@ -2197,8 +2199,9 @@ pub(crate) async fn agent_turn( model, temperature, silent, - None, + approval, channel_name, + channel_reply_target, multimodal_config, max_tool_iterations, None, @@ -2212,6 +2215,100 @@ pub(crate) async fn agent_turn( .await } +fn maybe_inject_channel_delivery_defaults( + tool_name: &str, + tool_args: &mut serde_json::Value, + channel_name: &str, + channel_reply_target: Option<&str>, +) { + if tool_name != "cron_add" { + return; + } + + if !matches!( + channel_name, + "telegram" | "discord" | "slack" | "mattermost" | "matrix" + ) { + return; + } + + let Some(reply_target) = channel_reply_target + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + + let Some(args) = tool_args.as_object_mut() else { + return; + }; + + let is_agent_job = args + .get("job_type") + .and_then(serde_json::Value::as_str) + .is_some_and(|job_type| job_type.eq_ignore_ascii_case("agent")) + || args + .get("prompt") + .and_then(serde_json::Value::as_str) + .is_some_and(|prompt| !prompt.trim().is_empty()); + if !is_agent_job { + return; + } + + let default_delivery = || { + serde_json::json!({ + "mode": "announce", + "channel": channel_name, + "to": reply_target, + }) + }; + + match args.get_mut("delivery") { + None => { + args.insert("delivery".to_string(), default_delivery()); + } + Some(serde_json::Value::Null) => { + *args.get_mut("delivery").expect("delivery key exists") = default_delivery(); + } + Some(serde_json::Value::Object(delivery)) => { + if delivery + .get("mode") + .and_then(serde_json::Value::as_str) + .is_some_and(|mode| mode.eq_ignore_ascii_case("none")) + { + return; + } + + delivery + .entry("mode".to_string()) + .or_insert_with(|| serde_json::Value::String("announce".to_string())); + + let needs_channel = delivery + .get("channel") + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty()); + if needs_channel { + delivery.insert( + "channel".to_string(), + serde_json::Value::String(channel_name.to_string()), + ); + } + + let needs_target = delivery + .get("to") + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty()); + if needs_target { + delivery.insert( + "to".to_string(), + serde_json::Value::String(reply_target.to_string()), + ); + } + } + Some(_) => {} + } +} + async fn execute_one_tool( call_name: &str, call_arguments: serde_json::Value, @@ -2405,6 +2502,7 @@ pub(crate) async fn run_tool_call_loop( silent: bool, approval: Option<&ApprovalManager>, channel_name: &str, + channel_reply_target: Option<&str>, multimodal_config: &crate::config::MultimodalConfig, max_tool_iterations: usize, cancellation_token: Option, @@ -2815,6 +2913,13 @@ pub(crate) async fn run_tool_call_loop( } } + maybe_inject_channel_delivery_defaults( + &tool_name, + &mut tool_args, + channel_name, + channel_reply_target, + ); + // ── Approval hook ──────────────────────────────── if let Some(mgr) = approval { if mgr.needs_approval(&tool_name) { @@ -3466,6 +3571,7 @@ pub async fn run( bootstrap_max_chars, native_tools, config.skills.prompt_injection_mode, + config.autonomy.level, ); // Append structured tool-use instructions with schemas (only for non-native providers) @@ -3556,6 +3662,7 @@ pub async fn run( false, approval_manager.as_ref(), channel_name, + None, &config.multimodal, config.agent.max_tool_iterations, None, @@ -3782,6 +3889,7 @@ pub async fn run( false, approval_manager.as_ref(), channel_name, + None, &config.multimodal, config.agent.max_tool_iterations, None, @@ -3894,6 +4002,7 @@ pub async fn process_message( &config.autonomy, &config.workspace_dir, )); + let approval_manager = ApprovalManager::for_non_interactive(&config.autonomy); let mem: Arc = Arc::from(memory::create_memory_with_storage_and_routes( &config.memory, &config.embedding_routes, @@ -4085,6 +4194,16 @@ pub async fn process_message( "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.", )); } + + // Filter out tools excluded for non-CLI channels (gateway counts as non-CLI). + // Skip when autonomy is `Full` — full-autonomy agents keep all tools. + if config.autonomy.level != AutonomyLevel::Full { + let excluded = &config.autonomy.non_cli_excluded_tools; + if !excluded.is_empty() { + tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name)); + } + } + let bootstrap_max_chars = if config.agent.compact_context { Some(6000) } else { @@ -4100,6 +4219,7 @@ pub async fn process_message( bootstrap_max_chars, native_tools, config.skills.prompt_injection_mode, + config.autonomy.level, ); if !native_tools { system_prompt.push_str(&build_tool_instructions(&tools_registry, Some(&i18n_descs))); @@ -4133,8 +4253,11 @@ pub async fn process_message( ChatMessage::system(&system_prompt), ChatMessage::user(&enriched), ]; - let excluded_tools = + let mut excluded_tools = compute_excluded_mcp_tools(&tools_registry, &config.agent.tool_filter_groups, message); + if config.autonomy.level != AutonomyLevel::Full { + excluded_tools.extend(config.autonomy.non_cli_excluded_tools.iter().cloned()); + } agent_turn( provider.as_ref(), @@ -4146,8 +4269,10 @@ pub async fn process_message( config.default_temperature, true, "daemon", + None, &config.multimodal, config.agent.max_tool_iterations, + Some(&approval_manager), &excluded_tools, &config.agent.tool_call_dedup_exempt, activated_handle_pm.as_ref(), @@ -4463,6 +4588,57 @@ mod tests { } } + struct RecordingArgsTool { + name: String, + recorded_args: Arc>>, + } + + impl RecordingArgsTool { + fn new(name: &str, recorded_args: Arc>>) -> Self { + Self { + name: name.to_string(), + recorded_args, + } + } + } + + #[async_trait] + impl Tool for RecordingArgsTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + "Records tool arguments for regression tests" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string" }, + "schedule": { "type": "object" }, + "delivery": { "type": "object" } + } + }) + } + + async fn execute( + &self, + args: serde_json::Value, + ) -> anyhow::Result { + self.recorded_args + .lock() + .expect("recorded args lock should be valid") + .push(args.clone()); + Ok(crate::tools::ToolResult { + success: true, + output: args.to_string(), + error: None, + }) + } + } + struct DelayTool { name: String, delay_ms: u64, @@ -4601,6 +4777,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 3, None, @@ -4650,6 +4827,7 @@ mod tests { true, None, "cli", + None, &multimodal, 3, None, @@ -4693,6 +4871,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 3, None, @@ -4822,6 +5001,7 @@ mod tests { true, Some(&approval_mgr), "telegram", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -4859,6 +5039,122 @@ mod tests { ); } + #[tokio::test] + async fn run_tool_call_loop_injects_channel_delivery_defaults_for_cron_add() { + let provider = ScriptedProvider::from_text_responses(vec![ + r#" +{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}} +"#, + "done", + ]); + + let recorded_args = Arc::new(Mutex::new(Vec::new())); + let tools_registry: Vec> = vec![Box::new(RecordingArgsTool::new( + "cron_add", + Arc::clone(&recorded_args), + ))]; + + let mut history = vec![ + ChatMessage::system("test-system"), + ChatMessage::user("schedule a reminder"), + ]; + let observer = NoopObserver; + + let result = run_tool_call_loop( + &provider, + &mut history, + &tools_registry, + &observer, + "mock-provider", + "mock-model", + 0.0, + true, + None, + "telegram", + Some("chat-42"), + &crate::config::MultimodalConfig::default(), + 4, + None, + None, + None, + &[], + &[], + None, + None, + ) + .await + .expect("cron_add delivery defaults should be injected"); + + assert_eq!(result, "done"); + + let recorded = recorded_args + .lock() + .expect("recorded args lock should be valid"); + let delivery = recorded[0]["delivery"].clone(); + assert_eq!( + delivery, + serde_json::json!({ + "mode": "announce", + "channel": "telegram", + "to": "chat-42", + }) + ); + } + + #[tokio::test] + async fn run_tool_call_loop_preserves_explicit_cron_delivery_none() { + let provider = ScriptedProvider::from_text_responses(vec![ + r#" +{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}} +"#, + "done", + ]); + + let recorded_args = Arc::new(Mutex::new(Vec::new())); + let tools_registry: Vec> = vec![Box::new(RecordingArgsTool::new( + "cron_add", + Arc::clone(&recorded_args), + ))]; + + let mut history = vec![ + ChatMessage::system("test-system"), + ChatMessage::user("schedule a quiet cron job"), + ]; + let observer = NoopObserver; + + let result = run_tool_call_loop( + &provider, + &mut history, + &tools_registry, + &observer, + "mock-provider", + "mock-model", + 0.0, + true, + None, + "telegram", + Some("chat-42"), + &crate::config::MultimodalConfig::default(), + 4, + None, + None, + None, + &[], + &[], + None, + None, + ) + .await + .expect("explicit delivery mode should be preserved"); + + assert_eq!(result, "done"); + + let recorded = recorded_args + .lock() + .expect("recorded args lock should be valid"); + assert_eq!(recorded[0]["delivery"], serde_json::json!({"mode": "none"})); + } + #[tokio::test] async fn run_tool_call_loop_deduplicates_repeated_tool_calls() { let provider = ScriptedProvider::from_text_responses(vec![ @@ -4894,6 +5190,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -4962,6 +5259,7 @@ mod tests { true, Some(&approval_mgr), "telegram", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5021,6 +5319,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5100,6 +5399,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5156,6 +5456,7 @@ mod tests { true, None, "cli", + None, &crate::config::MultimodalConfig::default(), 4, None, @@ -5228,8 +5529,10 @@ mod tests { 0.0, true, "daemon", + None, &crate::config::MultimodalConfig::default(), 4, + None, &[], &[], Some(&activated), @@ -6672,6 +6975,7 @@ Let me check the result."#; None, // no bootstrap_max_chars true, // native_tools crate::config::SkillsPromptInjectionMode::Full, + crate::security::AutonomyLevel::default(), ); // Must contain zero XML protocol artifacts @@ -7117,6 +7421,7 @@ Let me check the result."#; true, None, "telegram", + None, &crate::config::MultimodalConfig::default(), 4, None, diff --git a/src/channels/mod.rs b/src/channels/mod.rs index e34117a08..1d316357e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -98,7 +98,7 @@ use crate::observability::traits::{ObserverEvent, ObserverMetric}; use crate::observability::{self, runtime_trace, Observer}; use crate::providers::{self, ChatMessage, Provider}; use crate::runtime; -use crate::security::SecurityPolicy; +use crate::security::{AutonomyLevel, SecurityPolicy}; use crate::tools::{self, Tool}; use crate::util::truncate_with_ellipsis; use anyhow::{Context, Result}; @@ -328,6 +328,7 @@ struct ChannelRuntimeContext { multimodal: crate::config::MultimodalConfig, hooks: Option>, non_cli_excluded_tools: Arc>, + autonomy_level: AutonomyLevel, tool_call_dedup_exempt: Arc>, model_routes: Arc>, query_classification: crate::config::QueryClassificationConfig, @@ -2239,12 +2240,15 @@ async fn process_channel_message( true, Some(&*ctx.approval_manager), msg.channel.as_str(), + Some(msg.reply_target.as_str()), &ctx.multimodal, ctx.max_tool_iterations, Some(cancellation_token.clone()), delta_tx, ctx.hooks.as_deref(), - if msg.channel == "cli" { + if msg.channel == "cli" + || ctx.autonomy_level == AutonomyLevel::Full + { &[] } else { ctx.non_cli_excluded_tools.as_ref() @@ -2785,6 +2789,7 @@ pub fn build_system_prompt( bootstrap_max_chars, false, crate::config::SkillsPromptInjectionMode::Full, + AutonomyLevel::default(), ) } @@ -2797,6 +2802,7 @@ pub fn build_system_prompt_with_mode( bootstrap_max_chars: Option, native_tools: bool, skills_prompt_mode: crate::config::SkillsPromptInjectionMode, + autonomy_level: AutonomyLevel, ) -> String { use std::fmt::Write; let mut prompt = String::with_capacity(8192); @@ -2862,13 +2868,18 @@ pub fn build_system_prompt_with_mode( // ── 2. Safety ─────────────────────────────────────────────── prompt.push_str("## Safety\n\n"); - prompt.push_str( - "- Do not exfiltrate private data.\n\ - - Do not run destructive commands without asking.\n\ - - Do not bypass oversight or approval mechanisms.\n\ - - Prefer `trash` over `rm` (recoverable beats gone forever).\n\ - - When in doubt, ask before acting externally.\n\n", - ); + prompt.push_str("- Do not exfiltrate private data.\n"); + if autonomy_level != AutonomyLevel::Full { + prompt.push_str( + "- Do not run destructive commands without asking.\n\ + - Do not bypass oversight or approval mechanisms.\n", + ); + } + prompt.push_str("- Prefer `trash` over `rm` (recoverable beats gone forever).\n"); + if autonomy_level != AutonomyLevel::Full { + prompt.push_str("- When in doubt, ask before acting externally.\n"); + } + prompt.push('\n'); // ── 3. Skills (full or compact, based on config) ───────────── if !skills.is_empty() { @@ -4006,8 +4017,10 @@ pub async fn start_channels(config: Config) -> Result<()> { // Filter out tools excluded for non-CLI channels so the system prompt // does not advertise them for channel-driven runs. + // Skip this filter when autonomy is `Full` — full-autonomy agents keep + // all tools available regardless of channel. let excluded = &config.autonomy.non_cli_excluded_tools; - if !excluded.is_empty() { + if !excluded.is_empty() && config.autonomy.level != AutonomyLevel::Full { tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name)); } @@ -4026,6 +4039,7 @@ pub async fn start_channels(config: Config) -> Result<()> { bootstrap_max_chars, native_tools, config.skills.prompt_injection_mode, + config.autonomy.level, ); if !native_tools { system_prompt.push_str(&build_tool_instructions( @@ -4186,6 +4200,7 @@ pub async fn start_channels(config: Config) -> Result<()> { None }, non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()), + autonomy_level: config.autonomy.level, tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()), model_routes: Arc::new(config.model_routes.clone()), query_classification: config.query_classification.clone(), @@ -4490,6 +4505,7 @@ mod tests { workspace_dir: Arc::new(std::env::temp_dir()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -4599,6 +4615,7 @@ mod tests { workspace_dir: Arc::new(std::env::temp_dir()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -4664,6 +4681,7 @@ mod tests { workspace_dir: Arc::new(std::env::temp_dir()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -4748,6 +4766,7 @@ mod tests { workspace_dir: Arc::new(std::env::temp_dir()), message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -5280,6 +5299,7 @@ BTC is currently around $65,000 based on latest tool output."# slack: false, }, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -5353,6 +5373,7 @@ BTC is currently around $65,000 based on latest tool output."# slack: false, }, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -5442,6 +5463,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -5514,6 +5536,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -5596,6 +5619,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -5699,6 +5723,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -5783,6 +5808,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -5882,6 +5908,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -5966,6 +5993,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -6040,6 +6068,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -6225,6 +6254,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -6318,6 +6348,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -6429,6 +6460,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), approval_manager: Arc::new(ApprovalManager::for_non_interactive( @@ -6531,6 +6563,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -6618,6 +6651,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -6690,6 +6724,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -6956,6 +6991,7 @@ BTC is currently around $65,000 based on latest tool output."# None, false, crate::config::SkillsPromptInjectionMode::Compact, + AutonomyLevel::default(), ); assert!(prompt.contains(""), "missing skills XML"); @@ -7078,6 +7114,65 @@ BTC is currently around $65,000 based on latest tool output."# assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display()))); } + #[test] + fn full_autonomy_omits_approval_instructions() { + let ws = make_workspace(); + let prompt = build_system_prompt_with_mode( + ws.path(), + "model", + &[], + &[], + None, + None, + false, + crate::config::SkillsPromptInjectionMode::Full, + AutonomyLevel::Full, + ); + + assert!( + !prompt.contains("without asking"), + "full autonomy prompt must not tell the model to ask before acting" + ); + assert!( + !prompt.contains("ask before acting externally"), + "full autonomy prompt must not contain ask-before-acting instruction" + ); + // Core safety rules should still be present + assert!( + prompt.contains("Do not exfiltrate private data"), + "data exfiltration guard must remain" + ); + assert!( + prompt.contains("Prefer `trash` over `rm`"), + "trash-over-rm hint must remain" + ); + } + + #[test] + fn supervised_autonomy_includes_approval_instructions() { + let ws = make_workspace(); + let prompt = build_system_prompt_with_mode( + ws.path(), + "model", + &[], + &[], + None, + None, + false, + crate::config::SkillsPromptInjectionMode::Full, + AutonomyLevel::Supervised, + ); + + assert!( + prompt.contains("without asking"), + "supervised prompt must include ask-before-acting instruction" + ); + assert!( + prompt.contains("ask before acting externally"), + "supervised prompt must include ask-before-acting instruction" + ); + } + #[test] fn channel_notify_observer_truncates_utf8_arguments_safely() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -7320,6 +7415,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -7418,6 +7514,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -7516,6 +7613,7 @@ BTC is currently around $65,000 based on latest tool output."# multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -8078,6 +8176,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -8157,6 +8256,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(Vec::new()), query_classification: crate::config::QueryClassificationConfig::default(), @@ -8310,6 +8410,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(model_routes), query_classification: classification_config, @@ -8413,6 +8514,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(model_routes), query_classification: classification_config, @@ -8508,6 +8610,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(model_routes), query_classification: classification_config, @@ -8623,6 +8726,7 @@ This is an example JSON object for profile settings."#; multimodal: crate::config::MultimodalConfig::default(), hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), + autonomy_level: AutonomyLevel::default(), tool_call_dedup_exempt: Arc::new(Vec::new()), model_routes: Arc::new(model_routes), query_classification: classification_config, diff --git a/src/config/schema.rs b/src/config/schema.rs index e56ae52e1..10d006a0a 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -137,7 +137,12 @@ pub struct Config { pub cloud_ops: CloudOpsConfig, /// Conversational AI agent builder configuration (`[conversational_ai]`). - #[serde(default)] + /// + /// Experimental / future feature — not yet wired into the agent runtime. + /// Omitted from generated config files when disabled (the default). + /// Existing configs that already contain this section will continue to + /// deserialize correctly thanks to `#[serde(default)]`. + #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")] pub conversational_ai: ConversationalAiConfig, /// Managed cybersecurity service configuration (`[security_ops]`). @@ -4045,7 +4050,8 @@ pub struct ClassificationRule { pub struct HeartbeatConfig { /// Enable periodic heartbeat pings. Default: `false`. pub enabled: bool, - /// Interval in minutes between heartbeat pings. Default: `30`. + /// Interval in minutes between heartbeat pings. Default: `5`. + #[serde(default = "default_heartbeat_interval")] pub interval_minutes: u32, /// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2 /// executes only when the LLM decides there is work to do. Saves API cost @@ -4089,6 +4095,10 @@ pub struct HeartbeatConfig { pub max_run_history: u32, } +fn default_heartbeat_interval() -> u32 { + 5 +} + fn default_two_phase() -> bool { true } @@ -4109,7 +4119,7 @@ impl Default for HeartbeatConfig { fn default() -> Self { Self { enabled: false, - interval_minutes: 30, + interval_minutes: default_heartbeat_interval(), two_phase: true, message: None, target: None, @@ -4133,6 +4143,15 @@ pub struct CronConfig { /// Enable the cron subsystem. Default: `true`. #[serde(default = "default_true")] pub enabled: bool, + /// Run all overdue jobs at scheduler startup. Default: `true`. + /// + /// When the machine boots late or the daemon restarts, jobs whose + /// `next_run` is in the past are considered "missed". With this + /// option enabled the scheduler fires them once before entering + /// the normal polling loop. Disable if you prefer missed jobs to + /// simply wait for their next scheduled occurrence. + #[serde(default = "default_true")] + pub catch_up_on_startup: bool, /// Maximum number of historical cron run records to retain. Default: `50`. #[serde(default = "default_max_run_history")] pub max_run_history: u32, @@ -4146,6 +4165,7 @@ impl Default for CronConfig { fn default() -> Self { Self { enabled: true, + catch_up_on_startup: true, max_run_history: default_max_run_history(), } } @@ -5872,8 +5892,8 @@ fn default_conversational_ai_timeout_secs() -> u64 { /// Conversational AI agent builder configuration (`[conversational_ai]` section). /// -/// Controls language detection, escalation behavior, conversation limits, and -/// analytics for conversational agent workflows. Disabled by default. +/// **Status: Reserved for future use.** This configuration is parsed but not yet +/// consumed by the runtime. Setting `enabled = true` will produce a startup warning. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ConversationalAiConfig { /// Enable conversational AI features. Default: false. @@ -5905,6 +5925,17 @@ pub struct ConversationalAiConfig { pub knowledge_base_tool: Option, } +impl ConversationalAiConfig { + /// Returns `true` when the feature is disabled (the default). + /// + /// Used by `#[serde(skip_serializing_if)]` to omit the entire + /// `[conversational_ai]` section from newly-generated config files, + /// avoiding user confusion over an undocumented / experimental section. + pub fn is_disabled(&self) -> bool { + !self.enabled + } +} + impl Default for ConversationalAiConfig { fn default() -> Self { Self { @@ -7728,6 +7759,13 @@ impl Config { } set_runtime_proxy_config(self.proxy.clone()); + + if self.conversational_ai.enabled { + tracing::warn!( + "conversational_ai.enabled = true but conversational AI features are not yet \ + implemented; this section is reserved for future use and will be ignored" + ); + } } async fn resolve_config_path_for_save(&self) -> Result { @@ -8335,7 +8373,7 @@ mod tests { async fn heartbeat_config_default() { let h = HeartbeatConfig::default(); assert!(!h.enabled); - assert_eq!(h.interval_minutes, 30); + assert_eq!(h.interval_minutes, 5); assert!(h.message.is_none()); assert!(h.target.is_none()); assert!(h.to.is_none()); @@ -8369,11 +8407,13 @@ recipient = "42" async fn cron_config_serde_roundtrip() { let c = CronConfig { enabled: false, + catch_up_on_startup: false, max_run_history: 100, }; let json = serde_json::to_string(&c).unwrap(); let parsed: CronConfig = serde_json::from_str(&json).unwrap(); assert!(!parsed.enabled); + assert!(!parsed.catch_up_on_startup); assert_eq!(parsed.max_run_history, 100); } @@ -8387,6 +8427,7 @@ default_temperature = 0.7 let parsed: Config = toml::from_str(toml_str).unwrap(); assert!(parsed.cron.enabled); + assert!(parsed.cron.catch_up_on_startup); assert_eq!(parsed.cron.max_run_history, 50); } diff --git a/src/cron/mod.rs b/src/cron/mod.rs index a560e2e5e..bffb392ff 100644 --- a/src/cron/mod.rs +++ b/src/cron/mod.rs @@ -14,8 +14,8 @@ pub use schedule::{ }; #[allow(unused_imports)] pub use store::{ - add_agent_job, due_jobs, get_job, list_jobs, list_runs, record_last_run, record_run, - remove_job, reschedule_after_run, update_job, + add_agent_job, all_overdue_jobs, due_jobs, get_job, list_jobs, list_runs, record_last_run, + record_run, remove_job, reschedule_after_run, update_job, }; pub use types::{ deserialize_maybe_stringified, CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 8a9f7e95e..11db8accc 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -6,8 +6,9 @@ use crate::channels::{ }; use crate::config::Config; use crate::cron::{ - due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run, - update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule, SessionTarget, + all_overdue_jobs, due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, + reschedule_after_run, update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule, + SessionTarget, }; use crate::security::SecurityPolicy; use anyhow::Result; @@ -33,6 +34,18 @@ pub async fn run(config: Config) -> Result<()> { crate::health::mark_component_ok(SCHEDULER_COMPONENT); + // ── Startup catch-up: run ALL overdue jobs before entering the + // normal polling loop. The regular loop is capped by `max_tasks`, + // which could leave some overdue jobs waiting across many cycles + // if the machine was off for a while. The catch-up phase fetches + // without the `max_tasks` limit so every missed job fires once. + // Controlled by `[cron] catch_up_on_startup` (default: true). + if config.cron.catch_up_on_startup { + catch_up_overdue_jobs(&config, &security).await; + } else { + tracing::info!("Scheduler startup: catch-up disabled by config"); + } + loop { interval.tick().await; // Keep scheduler liveness fresh even when there are no due jobs. @@ -51,6 +64,35 @@ pub async fn run(config: Config) -> Result<()> { } } +/// Fetch **all** overdue jobs (ignoring `max_tasks`) and execute them. +/// +/// Called once at scheduler startup so that jobs missed during downtime +/// (e.g. late boot, daemon restart) are caught up immediately. +async fn catch_up_overdue_jobs(config: &Config, security: &Arc) { + let now = Utc::now(); + let jobs = match all_overdue_jobs(config, now) { + Ok(jobs) => jobs, + Err(e) => { + tracing::warn!("Startup catch-up query failed: {e}"); + return; + } + }; + + if jobs.is_empty() { + tracing::info!("Scheduler startup: no overdue jobs to catch up"); + return; + } + + tracing::info!( + count = jobs.len(), + "Scheduler startup: catching up overdue jobs" + ); + + process_due_jobs(config, security, jobs, SCHEDULER_COMPONENT).await; + + tracing::info!("Scheduler startup: catch-up complete"); +} + pub async fn execute_job_now(config: &Config, job: &CronJob) -> (bool, String) { let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir); Box::pin(execute_job_with_retry(config, &security, job)).await @@ -506,18 +548,12 @@ async fn run_job_command_with_timeout( ); } - let child = match Command::new("sh") - .arg("-lc") - .arg(&job.command) - .current_dir(&config.workspace_dir) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - { - Ok(child) => child, - Err(e) => return (false, format!("spawn error: {e}")), + let child = match build_cron_shell_command(&job.command, &config.workspace_dir) { + Ok(mut cmd) => match cmd.spawn() { + Ok(child) => child, + Err(e) => return (false, format!("spawn error: {e}")), + }, + Err(e) => return (false, format!("shell setup error: {e}")), }; match time::timeout(timeout, child.wait_with_output()).await { @@ -540,6 +576,35 @@ async fn run_job_command_with_timeout( } } +/// Build a shell `Command` for cron job execution. +/// +/// Uses `sh -c ` (non-login shell). On Windows, ZeroClaw users +/// typically have Git Bash installed which provides `sh` in PATH, and +/// cron commands are written with Unix shell syntax. The previous `-lc` +/// (login shell) flag was dropped: login shells load the full user +/// profile on every invocation which is slow and may cause side effects. +/// +/// The command is configured with: +/// - `current_dir` set to the workspace +/// - `stdin` piped to `/dev/null` (no interactive input) +/// - `stdout` and `stderr` piped for capture +/// - `kill_on_drop(true)` for safe timeout handling +fn build_cron_shell_command( + command: &str, + workspace_dir: &std::path::Path, +) -> anyhow::Result { + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(command) + .current_dir(workspace_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + Ok(cmd) +} + #[cfg(test)] mod tests { use super::*; @@ -1152,4 +1217,50 @@ mod tests { .to_string() .contains("matrix delivery channel requires `channel-matrix` feature")); } + + #[test] + fn build_cron_shell_command_uses_sh_non_login() { + let workspace = std::env::temp_dir(); + let cmd = build_cron_shell_command("echo cron-test", &workspace).unwrap(); + let debug = format!("{cmd:?}"); + assert!(debug.contains("echo cron-test")); + assert!(debug.contains("\"sh\""), "should use sh: {debug}"); + // Must NOT use login shell (-l) — login shells load full profile + // and are slow/unpredictable for cron jobs. + assert!( + !debug.contains("\"-lc\""), + "must not use login shell: {debug}" + ); + } + + #[tokio::test] + async fn build_cron_shell_command_executes_successfully() { + let workspace = std::env::temp_dir(); + let mut cmd = build_cron_shell_command("echo cron-ok", &workspace).unwrap(); + let output = cmd.output().await.unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("cron-ok")); + } + + #[tokio::test] + async fn catch_up_queries_all_overdue_jobs_ignoring_max_tasks() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp).await; + config.scheduler.max_tasks = 1; // limit normal polling to 1 + + // Create 3 jobs with "every minute" schedule + for i in 0..3 { + let _ = cron::add_job(&config, "* * * * *", &format!("echo catchup-{i}")).unwrap(); + } + + // Verify normal due_jobs is limited to max_tasks=1 + let far_future = Utc::now() + ChronoDuration::days(1); + let due = cron::due_jobs(&config, far_future).unwrap(); + assert_eq!(due.len(), 1, "due_jobs must respect max_tasks"); + + // all_overdue_jobs ignores the limit + let overdue = cron::all_overdue_jobs(&config, far_future).unwrap(); + assert_eq!(overdue.len(), 3, "all_overdue_jobs must return all"); + } } diff --git a/src/cron/store.rs b/src/cron/store.rs index 176c34ad6..887317aa9 100644 --- a/src/cron/store.rs +++ b/src/cron/store.rs @@ -188,6 +188,34 @@ pub fn due_jobs(config: &Config, now: DateTime) -> Result> { }) } +/// Return **all** enabled overdue jobs without the `max_tasks` limit. +/// +/// Used by the scheduler startup catch-up to ensure every missed job is +/// executed at least once after a period of downtime (late boot, daemon +/// restart, etc.). +pub fn all_overdue_jobs(config: &Config, now: DateTime) -> Result> { + with_connection(config, |conn| { + let mut stmt = conn.prepare( + "SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model, + enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output + FROM cron_jobs + WHERE enabled = 1 AND next_run <= ?1 + ORDER BY next_run ASC", + )?; + + let rows = stmt.query_map(params![now.to_rfc3339()], map_cron_job_row)?; + + let mut jobs = Vec::new(); + for row in rows { + match row { + Ok(job) => jobs.push(job), + Err(e) => tracing::warn!("Skipping cron job with unparseable row data: {e}"), + } + } + Ok(jobs) + }) +} + pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result { let mut job = get_job(config, job_id)?; let mut schedule_changed = false; @@ -704,6 +732,46 @@ mod tests { assert_eq!(due.len(), 2); } + #[test] + fn all_overdue_jobs_ignores_max_tasks_limit() { + let tmp = TempDir::new().unwrap(); + let mut config = test_config(&tmp); + config.scheduler.max_tasks = 2; + + let _ = add_job(&config, "* * * * *", "echo ov-1").unwrap(); + let _ = add_job(&config, "* * * * *", "echo ov-2").unwrap(); + let _ = add_job(&config, "* * * * *", "echo ov-3").unwrap(); + + let far_future = Utc::now() + ChronoDuration::days(365); + // due_jobs respects the limit + let due = due_jobs(&config, far_future).unwrap(); + assert_eq!(due.len(), 2); + // all_overdue_jobs returns everything + let overdue = all_overdue_jobs(&config, far_future).unwrap(); + assert_eq!(overdue.len(), 3); + } + + #[test] + fn all_overdue_jobs_excludes_disabled_jobs() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let job = add_job(&config, "* * * * *", "echo disabled").unwrap(); + let _ = update_job( + &config, + &job.id, + CronJobPatch { + enabled: Some(false), + ..CronJobPatch::default() + }, + ) + .unwrap(); + + let far_future = Utc::now() + ChronoDuration::days(365); + let overdue = all_overdue_jobs(&config, far_future).unwrap(); + assert!(overdue.is_empty()); + } + #[test] fn reschedule_after_run_persists_last_status_and_last_run() { let tmp = TempDir::new().unwrap(); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 4a2e2b8c6..179dd7a1d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -315,7 +315,10 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> { // ── Phase 1: LLM decision (two-phase mode) ────────────── let tasks_to_run = if two_phase { - let decision_prompt = HeartbeatEngine::build_decision_prompt(&tasks); + let decision_prompt = format!( + "[Heartbeat Task | decision] {}", + HeartbeatEngine::build_decision_prompt(&tasks), + ); match Box::pin(crate::agent::run( config.clone(), Some(decision_prompt), diff --git a/src/gateway/api.rs b/src/gateway/api.rs index ecae7026f..988c8d947 100644 --- a/src/gateway/api.rs +++ b/src/gateway/api.rs @@ -357,6 +357,65 @@ pub async fn handle_api_cron_delete( } } +/// GET /api/cron/settings — return cron subsystem settings +pub async fn handle_api_cron_settings_get( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + + let config = state.config.lock().clone(); + Json(serde_json::json!({ + "enabled": config.cron.enabled, + "catch_up_on_startup": config.cron.catch_up_on_startup, + "max_run_history": config.cron.max_run_history, + })) + .into_response() +} + +/// PATCH /api/cron/settings — update cron subsystem settings +pub async fn handle_api_cron_settings_patch( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + + let mut config = state.config.lock().clone(); + + if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) { + config.cron.enabled = v; + } + if let Some(v) = body.get("catch_up_on_startup").and_then(|v| v.as_bool()) { + config.cron.catch_up_on_startup = v; + } + if let Some(v) = body.get("max_run_history").and_then(|v| v.as_u64()) { + config.cron.max_run_history = u32::try_from(v).unwrap_or(u32::MAX); + } + + if let Err(e) = config.save().await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to save config: {e}")})), + ) + .into_response(); + } + + *state.config.lock() = config.clone(); + + Json(serde_json::json!({ + "status": "ok", + "enabled": config.cron.enabled, + "catch_up_on_startup": config.cron.catch_up_on_startup, + "max_run_history": config.cron.max_run_history, + })) + .into_response() +} + /// GET /api/integrations — list all integrations with status pub async fn handle_api_integrations( State(state): State, diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 0ab7bd463..2afe360cf 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -766,6 +766,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { .route("/api/tools", get(api::handle_api_tools)) .route("/api/cron", get(api::handle_api_cron_list)) .route("/api/cron", post(api::handle_api_cron_add)) + .route( + "/api/cron/settings", + get(api::handle_api_cron_settings_get).patch(api::handle_api_cron_settings_patch), + ) .route("/api/cron/{id}", delete(api::handle_api_cron_delete)) .route("/api/cron/{id}/runs", get(api::handle_api_cron_runs)) .route("/api/integrations", get(api::handle_api_integrations)) diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 4a3395c67..c4facf257 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -101,6 +101,7 @@ pub fn should_skip_autosave_content(content: &str) -> bool { let lowered = normalized.to_ascii_lowercase(); lowered.starts_with("[cron:") + || lowered.starts_with("[heartbeat task") || lowered.starts_with("[distilled_") || lowered.contains("distilled_index_sig:") } @@ -471,6 +472,12 @@ mod tests { assert!(should_skip_autosave_content( "[DISTILLED_MEMORY_CHUNK 1/2] DISTILLED_INDEX_SIG:abc123" )); + assert!(should_skip_autosave_content( + "[Heartbeat Task | decision] Should I run tasks?" + )); + assert!(should_skip_autosave_content( + "[Heartbeat Task | high] Execute scheduled patrol" + )); assert!(!should_skip_autosave_content( "User prefers concise answers." )); diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index dc664a33a..91f611416 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -463,6 +463,47 @@ fn resolve_quick_setup_dirs_with_home(home: &Path) -> (PathBuf, PathBuf) { (config_dir.clone(), config_dir.join("workspace")) } +fn homebrew_prefix_for_exe(exe: &Path) -> Option<&'static str> { + let exe = exe.to_string_lossy(); + if exe == "/opt/homebrew/bin/zeroclaw" + || exe.starts_with("/opt/homebrew/Cellar/zeroclaw/") + || exe.starts_with("/opt/homebrew/opt/zeroclaw/") + { + return Some("/opt/homebrew"); + } + + if exe == "/usr/local/bin/zeroclaw" + || exe.starts_with("/usr/local/Cellar/zeroclaw/") + || exe.starts_with("/usr/local/opt/zeroclaw/") + { + return Some("/usr/local"); + } + + None +} + +fn quick_setup_homebrew_service_note( + config_path: &Path, + workspace_dir: &Path, + exe: &Path, +) -> Option { + let prefix = homebrew_prefix_for_exe(exe)?; + let service_root = Path::new(prefix).join("var").join("zeroclaw"); + let service_config = service_root.join("config.toml"); + let service_workspace = service_root.join("workspace"); + + if config_path == service_config || workspace_dir == service_workspace { + return None; + } + + Some(format!( + "Homebrew service note: `brew services` uses {} (config {}) by default. Your onboarding just wrote {}. If you plan to run ZeroClaw as a service, copy or link this workspace first.", + service_workspace.display(), + service_config.display(), + config_path.display(), + )) +} + #[allow(clippy::too_many_lines)] async fn run_quick_setup_with_home( credential_override: Option<&str>, @@ -650,6 +691,16 @@ async fn run_quick_setup_with_home( style("Config saved:").white().bold(), style(config_path.display()).green() ); + if cfg!(target_os = "macos") { + if let Ok(exe) = std::env::current_exe() { + if let Some(note) = + quick_setup_homebrew_service_note(&config_path, &workspace_dir, &exe) + { + println!(); + println!(" {}", style(note).yellow()); + } + } + } println!(); println!(" {}", style("Next steps:").white().bold()); if credential_override.is_none() { @@ -6066,6 +6117,52 @@ mod tests { assert_eq!(config.config_path, expected_config_path); } + #[test] + fn homebrew_prefix_for_exe_detects_supported_layouts() { + assert_eq!( + homebrew_prefix_for_exe(Path::new("/opt/homebrew/bin/zeroclaw")), + Some("/opt/homebrew") + ); + assert_eq!( + homebrew_prefix_for_exe(Path::new( + "/opt/homebrew/Cellar/zeroclaw/0.5.0/bin/zeroclaw", + )), + Some("/opt/homebrew") + ); + assert_eq!( + homebrew_prefix_for_exe(Path::new("/usr/local/bin/zeroclaw")), + Some("/usr/local") + ); + assert_eq!(homebrew_prefix_for_exe(Path::new("/tmp/zeroclaw")), None); + } + + #[test] + fn quick_setup_homebrew_service_note_mentions_service_workspace() { + let note = quick_setup_homebrew_service_note( + Path::new("/Users/alix/.zeroclaw/config.toml"), + Path::new("/Users/alix/.zeroclaw/workspace"), + Path::new("/opt/homebrew/bin/zeroclaw"), + ) + .expect("homebrew installs should emit a service workspace note"); + + assert!(note.contains("/opt/homebrew/var/zeroclaw/workspace")); + assert!(note.contains("/opt/homebrew/var/zeroclaw/config.toml")); + assert!(note.contains("/Users/alix/.zeroclaw/config.toml")); + } + + #[test] + fn quick_setup_homebrew_service_note_skips_matching_service_layout() { + let service_config = Path::new("/opt/homebrew/var/zeroclaw/config.toml"); + let service_workspace = Path::new("/opt/homebrew/var/zeroclaw/workspace"); + + assert!(quick_setup_homebrew_service_note( + service_config, + service_workspace, + Path::new("/opt/homebrew/bin/zeroclaw"), + ) + .is_none()); + } + // ── scaffold_workspace: basic file creation ───────────────── #[tokio::test] diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index a93cad476..03f30fc06 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -211,9 +211,9 @@ impl AnthropicProvider { text.len() > 3072 } - /// Cache conversations with more than 4 messages (excluding system) + /// Cache conversations with more than 1 non-system message (i.e. after first exchange) fn should_cache_conversation(messages: &[ChatMessage]) -> bool { - messages.iter().filter(|m| m.role != "system").count() > 4 + messages.iter().filter(|m| m.role != "system").count() > 1 } /// Apply cache control to the last message content block @@ -447,17 +447,13 @@ impl AnthropicProvider { } } - // Convert system text to SystemPrompt with cache control if large + // Always use Blocks format with cache_control for system prompts let system_prompt = system_text.map(|text| { - if Self::should_cache_system(&text) { - SystemPrompt::Blocks(vec![SystemBlock { - block_type: "text".to_string(), - text, - cache_control: Some(CacheControl::ephemeral()), - }]) - } else { - SystemPrompt::String(text) - } + SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text, + cache_control: Some(CacheControl::ephemeral()), + }]) }); (system_prompt, native_messages) @@ -1063,12 +1059,8 @@ mod tests { role: "user".to_string(), content: "Hello".to_string(), }, - ChatMessage { - role: "assistant".to_string(), - content: "Hi".to_string(), - }, ]; - // Only 2 non-system messages + // Only 1 non-system message — should not cache assert!(!AnthropicProvider::should_cache_conversation(&messages)); } @@ -1078,8 +1070,8 @@ mod tests { role: "system".to_string(), content: "System prompt".to_string(), }]; - // Add 5 non-system messages - for i in 0..5 { + // Add 3 non-system messages + for i in 0..3 { messages.push(ChatMessage { role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(), content: format!("Message {i}"), @@ -1090,21 +1082,24 @@ mod tests { #[test] fn should_cache_conversation_boundary() { - let mut messages = vec![]; - // Add exactly 4 non-system messages - for i in 0..4 { - messages.push(ChatMessage { - role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(), - content: format!("Message {i}"), - }); - } + let messages = vec![ChatMessage { + role: "user".to_string(), + content: "Hello".to_string(), + }]; + // Exactly 1 non-system message — should not cache assert!(!AnthropicProvider::should_cache_conversation(&messages)); - // Add one more to cross boundary - messages.push(ChatMessage { - role: "user".to_string(), - content: "One more".to_string(), - }); + // Add one more to cross boundary (>1) + let messages = vec![ + ChatMessage { + role: "user".to_string(), + content: "Hello".to_string(), + }, + ChatMessage { + role: "assistant".to_string(), + content: "Hi".to_string(), + }, + ]; assert!(AnthropicProvider::should_cache_conversation(&messages)); } @@ -1217,7 +1212,7 @@ mod tests { } #[test] - fn convert_messages_small_system_prompt() { + fn convert_messages_small_system_prompt_uses_blocks_with_cache() { let messages = vec![ChatMessage { role: "system".to_string(), content: "Short system prompt".to_string(), @@ -1226,10 +1221,17 @@ mod tests { let (system_prompt, _) = AnthropicProvider::convert_messages(&messages); match system_prompt.unwrap() { - SystemPrompt::String(s) => { - assert_eq!(s, "Short system prompt"); + SystemPrompt::Blocks(blocks) => { + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "Short system prompt"); + assert!( + blocks[0].cache_control.is_some(), + "Small system prompts should have cache_control" + ); + } + SystemPrompt::String(_) => { + panic!("Expected Blocks variant with cache_control for small prompt") } - SystemPrompt::Blocks(_) => panic!("Expected String variant for small prompt"), } } @@ -1254,12 +1256,16 @@ mod tests { } #[test] - fn backward_compatibility_native_chat_request() { - // Test that requests without cache_control serialize identically to old format + fn native_chat_request_with_blocks_system() { + // System prompts now always use Blocks format with cache_control let req = NativeChatRequest { model: "claude-3-opus".to_string(), max_tokens: 4096, - system: Some(SystemPrompt::String("System".to_string())), + system: Some(SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: "System".to_string(), + cache_control: Some(CacheControl::ephemeral()), + }])), messages: vec![NativeMessage { role: "user".to_string(), content: vec![NativeContentOut::Text { @@ -1272,8 +1278,11 @@ mod tests { }; let json = serde_json::to_string(&req).unwrap(); - assert!(!json.contains("cache_control")); - assert!(json.contains(r#""system":"System""#)); + assert!(json.contains("System")); + assert!( + json.contains(r#""cache_control":{"type":"ephemeral"}"#), + "System prompt should include cache_control" + ); } #[tokio::test] diff --git a/src/providers/mod.rs b/src/providers/mod.rs index d6e185782..bfc788cb8 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1119,7 +1119,13 @@ fn create_provider_with_url_and_options( )?)) } // ── Primary providers (custom implementations) ─────── - "openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))), + "openrouter" => { + let mut p = openrouter::OpenRouterProvider::new(key); + if let Some(t) = options.provider_timeout_secs { + p = p.with_timeout_secs(t); + } + Ok(Box::new(p)) + } "anthropic" => Ok(Box::new(anthropic::AnthropicProvider::new(key))), "openai" => Ok(Box::new(openai::OpenAiProvider::with_base_url(api_url, key))), // Ollama uses api_url for custom base URL (e.g. remote Ollama instance) diff --git a/src/providers/openrouter.rs b/src/providers/openrouter.rs index c1bbdca0b..855416aae 100644 --- a/src/providers/openrouter.rs +++ b/src/providers/openrouter.rs @@ -4,12 +4,14 @@ use crate::providers::traits::{ Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall, }; use crate::tools::ToolSpec; +use anyhow::Context as _; use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; pub struct OpenRouterProvider { credential: Option, + timeout_secs: u64, } #[derive(Debug, Serialize)] @@ -149,9 +151,16 @@ impl OpenRouterProvider { pub fn new(credential: Option<&str>) -> Self { Self { credential: credential.map(ToString::to_string), + timeout_secs: 120, } } + /// Override the HTTP request timeout for LLM API calls. + pub fn with_timeout_secs(mut self, secs: u64) -> Self { + self.timeout_secs = secs; + self + } + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { let items = tools?; if items.is_empty() { @@ -296,7 +305,11 @@ impl OpenRouterProvider { } fn http_client(&self) -> Client { - crate::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10) + crate::config::build_runtime_proxy_client_with_timeouts( + "provider.openrouter", + self.timeout_secs, + 10, + ) } } @@ -368,7 +381,13 @@ impl Provider for OpenRouterProvider { return Err(super::api_error("OpenRouter", response).await); } - let chat_response: ApiChatResponse = response.json().await?; + let text = response.text().await?; + let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| { + format!( + "OpenRouter: failed to decode response body: {}", + &text[..text.len().min(500)] + ) + })?; chat_response .choices @@ -415,7 +434,13 @@ impl Provider for OpenRouterProvider { return Err(super::api_error("OpenRouter", response).await); } - let chat_response: ApiChatResponse = response.json().await?; + let text = response.text().await?; + let chat_response: ApiChatResponse = serde_json::from_str(&text).with_context(|| { + format!( + "OpenRouter: failed to decode response body: {}", + &text[..text.len().min(500)] + ) + })?; chat_response .choices @@ -460,7 +485,14 @@ impl Provider for OpenRouterProvider { return Err(super::api_error("OpenRouter", response).await); } - let native_response: NativeChatResponse = response.json().await?; + let text = response.text().await?; + let native_response: NativeChatResponse = + serde_json::from_str(&text).with_context(|| { + format!( + "OpenRouter: failed to decode response body: {}", + &text[..text.len().min(500)] + ) + })?; let usage = native_response.usage.map(|u| TokenUsage { input_tokens: u.prompt_tokens, output_tokens: u.completion_tokens, @@ -552,7 +584,14 @@ impl Provider for OpenRouterProvider { return Err(super::api_error("OpenRouter", response).await); } - let native_response: NativeChatResponse = response.json().await?; + let text = response.text().await?; + let native_response: NativeChatResponse = + serde_json::from_str(&text).with_context(|| { + format!( + "OpenRouter: failed to decode response body: {}", + &text[..text.len().min(500)] + ) + })?; let usage = native_response.usage.map(|u| TokenUsage { input_tokens: u.prompt_tokens, output_tokens: u.completion_tokens, @@ -1017,4 +1056,20 @@ mod tests { assert!(json.contains("reasoning_content")); assert!(json.contains("thinking...")); } + + // ═══════════════════════════════════════════════════════════════════════ + // timeout_secs configuration tests + // ═══════════════════════════════════════════════════════════════════════ + + #[test] + fn default_timeout_is_120() { + let provider = OpenRouterProvider::new(Some("key")); + assert_eq!(provider.timeout_secs, 120); + } + + #[test] + fn with_timeout_secs_overrides_default() { + let provider = OpenRouterProvider::new(Some("key")).with_timeout_secs(300); + assert_eq!(provider.timeout_secs, 300); + } } diff --git a/src/providers/reliable.rs b/src/providers/reliable.rs index 66c095948..65d399b9b 100644 --- a/src/providers/reliable.rs +++ b/src/providers/reliable.rs @@ -22,6 +22,13 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool { return false; } + // Tool schema validation errors are NOT non-retryable — the provider's + // built-in fallback in compatible.rs can recover by switching to + // prompt-guided tool instructions. + if is_tool_schema_error(err) { + return false; + } + // 4xx errors are generally non-retryable (bad request, auth failure, etc.), // except 429 (rate-limit — transient) and 408 (timeout — worth retrying). if let Some(reqwest_err) = err.downcast_ref::() { @@ -73,6 +80,22 @@ pub fn is_non_retryable(err: &anyhow::Error) -> bool { || msg_lower.contains("invalid")) } +/// Check if an error is a tool schema validation failure (e.g. Groq returning +/// "tool call validation failed: attempted to call tool '...' which was not in request"). +/// These errors should NOT be classified as non-retryable because the provider's +/// built-in fallback logic (`compatible.rs::is_native_tool_schema_unsupported`) +/// can recover by switching to prompt-guided tool instructions. +pub fn is_tool_schema_error(err: &anyhow::Error) -> bool { + let lower = err.to_string().to_lowercase(); + let hints = [ + "tool call validation failed", + "was not in request", + "not found in tool list", + "invalid_tool_call", + ]; + hints.iter().any(|hint| lower.contains(hint)) +} + fn is_context_window_exceeded(err: &anyhow::Error) -> bool { let lower = err.to_string().to_lowercase(); let hints = [ @@ -2189,4 +2212,55 @@ mod tests { // Should have been called twice: once with full messages, once with truncated assert_eq!(calls.load(Ordering::SeqCst), 2); } + + // ── Tool schema error detection tests ─────────────────────────────── + + #[test] + fn tool_schema_error_detects_groq_validation_failure() { + let msg = r#"Groq API error (400 Bad Request): {"error":{"message":"tool call validation failed: attempted to call tool 'memory_recall' which was not in request"}}"#; + let err = anyhow::anyhow!("{}", msg); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_detects_not_in_request() { + let err = anyhow::anyhow!("tool 'search' was not in request"); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_detects_not_found_in_tool_list() { + let err = anyhow::anyhow!("function 'foo' not found in tool list"); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_detects_invalid_tool_call() { + let err = anyhow::anyhow!("invalid_tool_call: no matching function"); + assert!(is_tool_schema_error(&err)); + } + + #[test] + fn tool_schema_error_ignores_unrelated_errors() { + let err = anyhow::anyhow!("invalid api key"); + assert!(!is_tool_schema_error(&err)); + + let err = anyhow::anyhow!("model not found"); + assert!(!is_tool_schema_error(&err)); + } + + #[test] + fn non_retryable_returns_false_for_tool_schema_400() { + // A 400 error with tool schema validation text should NOT be non-retryable. + let msg = "400 Bad Request: tool call validation failed: attempted to call tool 'x' which was not in request"; + let err = anyhow::anyhow!("{}", msg); + assert!(!is_non_retryable(&err)); + } + + #[test] + fn non_retryable_returns_true_for_other_400_errors() { + // A regular 400 error (e.g. invalid API key) should still be non-retryable. + let err = anyhow::anyhow!("400 Bad Request: invalid api key provided"); + assert!(is_non_retryable(&err)); + } } diff --git a/src/skills/audit.rs b/src/skills/audit.rs index 64fd9b2b0..15464fd65 100644 --- a/src/skills/audit.rs +++ b/src/skills/audit.rs @@ -409,13 +409,43 @@ fn has_shell_shebang(path: &Path) -> bool { return false; }; let prefix = &content[..content.len().min(128)]; - let shebang = String::from_utf8_lossy(prefix).to_ascii_lowercase(); - shebang.starts_with("#!") - && (shebang.contains("sh") - || shebang.contains("bash") - || shebang.contains("zsh") - || shebang.contains("pwsh") - || shebang.contains("powershell")) + let shebang_line = String::from_utf8_lossy(prefix) + .lines() + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + let Some(interpreter) = shebang_interpreter(&shebang_line) else { + return false; + }; + + matches!( + interpreter, + "sh" | "bash" | "zsh" | "ksh" | "fish" | "pwsh" | "powershell" + ) +} + +fn shebang_interpreter(line: &str) -> Option<&str> { + let shebang = line.strip_prefix("#!")?.trim(); + if shebang.is_empty() { + return None; + } + + let mut parts = shebang.split_whitespace(); + let first = parts.next()?; + let first_basename = Path::new(first).file_name()?.to_str()?; + + if first_basename == "env" { + for part in parts { + if part.starts_with('-') { + continue; + } + return Path::new(part).file_name()?.to_str(); + } + return None; + } + + Some(first_basename) } fn extract_markdown_links(content: &str) -> Vec { @@ -586,6 +616,30 @@ mod tests { ); } + #[test] + fn audit_allows_python_shebang_file_when_early_text_contains_sh() { + let dir = tempfile::tempdir().unwrap(); + let skill_dir = dir.path().join("python-helper"); + let scripts_dir = skill_dir.join("scripts"); + std::fs::create_dir_all(&scripts_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap(); + std::fs::write( + scripts_dir.join("helper.py"), + "#!/usr/bin/env python3\n\"\"\"Refresh report cache.\"\"\"\n\nprint(\"ok\")\n", + ) + .unwrap(); + + let report = audit_skill_directory(&skill_dir).unwrap(); + assert!( + !report + .findings + .iter() + .any(|finding| finding.contains("script-like files are blocked")), + "{:#?}", + report.findings + ); + } + #[test] fn audit_rejects_markdown_escape_links() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index 30c4aa0eb..8363209bb 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -418,6 +418,7 @@ impl DelegateTool { true, None, "delegate", + None, &self.multimodal_config, agent_config.max_iterations, None, diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d6880aac4..a4eb637b5 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -146,7 +146,7 @@ pub use workspace_tool::WorkspaceTool; use crate::config::{Config, DelegateAgentConfig}; use crate::memory::Memory; use crate::runtime::{NativeRuntime, RuntimeAdapter}; -use crate::security::SecurityPolicy; +use crate::security::{create_sandbox, SecurityPolicy}; use async_trait::async_trait; use parking_lot::RwLock; use std::collections::HashMap; @@ -283,8 +283,13 @@ pub fn all_tools_with_runtime( root_config: &crate::config::Config, ) -> (Vec>, Option) { let has_shell_access = runtime.has_shell_access(); + let sandbox = create_sandbox(&root_config.security); let mut tool_arcs: Vec> = vec![ - Arc::new(ShellTool::new(security.clone(), runtime)), + Arc::new(ShellTool::new_with_sandbox( + security.clone(), + runtime, + sandbox, + )), Arc::new(FileReadTool::new(security.clone())), Arc::new(FileWriteTool::new(security.clone())), Arc::new(FileEditTool::new(security.clone())), diff --git a/src/tools/shell.rs b/src/tools/shell.rs index a03769a55..5867d907f 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -1,5 +1,6 @@ use super::traits::{Tool, ToolResult}; use crate::runtime::RuntimeAdapter; +use crate::security::traits::Sandbox; use crate::security::SecurityPolicy; use async_trait::async_trait; use serde_json::json; @@ -44,11 +45,28 @@ const SAFE_ENV_VARS: &[&str] = &[ pub struct ShellTool { security: Arc, runtime: Arc, + sandbox: Arc, } impl ShellTool { pub fn new(security: Arc, runtime: Arc) -> Self { - Self { security, runtime } + Self { + security, + runtime, + sandbox: Arc::new(crate::security::NoopSandbox), + } + } + + pub fn new_with_sandbox( + security: Arc, + runtime: Arc, + sandbox: Arc, + ) -> Self { + Self { + security, + runtime, + sandbox, + } } } @@ -169,6 +187,14 @@ impl Tool for ShellTool { }); } }; + + // Apply sandbox wrapping before execution. + // The Sandbox trait operates on std::process::Command, so use as_std_mut() + // to get a mutable reference to the underlying command. + self.sandbox + .wrap_command(cmd.as_std_mut()) + .map_err(|e| anyhow::anyhow!("Sandbox error: {}", e))?; + cmd.env_clear(); for var in collect_allowed_shell_env_vars(&self.security) { @@ -690,4 +716,59 @@ mod tests { || r2.error.as_deref().unwrap_or("").contains("budget") ); } + + // ── Sandbox integration tests ──────────────────────── + + #[test] + fn shell_tool_can_be_constructed_with_sandbox() { + use crate::security::NoopSandbox; + + let sandbox: Arc = Arc::new(NoopSandbox); + let tool = ShellTool::new_with_sandbox( + test_security(AutonomyLevel::Supervised), + test_runtime(), + sandbox, + ); + assert_eq!(tool.name(), "shell"); + } + + #[test] + fn noop_sandbox_does_not_modify_command() { + use crate::security::NoopSandbox; + + let sandbox = NoopSandbox; + let mut cmd = std::process::Command::new("echo"); + cmd.arg("hello"); + + let program_before = cmd.get_program().to_os_string(); + let args_before: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect(); + + sandbox + .wrap_command(&mut cmd) + .expect("wrap_command should succeed"); + + assert_eq!(cmd.get_program(), program_before); + assert_eq!( + cmd.get_args().map(|a| a.to_os_string()).collect::>(), + args_before + ); + } + + #[tokio::test] + async fn shell_executes_with_sandbox() { + use crate::security::NoopSandbox; + + let sandbox: Arc = Arc::new(NoopSandbox); + let tool = ShellTool::new_with_sandbox( + test_security(AutonomyLevel::Supervised), + test_runtime(), + sandbox, + ); + let result = tool + .execute(json!({"command": "echo sandbox_test"})) + .await + .expect("command with sandbox should succeed"); + assert!(result.success); + assert!(result.output.contains("sandbox_test")); + } } diff --git a/web/dist/logo.png b/web/dist/logo.png new file mode 100644 index 000000000..a76068f23 Binary files /dev/null and b/web/dist/logo.png differ diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 000000000..a76068f23 Binary files /dev/null and b/web/public/logo.png differ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 352af676b..791e7daf9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -193,6 +193,25 @@ export function getCronRuns( ).then((data) => unwrapField(data, 'runs')); } +export interface CronSettings { + enabled: boolean; + catch_up_on_startup: boolean; + max_run_history: number; +} + +export function getCronSettings(): Promise { + return apiFetch('/api/cron/settings'); +} + +export function patchCronSettings( + patch: Partial, +): Promise { + return apiFetch('/api/cron/settings', { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + // --------------------------------------------------------------------------- // Integrations // --------------------------------------------------------------------------- diff --git a/web/src/pages/Cron.tsx b/web/src/pages/Cron.tsx index 8a09d79f3..a12a501d5 100644 --- a/web/src/pages/Cron.tsx +++ b/web/src/pages/Cron.tsx @@ -12,7 +12,15 @@ import { RefreshCw, } from 'lucide-react'; import type { CronJob, CronRun } from '@/types/api'; -import { getCronJobs, addCronJob, deleteCronJob, getCronRuns } from '@/lib/api'; +import { + getCronJobs, + addCronJob, + deleteCronJob, + getCronRuns, + getCronSettings, + patchCronSettings, +} from '@/lib/api'; +import type { CronSettings } from '@/lib/api'; import { t } from '@/lib/i18n'; function formatDate(iso: string | null): string { @@ -143,6 +151,8 @@ export default function Cron() { const [showForm, setShowForm] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); const [expandedJob, setExpandedJob] = useState(null); + const [settings, setSettings] = useState(null); + const [togglingCatchUp, setTogglingCatchUp] = useState(false); // Form state const [formName, setFormName] = useState(''); @@ -159,8 +169,28 @@ export default function Cron() { .finally(() => setLoading(false)); }; + const fetchSettings = () => { + getCronSettings().then(setSettings).catch(() => {}); + }; + + const toggleCatchUp = async () => { + if (!settings) return; + setTogglingCatchUp(true); + try { + const updated = await patchCronSettings({ + catch_up_on_startup: !settings.catch_up_on_startup, + }); + setSettings(updated); + } catch { + // silently fail — user can retry + } finally { + setTogglingCatchUp(false); + } + }; + useEffect(() => { fetchJobs(); + fetchSettings(); }, []); const handleAdd = async () => { @@ -250,6 +280,37 @@ export default function Cron() { + {/* Catch-up toggle */} + {settings && ( +
+
+ + Catch up missed jobs on startup + +

+ Run all overdue jobs when ZeroClaw starts after downtime +

+
+ +
+ )} + {/* Add Job Form Modal */} {showForm && (