Skip to main content

skilllite_agent/skills/
executor.rs

1//! Skill execution: dispatches tool calls to sandbox runner.
2
3use anyhow::{Context, Result};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::path::Path;
7
8use super::usage_stats;
9use crate::high_risk;
10use crate::types::{EventSink, ToolResult};
11use skilllite_core::skill::metadata::{self, SkillMetadata};
12use skilllite_sandbox::runner::{ResourceLimits, SandboxConfig, SandboxLevel};
13
14use super::loader::sanitize_tool_name;
15use super::security::{compute_skill_hash, run_security_scan};
16use super::LoadedSkill;
17
18/// Execute a skill tool call. Dispatches to sandbox execution.
19/// When `entry_point_override` is `Some` and skill has no entry_point, use it (e.g. 大模型根据 SKILL.md 推理出的入口).
20pub fn execute_skill(
21    skill: &LoadedSkill,
22    tool_name: &str,
23    arguments: &str,
24    workspace: &Path,
25    event_sink: &mut dyn EventSink,
26    entry_point_override: Option<&str>,
27) -> ToolResult {
28    let result = execute_skill_inner(
29        skill,
30        tool_name,
31        arguments,
32        workspace,
33        event_sink,
34        entry_point_override,
35    );
36    match result {
37        Ok(content) => {
38            usage_stats::track_skill_execution(&skill.name, true);
39            ToolResult {
40                tool_call_id: String::new(),
41                tool_name: tool_name.to_string(),
42                content,
43                is_error: false,
44                counts_as_failure: false,
45            }
46        }
47        Err(e) => {
48            usage_stats::track_skill_execution(&skill.name, false);
49            ToolResult {
50                tool_call_id: String::new(),
51                tool_name: tool_name.to_string(),
52                content: format!("Error: {}", e),
53                is_error: true,
54                counts_as_failure: true,
55            }
56        }
57    }
58}
59
60// Session-level cache of confirmed skills (skill_name → code_hash).
61// Avoids re-scanning skills that were already confirmed in this session.
62// Thread-local because EventSink requires &mut (not shareable across threads).
63thread_local! {
64    static CONFIRMED_SKILLS: std::cell::RefCell<HashMap<String, String>> =
65        std::cell::RefCell::new(HashMap::new());
66}
67
68fn execute_skill_inner(
69    skill: &LoadedSkill,
70    tool_name: &str,
71    arguments: &str,
72    workspace: &Path,
73    event_sink: &mut dyn EventSink,
74    entry_point_override: Option<&str>,
75) -> Result<String> {
76    let skill_dir = &skill.skill_dir;
77    let mut metadata = skill.metadata.clone();
78    if let Some(msg) = skilllite_core::skill::denylist::deny_reason_for_skill_name(&metadata.name) {
79        anyhow::bail!(msg);
80    }
81    if metadata.entry_point.is_empty() {
82        if let Some(ep) = entry_point_override {
83            if !ep.is_empty() && skill_dir.join(ep).is_file() {
84                metadata.entry_point = ep.to_string();
85            }
86        }
87    }
88
89    // Phase 2.5: Multi-script tool routing
90    // If tool_name is in the multi_script_entries map, use that script as entry_point.
91    // Try exact match first, then normalized match (hyphens → underscores).
92    let multi_script_entry: Option<&String> =
93        skill.multi_script_entries.get(tool_name).or_else(|| {
94            skill
95                .multi_script_entries
96                .get(&sanitize_tool_name(tool_name))
97        });
98
99    // For Level 3: security scan + user confirmation
100    // Ported from Python `UnifiedExecutionService.execute_skill` L3 flow
101    let sandbox_level = SandboxLevel::from_env_or_cli(None);
102    if sandbox_level == SandboxLevel::Level3 {
103        let code_hash = compute_skill_hash(skill_dir, &metadata);
104
105        // Check session-level confirmation cache
106        let already_confirmed = CONFIRMED_SKILLS.with(|cache| {
107            let cache = cache.borrow();
108            cache.get(&skill.name) == Some(&code_hash)
109        });
110
111        if !already_confirmed {
112            // Run security scan on entry point
113            let scan_report = run_security_scan(skill_dir, &metadata);
114
115            let prompt = if let Some(report) = scan_report {
116                format!(
117                    "Skill '{}' security scan results:\n\n{}\n\nAllow execution?",
118                    skill.name, report
119                )
120            } else {
121                format!(
122                    "Skill '{}' wants to execute code (sandbox level 3). Allow?",
123                    skill.name
124                )
125            };
126
127            if !event_sink.on_confirmation_request(&prompt) {
128                return Ok("Execution cancelled by user.".to_string());
129            }
130
131            // Cache confirmation
132            CONFIRMED_SKILLS.with(|cache| {
133                cache.borrow_mut().insert(skill.name.clone(), code_hash);
134            });
135        }
136    }
137
138    // A11: 网络 skill 执行前确认
139    if high_risk::confirm_network() && metadata.network.enabled {
140        let network_cache_key = format!("{}:network", skill.name);
141        let already_network_confirmed = CONFIRMED_SKILLS.with(|cache| {
142            let cache = cache.borrow();
143            cache.get(&network_cache_key).is_some()
144        });
145        if !already_network_confirmed {
146            let msg = format!(
147                "⚠️ 网络访问确认\n\nSkill '{}' 将发起网络请求。\n\n确认执行?",
148                skill.name
149            );
150            if !event_sink.on_confirmation_request(&msg) {
151                return Ok("Execution cancelled: network skill not confirmed by user.".to_string());
152            }
153            CONFIRMED_SKILLS.with(|cache| {
154                cache
155                    .borrow_mut()
156                    .insert(network_cache_key, "confirmed".to_string());
157            });
158        }
159    }
160
161    // Setup environment
162    let cache_dir = skilllite_core::config::CacheConfig::cache_dir();
163    let env_spec = skilllite_core::EnvSpec::from_metadata(skill_dir, &metadata);
164    let env_path = skilllite_sandbox::env::builder::ensure_environment(
165        skill_dir,
166        &env_spec,
167        cache_dir.as_deref(),
168        None,
169        None,
170    )?;
171
172    let limits = ResourceLimits::from_env();
173
174    if metadata.is_bash_tool_skill() {
175        // Bash-tool skill: extract command from arguments
176        let args: Value = serde_json::from_str(arguments).context("Invalid arguments JSON")?;
177        let command = args
178            .get("command")
179            .and_then(|v| v.as_str())
180            .context("'command' is required for bash-tool skills")?;
181
182        let skill_patterns = metadata.get_bash_patterns();
183        let validator_patterns: Vec<skilllite_sandbox::bash_validator::BashToolPattern> =
184            skill_patterns
185                .into_iter()
186                .map(|p| skilllite_sandbox::bash_validator::BashToolPattern {
187                    command_prefix: p.command_prefix,
188                    raw_pattern: p.raw_pattern,
189                })
190                .collect();
191        skilllite_sandbox::bash_validator::validate_bash_command(command, &validator_patterns)
192            .map_err(|e| anyhow::anyhow!("Command validation failed: {}", e))?;
193
194        // Execute bash command (same logic as main.rs bash_command).
195        // Resolve the effective cwd: prefer SKILLLITE_OUTPUT_DIR so file outputs
196        // (screenshots, PDFs, etc.) land in the output directory automatically.
197        let effective_cwd = skilllite_core::config::PathsConfig::from_env()
198            .output_dir
199            .as_ref()
200            .map(std::path::PathBuf::from)
201            .filter(|p| p.is_dir())
202            .unwrap_or_else(|| workspace.to_path_buf());
203
204        execute_bash_in_skill(skill_dir, command, &env_path, &effective_cwd, workspace)
205    } else {
206        // Regular skill or multi-script tool: pass arguments as input JSON
207        let input_json = if arguments.trim().is_empty() || arguments.trim() == "{}" {
208            "{}".to_string()
209        } else {
210            arguments.to_string()
211        };
212
213        // Validate input JSON
214        let _: Value = serde_json::from_str(&input_json)
215            .map_err(|e| anyhow::anyhow!("Invalid input JSON: {}", e))?;
216
217        let effective_metadata = if let Some(ref entry) = multi_script_entry {
218            let mut m = metadata.clone();
219            m.entry_point = entry.to_string();
220            m
221        } else {
222            metadata
223        };
224
225        let runtime = skilllite_sandbox::env::builder::build_runtime_paths(&env_path);
226        let config = build_sandbox_config(skill_dir, &effective_metadata);
227        let output = skilllite_sandbox::runner::run_in_sandbox_with_limits_and_level(
228            skill_dir,
229            &runtime,
230            &config,
231            &input_json,
232            limits,
233            sandbox_level,
234        )?;
235        Ok(output)
236    }
237}
238
239/// Build a `SandboxConfig` from `SkillMetadata`, resolving language via `detect_language`.
240fn build_sandbox_config(skill_dir: &Path, metadata: &SkillMetadata) -> SandboxConfig {
241    SandboxConfig {
242        name: metadata.name.clone(),
243        entry_point: metadata.entry_point.clone(),
244        language: metadata::detect_language(skill_dir, metadata),
245        network_enabled: metadata.network.enabled,
246        network_outbound: metadata.network.outbound.clone(),
247        uses_playwright: metadata.uses_playwright(),
248    }
249}
250
251/// Execute a bash command in a skill's environment context.
252///
253/// `cwd` is the effective working directory for the command (typically
254/// `SKILLLITE_OUTPUT_DIR` so file outputs land there automatically).
255/// `workspace` is exposed as the `SKILLLITE_WORKSPACE` env var so the
256/// command can reference workspace files when needed.
257///
258/// The skill's `node_modules/.bin/` is still injected into PATH so CLI
259/// tools (e.g. agent-browser) are found.
260///
261/// Returns structured text with stdout, stderr, and exit_code so the LLM
262/// always sees both channels — critical for diagnosing failures.
263fn execute_bash_in_skill(
264    skill_dir: &Path,
265    command: &str,
266    env_path: &Path,
267    cwd: &Path,
268    workspace: &Path,
269) -> Result<String> {
270    use std::process::{Command, Stdio};
271
272    // Rewrite the command: resolve relative file-output paths to absolute paths
273    // using the output directory. This is the reliable fallback because some tools
274    // (e.g. agent-browser) ignore the shell's cwd and save files relative to their
275    // own process directory. By injecting absolute paths, we guarantee the file
276    // lands in the output directory regardless of the tool's internal behavior.
277    let command = rewrite_output_paths(command, cwd);
278
279    tracing::info!("bash_in_skill: cmd={:?} cwd={}", command, cwd.display());
280
281    let mut cmd = Command::new("sh");
282    cmd.arg("-c").arg(command.as_str());
283    cmd.current_dir(cwd);
284
285    // Expose workspace and output directory as env vars so LLM-generated commands
286    // can use $SKILLLITE_OUTPUT_DIR/filename to produce absolute paths.
287    // This is critical because some tools (e.g. agent-browser) ignore shell cwd
288    // and resolve file paths relative to their own process directory.
289    cmd.env("SKILLLITE_WORKSPACE", workspace.as_os_str());
290    if let Some(ref output_dir) = skilllite_core::config::PathsConfig::from_env().output_dir {
291        cmd.env("SKILLLITE_OUTPUT_DIR", output_dir);
292    }
293    cmd.stdin(Stdio::null());
294    cmd.stdout(Stdio::piped());
295    cmd.stderr(Stdio::piped());
296
297    // Inject node_modules/.bin/ into PATH (from env cache or from skill_dir)
298    let bin_dir = if env_path.exists() {
299        env_path.join("node_modules").join(".bin")
300    } else {
301        skill_dir.join("node_modules").join(".bin")
302    };
303    if bin_dir.exists() {
304        let current_path = std::env::var("PATH").unwrap_or_default();
305        cmd.env("PATH", format!("{}:{}", bin_dir.display(), current_path));
306    }
307
308    let output = cmd
309        .output()
310        .with_context(|| format!("Failed to execute bash command: {}", command))?;
311
312    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
313    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
314    let exit_code = output.status.code().unwrap_or(-1);
315
316    tracing::info!(
317        "bash_in_skill: exit_code={} stdout_len={} stderr_len={}",
318        exit_code,
319        stdout.len(),
320        stderr.len(),
321    );
322
323    // Always return both stdout and stderr so the LLM can see errors.
324    // Format as structured text (matching execute_bash_with_env in main.rs).
325    let mut result = String::new();
326    let stdout_trimmed = stdout.trim();
327    let stderr_trimmed = stderr.trim();
328
329    if exit_code == 0 {
330        if !stdout_trimmed.is_empty() {
331            result.push_str(stdout_trimmed);
332        }
333        if !stderr_trimmed.is_empty() {
334            if !result.is_empty() {
335                result.push('\n');
336            }
337            result.push_str(&format!("[stderr]: {}", stderr_trimmed));
338        }
339        if result.is_empty() {
340            result.push_str("Command succeeded (exit 0)");
341        }
342    } else {
343        result.push_str(&format!("Command failed (exit {}):", exit_code));
344        if !stdout_trimmed.is_empty() {
345            result.push_str(&format!("\n{}", stdout_trimmed));
346        }
347        if !stderr_trimmed.is_empty() {
348            result.push_str(&format!("\n[stderr]: {}", stderr_trimmed));
349        }
350    }
351
352    Ok(result)
353}
354
355/// Rewrite relative file-output paths in a bash command to absolute paths.
356///
357/// Many CLI tools (e.g. `agent-browser`) do NOT save files relative to the
358/// shell's current working directory.  To guarantee output files land in
359/// the intended directory we resolve any "bare filename" argument that looks
360/// like a file-output path (has a common file extension) into an absolute
361/// path under `output_dir`.
362///
363/// Examples:
364///   "agent-browser screenshot shot.png"
365///   → "agent-browser screenshot /Users/x/.skilllite/chat/output/shot.png"
366///
367///   "agent-browser screenshot $SKILLLITE_OUTPUT_DIR/shot.png"
368///   → unchanged (already uses env var / absolute prefix)
369///
370///   "agent-browser open https://example.com"
371///   → unchanged (URL, not a file path)
372fn rewrite_output_paths(command: &str, output_dir: &Path) -> String {
373    // Common file-output extensions that should be rewritten
374    const OUTPUT_EXTENSIONS: &[&str] = &[
375        ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf", ".html", ".htm", ".json",
376        ".csv", ".txt", ".md", ".webm", ".mp4",
377    ];
378
379    let parts: Vec<&str> = command.split_whitespace().collect();
380    if parts.len() < 2 {
381        return command.to_string();
382    }
383
384    let mut result_parts: Vec<String> = Vec::with_capacity(parts.len());
385    for part in &parts {
386        let lower = part.to_lowercase();
387
388        // Skip if already absolute, uses env var, or is a URL
389        let is_absolute = part.starts_with('/');
390        let has_env_var = part.contains('$');
391        let is_url = part.contains("://");
392
393        let has_output_ext = OUTPUT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext));
394
395        if has_output_ext && !is_absolute && !has_env_var && !is_url {
396            // Resolve to absolute path under output_dir
397            let abs = output_dir.join(part);
398            result_parts.push(abs.to_string_lossy().to_string());
399        } else {
400            result_parts.push(part.to_string());
401        }
402    }
403
404    result_parts.join(" ")
405}