Skip to main content

molo_coding/coding/
effect_executor.rs

1use crate::harness::{EffectExecutor, ExecutionError, ExecutionPolicy, RawEffectOutput};
2use crate::{EffectKind, EffectRequest, RunContext};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use super::command::CommandExecutor;
7use super::error::CodingError;
8use super::git::{GitInspector, GitOperation};
9use super::payload::{
10    ApplyPatchPayload, CommandPayload, GitPayload, ListFilesPayload, ReadFilePayload,
11    SearchPayload, WriteFilePayload,
12};
13use super::search::{RepoSearchRequest, RepoSearcher, SearchMode};
14use super::workspace::{FileBody, FileReadOptions, PatchRequest, Workspace};
15
16/// Configuration for [`CodingEffectExecutor`].
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct CodingExecutorConfig {
21    /// Whether write and patch effects may execute under
22    /// `SandboxPolicy::ReadOnly`. The default is false.
23    pub(crate) allow_write_in_read_only_policy: bool,
24    /// Default read max bytes when payload omits it.
25    pub(crate) default_read_max_bytes: usize,
26    /// Default search matches when payload omits it.
27    pub(crate) default_search_max_matches: usize,
28    /// Command policy/capability mismatch behavior.
29    pub(crate) command_policy_capability_mode: super::command::PolicyCapabilityMode,
30}
31
32impl Default for CodingExecutorConfig {
33    fn default() -> Self {
34        Self {
35            allow_write_in_read_only_policy: false,
36            default_read_max_bytes: 64 * 1024,
37            default_search_max_matches: 100,
38            command_policy_capability_mode: super::command::PolicyCapabilityMode::RequireEnforced,
39        }
40    }
41}
42
43impl CodingExecutorConfig {
44    /// Constructs a config with default values.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Whether write and patch effects may execute under read-only sandbox policy.
50    pub fn allow_write_in_read_only_policy(&self) -> bool {
51        self.allow_write_in_read_only_policy
52    }
53
54    /// Returns a config with updated read-only write behavior.
55    pub fn with_allow_write_in_read_only_policy(
56        mut self,
57        allow_write_in_read_only_policy: bool,
58    ) -> Self {
59        self.allow_write_in_read_only_policy = allow_write_in_read_only_policy;
60        self
61    }
62
63    /// Default read max bytes when payload omits it.
64    pub fn default_read_max_bytes(&self) -> usize {
65        self.default_read_max_bytes
66    }
67
68    /// Returns a config with an updated default read byte cap.
69    pub fn with_default_read_max_bytes(mut self, default_read_max_bytes: usize) -> Self {
70        self.default_read_max_bytes = default_read_max_bytes;
71        self
72    }
73
74    /// Default search matches when payload omits it.
75    pub fn default_search_max_matches(&self) -> usize {
76        self.default_search_max_matches
77    }
78
79    /// Returns a config with an updated default search match cap.
80    pub fn with_default_search_max_matches(mut self, default_search_max_matches: usize) -> Self {
81        self.default_search_max_matches = default_search_max_matches;
82        self
83    }
84
85    /// Command policy/capability mismatch behavior.
86    pub fn command_policy_capability_mode(&self) -> super::command::PolicyCapabilityMode {
87        self.command_policy_capability_mode
88    }
89
90    /// Returns a config with updated command policy/capability behavior.
91    pub fn with_command_policy_capability_mode(
92        mut self,
93        command_policy_capability_mode: super::command::PolicyCapabilityMode,
94    ) -> Self {
95        self.command_policy_capability_mode = command_policy_capability_mode;
96        self
97    }
98}
99
100/// Effect executor that routes typed coding payloads to coding primitives.
101#[derive(Debug, Clone)]
102pub struct CodingEffectExecutor<W, C, G, S> {
103    workspace: W,
104    commands: C,
105    git: G,
106    searcher: S,
107    config: CodingExecutorConfig,
108}
109
110impl<W, C, G, S> CodingEffectExecutor<W, C, G, S> {
111    /// Constructs a coding effect executor.
112    pub fn new(workspace: W, commands: C, git: G, searcher: S) -> Self {
113        Self {
114            workspace,
115            commands,
116            git,
117            searcher,
118            config: CodingExecutorConfig::default(),
119        }
120    }
121
122    /// Replaces executor configuration.
123    pub fn with_config(mut self, config: CodingExecutorConfig) -> Self {
124        self.config = config;
125        self
126    }
127}
128
129#[async_trait]
130impl<W, C, G, S> EffectExecutor for CodingEffectExecutor<W, C, G, S>
131where
132    W: Workspace,
133    C: CommandExecutor,
134    G: GitInspector,
135    S: RepoSearcher,
136{
137    async fn execute(
138        &self,
139        request: &EffectRequest,
140        policy: &ExecutionPolicy,
141        context: &RunContext,
142    ) -> Result<RawEffectOutput, ExecutionError> {
143        match &request.kind {
144            EffectKind::ReadFile => self.execute_read(request).await,
145            EffectKind::WriteFile => self.execute_write(request, policy).await,
146            EffectKind::ApplyPatch => self.execute_patch(request, policy).await,
147            EffectKind::Search => self.execute_search(request, context).await,
148            EffectKind::ExecuteCommand => self.execute_command(request, policy, context).await,
149            EffectKind::Git => self.execute_git(request, context).await,
150            other => Err(ExecutionError::Unsupported(format!(
151                "coding executor does not support effect kind {other:?}"
152            ))),
153        }
154    }
155}
156
157impl<W, C, G, S> CodingEffectExecutor<W, C, G, S>
158where
159    W: Workspace,
160    C: CommandExecutor,
161    G: GitInspector,
162    S: RepoSearcher,
163{
164    async fn execute_read(
165        &self,
166        request: &EffectRequest,
167    ) -> Result<RawEffectOutput, ExecutionError> {
168        let payload = ReadFilePayload::from_effect(request)?;
169        let content = self
170            .workspace
171            .read_file(
172                &payload.path,
173                FileReadOptions {
174                    max_bytes: Some(
175                        payload
176                            .max_bytes
177                            .unwrap_or(self.config.default_read_max_bytes),
178                    ),
179                    include_binary: false,
180                },
181            )
182            .await
183            .map_err(CodingError::from)?;
184        let observation = match &content.body {
185            FileBody::Text { text, .. } => {
186                format!(
187                    "read {} ({} bytes, truncated={}):\n{}",
188                    content.path.display(),
189                    content.version.len,
190                    content.truncated,
191                    text
192                )
193            }
194            FileBody::Binary { .. } => format!(
195                "read {} as binary metadata ({} bytes, truncated={})",
196                content.path.display(),
197                content.version.len,
198                content.truncated
199            ),
200        };
201        raw_json(observation, &content)
202    }
203
204    async fn execute_write(
205        &self,
206        request: &EffectRequest,
207        policy: &ExecutionPolicy,
208    ) -> Result<RawEffectOutput, ExecutionError> {
209        require_write_policy(policy, self.config.allow_write_in_read_only_policy)?;
210        let payload = WriteFilePayload::from_effect(request)?;
211        let result = self
212            .workspace
213            .write_file(payload.into_request())
214            .await
215            .map_err(CodingError::from)?;
216        raw_json(
217            format!(
218                "wrote {} ({} bytes, created={})",
219                result.path.display(),
220                result.bytes_written,
221                result.created
222            ),
223            &result,
224        )
225    }
226
227    async fn execute_patch(
228        &self,
229        request: &EffectRequest,
230        policy: &ExecutionPolicy,
231    ) -> Result<RawEffectOutput, ExecutionError> {
232        require_write_policy(policy, self.config.allow_write_in_read_only_policy)?;
233        let mut payload = ApplyPatchPayload::from_effect(request)?;
234        for expected in payload.expected_versions {
235            for file in &mut payload.patch.files {
236                if file.path == expected.path && file.expected_version.is_none() {
237                    file.expected_version = Some(expected.clone());
238                }
239            }
240        }
241        let result = self
242            .workspace
243            .apply_patch(PatchRequest {
244                patch: payload.patch,
245                dry_run: payload.dry_run,
246                allow_partial: false,
247            })
248            .await
249            .map_err(CodingError::from)?;
250        raw_json(
251            format!(
252                "patch applied={} changed={} conflicts={}",
253                result.applied,
254                result.changed_files.len(),
255                result.conflicts.len()
256            ),
257            &result,
258        )
259    }
260
261    async fn execute_search(
262        &self,
263        request: &EffectRequest,
264        context: &RunContext,
265    ) -> Result<RawEffectOutput, ExecutionError> {
266        if let Ok(payload) = ListFilesPayload::from_effect(request) {
267            let entries = self
268                .workspace
269                .list_files(payload.into_query())
270                .await
271                .map_err(CodingError::from)?;
272            return raw_json(format!("listed {} entrie(s)", entries.len()), &entries);
273        }
274        let payload = SearchPayload::from_effect(request)?;
275        let results = self
276            .searcher
277            .search(
278                RepoSearchRequest {
279                    query: payload.query,
280                    paths: payload.paths,
281                    mode: SearchMode::Literal,
282                    max_matches: payload
283                        .max_matches
284                        .unwrap_or(self.config.default_search_max_matches),
285                    context_lines: payload.context_lines,
286                    include_hidden: false,
287                    respect_gitignore: true,
288                },
289                context,
290            )
291            .await
292            .map_err(CodingError::from)?;
293        raw_json(
294            format!(
295                "search returned {} match(es), truncated={}",
296                results.matches.len(),
297                results.truncated
298            ),
299            &results,
300        )
301    }
302
303    async fn execute_command(
304        &self,
305        request: &EffectRequest,
306        policy: &ExecutionPolicy,
307        context: &RunContext,
308    ) -> Result<RawEffectOutput, ExecutionError> {
309        let payload = CommandPayload::from_effect(request)?;
310        let capabilities = self.commands.capabilities();
311        super::command::validate_command_capabilities(
312            &capabilities,
313            &payload.request,
314            policy,
315            self.config.command_policy_capability_mode,
316        )
317        .map_err(CodingError::from)?;
318        let output = self
319            .commands
320            .execute(payload.request, policy, context)
321            .await
322            .map_err(CodingError::from)?;
323        super::command::validate_policy_enforcement_report(
324            &capabilities,
325            &output.policy_enforcement,
326            self.config.command_policy_capability_mode,
327        )
328        .map_err(CodingError::from)?;
329        raw_json(
330            format!(
331                "command status={:?}, stdout_bytes={}, stderr_bytes={}, truncated={}",
332                output.status, output.stdout.bytes, output.stderr.bytes, output.truncated
333            ),
334            &output,
335        )
336    }
337
338    async fn execute_git(
339        &self,
340        request: &EffectRequest,
341        context: &RunContext,
342    ) -> Result<RawEffectOutput, ExecutionError> {
343        let payload = GitPayload::from_effect(request)?;
344        match payload.operation {
345            GitOperation::Status(request) => {
346                let status = self
347                    .git
348                    .status(request, context)
349                    .await
350                    .map_err(CodingError::from)?;
351                raw_json(
352                    format!("git status: {} changed file(s)", status.changed_files.len()),
353                    &status,
354                )
355            }
356            GitOperation::Diff(request) => {
357                let diff = self
358                    .git
359                    .diff(request, context)
360                    .await
361                    .map_err(CodingError::from)?;
362                raw_json(
363                    format!(
364                        "git diff: {} changed path hint(s), truncated={}",
365                        diff.changed_files.len(),
366                        diff.truncated
367                    ),
368                    &diff,
369                )
370            }
371            GitOperation::ChangedFiles(request) => {
372                let files = self
373                    .git
374                    .changed_files(request, context)
375                    .await
376                    .map_err(CodingError::from)?;
377                raw_json(format!("git changed files: {}", files.len()), &files)
378            }
379            GitOperation::Head => {
380                let head = self.git.head(context).await.map_err(CodingError::from)?;
381                raw_json("git head", &head)
382            }
383        }
384    }
385}
386
387fn require_write_policy(
388    policy: &ExecutionPolicy,
389    allow_read_only: bool,
390) -> Result<(), ExecutionError> {
391    if allow_read_only {
392        return Ok(());
393    }
394    match policy.sandbox() {
395        crate::harness::SandboxPolicy::WorkspaceWrite
396        | crate::harness::SandboxPolicy::FullAccess => Ok(()),
397        _ => Err(ExecutionError::Unsupported(
398            "write and patch effects require workspace-write sandbox policy".to_string(),
399        )),
400    }
401}
402
403fn raw_json<T>(summary: impl Into<String>, value: &T) -> Result<RawEffectOutput, ExecutionError>
404where
405    T: Serialize,
406{
407    let json = serde_json::to_string_pretty(value).map_err(|error| {
408        ExecutionError::Failed(format!("failed to encode coding output: {error}"))
409    })?;
410    Ok(RawEffectOutput::text(format!("{}\n{}", summary.into(), json)).with_debug(json))
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::coding::{
417        CommandPayload, LocalCommandExecutor, LocalWorkspace, PolicyCapabilityMode,
418        ReadFilePayload, WorkspacePath, WorkspaceSearcher, WriteFilePayload,
419    };
420    use crate::harness::{NetworkPolicy, SandboxPolicy};
421    use std::time::Duration;
422
423    fn temp_dir(tag: &str) -> std::path::PathBuf {
424        let dir =
425            std::env::temp_dir().join(format!("molo-coding-executor-{}-{tag}", std::process::id()));
426        let _ = std::fs::remove_dir_all(&dir);
427        std::fs::create_dir_all(&dir).unwrap();
428        dir
429    }
430
431    fn policy(sandbox: SandboxPolicy) -> ExecutionPolicy {
432        ExecutionPolicy::new(sandbox, NetworkPolicy::Deny)
433            .with_timeout(Some(Duration::from_secs(5)))
434    }
435
436    #[tokio::test]
437    async fn read_file_effect_executes_through_workspace() {
438        let root = temp_dir("read");
439        std::fs::write(root.join("a.txt"), "alpha").unwrap();
440        let workspace = LocalWorkspace::new(&root).unwrap();
441        let commands = LocalCommandExecutor::new(workspace.clone());
442        let git = crate::coding::CliGitInspector::new(commands.clone());
443        let searcher = WorkspaceSearcher::new(workspace.clone());
444        let executor = CodingEffectExecutor::new(workspace, commands, git, searcher);
445        let effect = ReadFilePayload {
446            path: WorkspacePath::parse("a.txt").unwrap(),
447            max_bytes: Some(64),
448        }
449        .into_effect()
450        .unwrap();
451
452        let output = executor
453            .execute(
454                &effect,
455                &policy(SandboxPolicy::ReadOnly),
456                &crate::RunContext::new("coding-exec"),
457            )
458            .await
459            .unwrap();
460
461        assert!(output.observation_for_model.contains("alpha"));
462        let _ = std::fs::remove_dir_all(root);
463    }
464
465    #[tokio::test]
466    async fn write_file_requires_workspace_write_policy() {
467        let root = temp_dir("write-policy");
468        let workspace = LocalWorkspace::new(&root).unwrap();
469        let commands = LocalCommandExecutor::new(workspace.clone());
470        let git = crate::coding::CliGitInspector::new(commands.clone());
471        let searcher = WorkspaceSearcher::new(workspace.clone());
472        let executor = CodingEffectExecutor::new(workspace, commands, git, searcher);
473        let effect = WriteFilePayload {
474            path: WorkspacePath::parse("a.txt").unwrap(),
475            content: crate::coding::FileWriteContent::Text("alpha".to_string()),
476            expected_version: None,
477            create: true,
478            overwrite: false,
479        }
480        .into_effect()
481        .unwrap();
482
483        let error = executor
484            .execute(
485                &effect,
486                &policy(SandboxPolicy::ReadOnly),
487                &crate::RunContext::new("coding-exec"),
488            )
489            .await
490            .unwrap_err();
491
492        assert!(matches!(error, ExecutionError::Unsupported(_)));
493        assert!(!root.join("a.txt").exists());
494        let _ = std::fs::remove_dir_all(root);
495    }
496
497    #[tokio::test]
498    async fn command_effect_denies_capability_mismatch_by_default() {
499        let root = temp_dir("command-mismatch");
500        let workspace = LocalWorkspace::new(&root).unwrap();
501        let commands = LocalCommandExecutor::new(workspace.clone());
502        let git = crate::coding::CliGitInspector::new(commands.clone());
503        let searcher = WorkspaceSearcher::new(workspace.clone());
504        let executor = CodingEffectExecutor::new(workspace, commands, git, searcher);
505        let effect = CommandPayload {
506            request: crate::coding::CommandRequest::new(["printf", "ok"]),
507        }
508        .into_effect()
509        .unwrap();
510
511        let error = executor
512            .execute(
513                &effect,
514                &policy(SandboxPolicy::ReadOnly),
515                &crate::RunContext::new("coding-exec"),
516            )
517            .await
518            .unwrap_err();
519
520        assert!(matches!(error, ExecutionError::Denied(_)));
521        let _ = std::fs::remove_dir_all(root);
522    }
523
524    #[tokio::test]
525    async fn command_effect_can_run_in_explicit_advisory_mode() {
526        let root = temp_dir("command-advisory");
527        let workspace = LocalWorkspace::new(&root).unwrap();
528        let commands = LocalCommandExecutor::new(workspace.clone()).with_advisory_policy(true);
529        let git = crate::coding::CliGitInspector::new(commands.clone());
530        let searcher = WorkspaceSearcher::new(workspace.clone());
531        let executor = CodingEffectExecutor::new(workspace, commands, git, searcher).with_config(
532            CodingExecutorConfig::default()
533                .with_command_policy_capability_mode(PolicyCapabilityMode::AllowAdvisory),
534        );
535        let effect = CommandPayload {
536            request: crate::coding::CommandRequest::new(["printf", "ok"]),
537        }
538        .into_effect()
539        .unwrap();
540
541        let output = executor
542            .execute(
543                &effect,
544                &policy(SandboxPolicy::ReadOnly),
545                &crate::RunContext::new("coding-exec"),
546            )
547            .await
548            .unwrap();
549
550        assert!(output.observation_for_model.contains("command status"));
551        assert!(output.debug.unwrap().contains("Advisory"));
552        let _ = std::fs::remove_dir_all(root);
553    }
554
555    #[test]
556    fn config_default_is_conservative() {
557        let config = CodingExecutorConfig::default();
558        assert!(!config.allow_write_in_read_only_policy);
559        assert!(config.default_read_max_bytes > 0);
560        assert!(config.default_search_max_matches > 0);
561        assert_eq!(
562            config.command_policy_capability_mode(),
563            super::super::command::PolicyCapabilityMode::RequireEnforced
564        );
565    }
566}