Skip to main content

elizaos_plugin_code/
service.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use tokio::fs;
6use tokio::io::AsyncReadExt;
7use tokio::process::Command;
8use tokio::time::timeout;
9use tracing::info;
10
11use crate::error::Result;
12use crate::path_utils::{is_forbidden_command, is_safe_command, validate_path};
13use crate::types::{
14    CodeConfig, CommandHistoryEntry, CommandResult, FileOperation, FileOperationType,
15};
16
17pub struct CoderService {
18    config: CodeConfig,
19    cwd_by_conversation: HashMap<String, PathBuf>,
20    history_by_conversation: HashMap<String, Vec<CommandHistoryEntry>>,
21    max_history_per_conversation: usize,
22}
23
24impl CoderService {
25    pub fn new(config: CodeConfig) -> Self {
26        info!("Coder service initialized");
27        Self {
28            cwd_by_conversation: HashMap::new(),
29            history_by_conversation: HashMap::new(),
30            config,
31            max_history_per_conversation: 100,
32        }
33    }
34
35    pub fn allowed_directory(&self) -> &Path {
36        &self.config.allowed_directory
37    }
38
39    pub fn current_directory(&self, conversation_id: &str) -> PathBuf {
40        self.cwd_by_conversation
41            .get(conversation_id)
42            .cloned()
43            .unwrap_or_else(|| self.config.allowed_directory.clone())
44    }
45
46    pub fn get_command_history(
47        &self,
48        conversation_id: &str,
49        limit: Option<usize>,
50    ) -> Vec<CommandHistoryEntry> {
51        let all = self
52            .history_by_conversation
53            .get(conversation_id)
54            .cloned()
55            .unwrap_or_default();
56        match limit {
57            None => all,
58            Some(l) => {
59                if l == 0 {
60                    vec![]
61                } else if all.len() <= l {
62                    all
63                } else {
64                    all[all.len() - l..].to_vec()
65                }
66            }
67        }
68    }
69
70    fn now_ms() -> u64 {
71        SystemTime::now()
72            .duration_since(UNIX_EPOCH)
73            .unwrap_or_else(|_| Duration::from_millis(0))
74            .as_millis() as u64
75    }
76
77    fn ensure_enabled(&self) -> Option<String> {
78        if self.config.enabled {
79            None
80        } else {
81            Some("Coder plugin is disabled. Set CODER_ENABLED=true to enable.".to_string())
82        }
83    }
84
85    fn add_history(
86        &mut self,
87        conversation_id: &str,
88        command: &str,
89        result: &CommandResult,
90        file_ops: Option<Vec<FileOperation>>,
91    ) {
92        let list = self
93            .history_by_conversation
94            .entry(conversation_id.to_string())
95            .or_default();
96
97        list.push(CommandHistoryEntry {
98            timestamp_ms: Self::now_ms(),
99            working_directory: result.executed_in.clone(),
100            command: command.to_string(),
101            stdout: result.stdout.clone(),
102            stderr: result.stderr.clone(),
103            exit_code: result.exit_code,
104            file_operations: file_ops,
105        });
106
107        if list.len() > self.max_history_per_conversation {
108            let start = list.len() - self.max_history_per_conversation;
109            *list = list[start..].to_vec();
110        }
111    }
112
113    fn resolve_within(&self, conversation_id: &str, target: &str) -> Option<PathBuf> {
114        let cwd = self.current_directory(conversation_id);
115        validate_path(target, &self.config.allowed_directory, &cwd)
116    }
117
118    pub async fn change_directory(&mut self, conversation_id: &str, target: &str) -> CommandResult {
119        if let Some(msg) = self.ensure_enabled() {
120            return CommandResult {
121                success: false,
122                stdout: "".to_string(),
123                stderr: msg,
124                exit_code: Some(1),
125                error: Some("Coder disabled".to_string()),
126                executed_in: self
127                    .current_directory(conversation_id)
128                    .display()
129                    .to_string(),
130            };
131        }
132
133        let resolved = match self.resolve_within(conversation_id, target) {
134            Some(p) => p,
135            None => {
136                return CommandResult {
137                    success: false,
138                    stdout: "".to_string(),
139                    stderr: "Cannot navigate outside allowed directory".to_string(),
140                    exit_code: Some(1),
141                    error: Some("Permission denied".to_string()),
142                    executed_in: self
143                        .current_directory(conversation_id)
144                        .display()
145                        .to_string(),
146                }
147            }
148        };
149
150        let meta = fs::metadata(&resolved).await;
151        let is_dir = meta.map(|m| m.is_dir()).unwrap_or(false);
152        if !is_dir {
153            return CommandResult {
154                success: false,
155                stdout: "".to_string(),
156                stderr: "Not a directory".to_string(),
157                exit_code: Some(1),
158                error: Some("Not a directory".to_string()),
159                executed_in: self
160                    .current_directory(conversation_id)
161                    .display()
162                    .to_string(),
163            };
164        }
165
166        self.cwd_by_conversation
167            .insert(conversation_id.to_string(), resolved.clone());
168
169        CommandResult {
170            success: true,
171            stdout: format!("Changed directory to: {}", resolved.display()),
172            stderr: "".to_string(),
173            exit_code: Some(0),
174            error: None,
175            executed_in: resolved.display().to_string(),
176        }
177    }
178
179    pub async fn read_file(
180        &self,
181        conversation_id: &str,
182        filepath: &str,
183    ) -> std::result::Result<String, String> {
184        if let Some(msg) = self.ensure_enabled() {
185            return Err(msg);
186        }
187        let resolved = self
188            .resolve_within(conversation_id, filepath)
189            .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
190        let meta = fs::metadata(&resolved).await.map_err(|e| {
191            if e.kind() == std::io::ErrorKind::NotFound {
192                "File not found".to_string()
193            } else {
194                e.to_string()
195            }
196        })?;
197        if meta.is_dir() {
198            return Err("Path is a directory".to_string());
199        }
200        fs::read_to_string(&resolved)
201            .await
202            .map_err(|e| e.to_string())
203    }
204
205    pub async fn write_file(
206        &self,
207        conversation_id: &str,
208        filepath: &str,
209        content: &str,
210    ) -> std::result::Result<(), String> {
211        if let Some(msg) = self.ensure_enabled() {
212            return Err(msg);
213        }
214        if filepath.trim().is_empty() {
215            return Err("File path cannot be empty".to_string());
216        }
217        let resolved = self
218            .resolve_within(conversation_id, filepath)
219            .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
220        if let Some(parent) = resolved.parent() {
221            if !parent.as_os_str().is_empty() {
222                fs::create_dir_all(parent)
223                    .await
224                    .map_err(|e| e.to_string())?;
225            }
226        }
227        fs::write(&resolved, content)
228            .await
229            .map_err(|e| e.to_string())
230    }
231
232    pub async fn edit_file(
233        &self,
234        conversation_id: &str,
235        filepath: &str,
236        old_str: &str,
237        new_str: &str,
238    ) -> std::result::Result<(), String> {
239        if let Some(msg) = self.ensure_enabled() {
240            return Err(msg);
241        }
242        let resolved = self
243            .resolve_within(conversation_id, filepath)
244            .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
245        let content = fs::read_to_string(&resolved).await.map_err(|e| {
246            if e.kind() == std::io::ErrorKind::NotFound {
247                "File not found".to_string()
248            } else {
249                e.to_string()
250            }
251        })?;
252        if !content.contains(old_str) {
253            return Err("Could not find old_str in file".to_string());
254        }
255        let next = content.replacen(old_str, new_str, 1);
256        fs::write(&resolved, next).await.map_err(|e| e.to_string())
257    }
258
259    pub async fn list_files(
260        &self,
261        conversation_id: &str,
262        dirpath: &str,
263    ) -> std::result::Result<Vec<String>, String> {
264        if let Some(msg) = self.ensure_enabled() {
265            return Err(msg);
266        }
267        let resolved = self
268            .resolve_within(conversation_id, dirpath)
269            .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
270        let mut entries = fs::read_dir(&resolved).await.map_err(|e| {
271            if e.kind() == std::io::ErrorKind::NotFound {
272                "Directory not found".to_string()
273            } else {
274                e.to_string()
275            }
276        })?;
277        let mut items: Vec<String> = vec![];
278        while let Some(e) = entries.next_entry().await.map_err(|e| e.to_string())? {
279            let name = e.file_name().to_string_lossy().to_string();
280            if name.starts_with('.') {
281                continue;
282            }
283            let meta = e.metadata().await.map_err(|e| e.to_string())?;
284            items.push(if meta.is_dir() {
285                format!("{}/", name)
286            } else {
287                name
288            });
289        }
290        items.sort();
291        Ok(items)
292    }
293
294    pub async fn search_files(
295        &self,
296        conversation_id: &str,
297        pattern: &str,
298        dirpath: &str,
299        max_matches: usize,
300    ) -> std::result::Result<Vec<(String, usize, String)>, String> {
301        if let Some(msg) = self.ensure_enabled() {
302            return Err(msg);
303        }
304        let needle = pattern.trim();
305        if needle.is_empty() {
306            return Err("Missing pattern".to_string());
307        }
308        let resolved = self
309            .resolve_within(conversation_id, dirpath)
310            .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
311        let limit = if max_matches == 0 {
312            50
313        } else {
314            max_matches.min(500)
315        };
316        let mut matches: Vec<(String, usize, String)> = vec![];
317        self.search_dir(&resolved, needle.to_lowercase(), &mut matches, limit)
318            .await
319            .map_err(|e| e.to_string())?;
320        Ok(matches)
321    }
322
323    async fn search_dir(
324        &self,
325        dir: &Path,
326        needle_lower: String,
327        matches: &mut Vec<(String, usize, String)>,
328        limit: usize,
329    ) -> Result<()> {
330        if matches.len() >= limit {
331            return Ok(());
332        }
333
334        let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()];
335
336        while let Some(current) = stack.pop() {
337            if matches.len() >= limit {
338                break;
339            }
340
341            let mut entries = match fs::read_dir(&current).await {
342                Ok(e) => e,
343                Err(_) => continue,
344            };
345
346            while let Some(entry) = entries.next_entry().await? {
347                if matches.len() >= limit {
348                    break;
349                }
350
351                let name = entry.file_name().to_string_lossy().to_string();
352                if name.starts_with('.') {
353                    continue;
354                }
355
356                let p = entry.path();
357                let meta = entry.metadata().await?;
358
359                if meta.is_dir() {
360                    if name == "node_modules"
361                        || name == "dist"
362                        || name == "build"
363                        || name == "coverage"
364                        || name == ".git"
365                    {
366                        continue;
367                    }
368                    stack.push(p);
369                    continue;
370                }
371
372                if !meta.is_file() {
373                    continue;
374                }
375
376                let mut f = fs::File::open(&p).await?;
377                let mut content = String::new();
378                let _ = f.read_to_string(&mut content).await;
379                for (i, line) in content.lines().enumerate() {
380                    if matches.len() >= limit {
381                        break;
382                    }
383                    if line.to_lowercase().contains(&needle_lower) {
384                        let rel = p
385                            .strip_prefix(&self.config.allowed_directory)
386                            .unwrap_or(&p)
387                            .display()
388                            .to_string();
389                        matches.push((rel, i + 1, line.trim().chars().take(240).collect()));
390                    }
391                }
392            }
393        }
394
395        Ok(())
396    }
397
398    pub async fn execute_shell(
399        &mut self,
400        conversation_id: &str,
401        command: &str,
402    ) -> Result<CommandResult> {
403        if let Some(msg) = self.ensure_enabled() {
404            return Ok(CommandResult {
405                success: false,
406                stdout: "".to_string(),
407                stderr: msg,
408                exit_code: Some(1),
409                error: Some("Coder disabled".to_string()),
410                executed_in: self
411                    .current_directory(conversation_id)
412                    .display()
413                    .to_string(),
414            });
415        }
416
417        let trimmed = command.trim();
418        if trimmed.is_empty() {
419            return Ok(CommandResult {
420                success: false,
421                stdout: "".to_string(),
422                stderr: "Invalid command".to_string(),
423                exit_code: Some(1),
424                error: Some("Empty command".to_string()),
425                executed_in: self
426                    .current_directory(conversation_id)
427                    .display()
428                    .to_string(),
429            });
430        }
431
432        if !is_safe_command(trimmed) {
433            return Ok(CommandResult {
434                success: false,
435                stdout: "".to_string(),
436                stderr: "Command contains forbidden patterns".to_string(),
437                exit_code: Some(1),
438                error: Some("Security policy violation".to_string()),
439                executed_in: self
440                    .current_directory(conversation_id)
441                    .display()
442                    .to_string(),
443            });
444        }
445
446        if is_forbidden_command(trimmed, &self.config.forbidden_commands) {
447            return Ok(CommandResult {
448                success: false,
449                stdout: "".to_string(),
450                stderr: "Command is forbidden by security policy".to_string(),
451                exit_code: Some(1),
452                error: Some("Forbidden command".to_string()),
453                executed_in: self
454                    .current_directory(conversation_id)
455                    .display()
456                    .to_string(),
457            });
458        }
459
460        let cwd = self.current_directory(conversation_id);
461        let cwd_str = cwd.display().to_string();
462
463        let use_shell = trimmed.contains('>') || trimmed.contains('<') || trimmed.contains('|');
464        let mut cmd = if use_shell {
465            let mut c = Command::new("sh");
466            c.args(["-c", trimmed]);
467            c
468        } else {
469            let parts: Vec<&str> = trimmed.split_whitespace().collect();
470            let mut c = Command::new(parts[0]);
471            if parts.len() > 1 {
472                c.args(&parts[1..]);
473            }
474            c
475        };
476
477        cmd.current_dir(&cwd)
478            .stdout(std::process::Stdio::piped())
479            .stderr(std::process::Stdio::piped());
480
481        let timeout_duration = Duration::from_millis(self.config.timeout_ms);
482        let spawn = cmd
483            .spawn()
484            .map_err(|e| crate::error::CodeError::Process(e.to_string()))?;
485
486        let output = timeout(timeout_duration, spawn.wait_with_output()).await;
487
488        let result = match output {
489            Ok(Ok(out)) => {
490                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
491                let stderr = String::from_utf8_lossy(&out.stderr).to_string();
492                CommandResult {
493                    success: out.status.success(),
494                    stdout,
495                    stderr,
496                    exit_code: out.status.code(),
497                    error: None,
498                    executed_in: cwd_str.clone(),
499                }
500            }
501            Ok(Err(e)) => CommandResult {
502                success: false,
503                stdout: "".to_string(),
504                stderr: e.to_string(),
505                exit_code: Some(1),
506                error: Some("Command failed".to_string()),
507                executed_in: cwd_str.clone(),
508            },
509            Err(_) => CommandResult {
510                success: false,
511                stdout: "".to_string(),
512                stderr: "Command timed out".to_string(),
513                exit_code: None,
514                error: Some("Command execution timeout".to_string()),
515                executed_in: cwd_str.clone(),
516            },
517        };
518
519        self.add_history(conversation_id, trimmed, &result, None);
520        Ok(result)
521    }
522
523    pub async fn git(&mut self, conversation_id: &str, args: &str) -> Result<CommandResult> {
524        self.execute_shell(conversation_id, &format!("git {}", args))
525            .await
526    }
527
528    pub fn note_file_op(
529        &mut self,
530        conversation_id: &str,
531        op_type: FileOperationType,
532        target: &str,
533    ) {
534        let cwd = self
535            .current_directory(conversation_id)
536            .display()
537            .to_string();
538        let entry = CommandResult {
539            success: true,
540            stdout: "".to_string(),
541            stderr: "".to_string(),
542            exit_code: Some(0),
543            error: None,
544            executed_in: cwd,
545        };
546        self.add_history(
547            conversation_id,
548            "<file_op>",
549            &entry,
550            Some(vec![FileOperation {
551                r#type: op_type,
552                target: target.to_string(),
553            }]),
554        );
555    }
556}