fix(hands): add max_concurrent + timeout_secs fields + hand timeout enforcement
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

M3-04/M3-05 audit fixes:
- HandConfig: add max_concurrent (u32) and timeout_secs (u64) with serde defaults
- Kernel execute_hand: enforce timeout via tokio::time::timeout, cancel on expiry
- All 9 hand implementations: add max_concurrent: 0, timeout_secs: 0
- Agent createClone: pass soul field through to kernel
- Fix duplicate soul block in agent_create command
This commit is contained in:
iven
2026-04-04 18:41:15 +08:00
parent a644988ca3
commit 59f660b93b
14 changed files with 93 additions and 2 deletions

View File

@@ -29,6 +29,29 @@ pub struct HandConfig {
/// Whether the hand is enabled
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Maximum concurrent executions for this hand (0 = unlimited)
#[serde(default)]
pub max_concurrent: u32,
/// Timeout in seconds for each execution (0 = use HandContext default)
#[serde(default)]
pub timeout_secs: u64,
}
impl Default for HandConfig {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
description: String::new(),
needs_approval: false,
dependencies: Vec::new(),
input_schema: None,
tags: Vec::new(),
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
}
}
}
fn default_enabled() -> bool { true }

View File

@@ -153,6 +153,8 @@ impl BrowserHand {
})),
tags: vec!["automation".to_string(), "web".to_string(), "browser".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
}
}

View File

@@ -256,6 +256,8 @@ impl ClipHand {
})),
tags: vec!["video".to_string(), "media".to_string(), "editing".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
ffmpeg_path: Arc::new(RwLock::new(None)),
}

View File

@@ -163,6 +163,8 @@ impl CollectorHand {
})),
tags: vec!["data".to_string(), "collection".to_string(), "scraping".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))

View File

@@ -492,6 +492,8 @@ impl QuizHand {
})),
tags: vec!["assessment".to_string(), "education".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
state: Arc::new(RwLock::new(QuizState::default())),
quiz_generator: Arc::new(DefaultQuizGenerator),

View File

@@ -183,6 +183,8 @@ impl ResearcherHand {
})),
tags: vec!["research".to_string(), "web".to_string(), "search".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))

View File

@@ -171,6 +171,8 @@ impl SlideshowHand {
})),
tags: vec!["presentation".to_string(), "education".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
state: Arc::new(RwLock::new(SlideshowState::default())),
}

View File

@@ -164,6 +164,8 @@ impl SpeechHand {
})),
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
state: Arc::new(RwLock::new(SpeechState {
config: SpeechConfig::default(),

View File

@@ -272,6 +272,8 @@ impl TwitterHand {
})),
tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string(), "demo".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
credentials: Arc::new(RwLock::new(None)),
}

View File

@@ -195,6 +195,8 @@ impl WhiteboardHand {
})),
tags: vec!["presentation".to_string(), "education".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
canvas_width: 1920.0,

View File

@@ -51,12 +51,40 @@ impl Kernel {
let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
self.running_hand_runs.insert(run_id, cancel_flag.clone());
// Execute the hand
// Execute the hand (with optional timeout from HandConfig)
let context = HandContext::default();
let start = std::time::Instant::now();
let hand_result = self.hands.execute(hand_id, &context, input).await;
// Determine timeout: prefer HandConfig.timeout_secs, fallback to context default (300s)
let timeout_secs = self.hands.get_config(hand_id)
.await
.map(|c| if c.timeout_secs > 0 { c.timeout_secs } else { context.timeout_secs })
.unwrap_or(context.timeout_secs);
let hand_result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
self.hands.execute(hand_id, &context, input),
).await;
let duration = start.elapsed();
// Handle timeout
let hand_result = match hand_result {
Ok(result) => result,
Err(_) => {
// Timeout elapsed
cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed);
let mut run_update = run.clone();
run_update.status = HandRunStatus::Failed;
run_update.error = Some(format!("Hand execution timed out after {}s", timeout_secs));
run_update.completed_at = Some(chrono::Utc::now().to_rfc3339());
run_update.duration_ms = Some(duration.as_millis() as u64);
self.memory.update_hand_run(&run_update).await?;
self.running_hand_runs.remove(&run_id);
return Err(zclaw_types::ZclawError::Timeout(format!("Hand '{}' timed out after {}s", hand_id, timeout_secs)));
}
};
// Check if cancelled during execution
if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
let mut run_update = run.clone();