merge: resolve conflicts with master, include channel-lark in RELEASE_CARGO_FEATURES

Add channel-lark (merged to master separately) to RELEASE_CARGO_FEATURES
env var. Keep the DRY env-var approach and remove stale Docker build-args.
This commit is contained in:
argenis de la rosa 2026-03-19 14:24:30 -04:00
commit 3ce7f2345e
33 changed files with 1294 additions and 131 deletions

View File

@ -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 }}

View File

@ -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
cat > ~/.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"

View File

@ -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:

View File

@ -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:

2
Cargo.lock generated
View File

@ -9164,7 +9164,7 @@ dependencies = [
[[package]]
name = "zeroclawlabs"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"async-imap",

View File

@ -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"

View File

@ -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 \

View File

@ -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 \

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
<img src="docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
</p>
<h1 align="center">ZeroClaw 🦀</h1>
@ -16,6 +16,7 @@
<a href="https://x.com/zeroclawlabs?s=21"><img src="https://img.shields.io/badge/X-%40zeroclawlabs-000000?style=flat&logo=x&logoColor=white" alt="X: @zeroclawlabs" /></a>
<a href="https://www.facebook.com/groups/zeroclawlabs"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></a>
<a href="https://discord.com/invite/wDshRVqRjx"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://www.instagram.com/therealzeroclaw"><img src="https://img.shields.io/badge/Instagram-%40therealzeroclaw-E4405F?style=flat&logo=instagram&logoColor=white" alt="Instagram: @therealzeroclaw" /></a>
<a href="https://www.tiktok.com/@zeroclawlabs"><img src="https://img.shields.io/badge/TikTok-%40zeroclawlabs-000000?style=flat&logo=tiktok&logoColor=white" alt="TikTok: @zeroclawlabs" /></a>
<a href="https://www.rednote.com/user/profile/69b735e6000000002603927e"><img src="https://img.shields.io/badge/RedNote-Official-FF2442?style=flat" alt="RedNote" /></a>
<a href="https://www.reddit.com/r/zeroclawlabs/"><img src="https://img.shields.io/badge/Reddit-r%2Fzeroclawlabs-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/zeroclawlabs" /></a>

View File

@ -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/stdin) 2>/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 ]] && (: </proc/self/fd/0) 2>/dev/null; then
echo "/proc/self/fd/0"
return 0
fi
if (: </dev/tty) 2>/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/tty 2>/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

View File

@ -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<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
@ -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<CancellationToken>,
@ -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<dyn Memory> = 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<Mutex<Vec<serde_json::Value>>>,
}
impl RecordingArgsTool {
fn new(name: &str, recorded_args: Arc<Mutex<Vec<serde_json::Value>>>) -> 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<crate::tools::ToolResult> {
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#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"remind me later","schedule":{"kind":"every","every_ms":60000}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = 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#"<tool_call>
{"name":"cron_add","arguments":{"job_type":"agent","prompt":"run silently","schedule":{"kind":"every","every_ms":60000},"delivery":{"mode":"none"}}}
</tool_call>"#,
"done",
]);
let recorded_args = Arc::new(Mutex::new(Vec::new()));
let tools_registry: Vec<Box<dyn Tool>> = 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,

View File

@ -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<Arc<crate::hooks::HookRunner>>,
non_cli_excluded_tools: Arc<Vec<String>>,
autonomy_level: AutonomyLevel,
tool_call_dedup_exempt: Arc<Vec<String>>,
model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
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<usize>,
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("<available_skills>"), "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::<String>();
@ -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,

View File

@ -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<String>,
}
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<PathBuf> {
@ -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);
}

View File

@ -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,

View File

@ -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<SecurityPolicy>) {
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 <command>` (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<Command> {
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");
}
}

View File

@ -188,6 +188,34 @@ pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
})
}
/// 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<Utc>) -> Result<Vec<CronJob>> {
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<CronJob> {
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();

View File

@ -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),

View File

@ -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<AppState>,
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<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> 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<AppState>,

View File

@ -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))

View File

@ -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."
));

View File

@ -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<String> {
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]

View File

@ -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]

View File

@ -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)

View File

@ -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<String>,
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<Vec<NativeToolSpec>> {
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);
}
}

View File

@ -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::<reqwest::Error>() {
@ -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));
}
}

View File

@ -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<String> {
@ -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();

View File

@ -418,6 +418,7 @@ impl DelegateTool {
true,
None,
"delegate",
None,
&self.multimodal_config,
agent_config.max_iterations,
None,

View File

@ -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<Box<dyn Tool>>, Option<DelegateParentToolsHandle>) {
let has_shell_access = runtime.has_shell_access();
let sandbox = create_sandbox(&root_config.security);
let mut tool_arcs: Vec<Arc<dyn Tool>> = 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())),

View File

@ -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<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
}
impl ShellTool {
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
Self { security, runtime }
Self {
security,
runtime,
sandbox: Arc::new(crate::security::NoopSandbox),
}
}
pub fn new_with_sandbox(
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
sandbox: Arc<dyn Sandbox>,
) -> 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<dyn Sandbox> = 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::<Vec<_>>(),
args_before
);
}
#[tokio::test]
async fn shell_executes_with_sandbox() {
use crate::security::NoopSandbox;
let sandbox: Arc<dyn Sandbox> = 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"));
}
}

BIN
web/dist/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -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<CronSettings> {
return apiFetch<CronSettings>('/api/cron/settings');
}
export function patchCronSettings(
patch: Partial<CronSettings>,
): Promise<CronSettings> {
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------

View File

@ -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<string | null>(null);
const [expandedJob, setExpandedJob] = useState<string | null>(null);
const [settings, setSettings] = useState<CronSettings | null>(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() {
</button>
</div>
{/* Catch-up toggle */}
{settings && (
<div className="glass-card px-4 py-3 flex items-center justify-between">
<div>
<span className="text-sm font-medium text-white">
Catch up missed jobs on startup
</span>
<p className="text-xs text-[#556080] mt-0.5">
Run all overdue jobs when ZeroClaw starts after downtime
</p>
</div>
<button
onClick={toggleCatchUp}
disabled={togglingCatchUp}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300 focus:outline-none ${
settings.catch_up_on_startup
? 'bg-[#0080ff]'
: 'bg-[#1a1a3e]'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform duration-300 ${
settings.catch_up_on_startup
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
)}
{/* Add Job Form Modal */}
{showForm && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">