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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct CodingExecutorConfig {
21 pub(crate) allow_write_in_read_only_policy: bool,
24 pub(crate) default_read_max_bytes: usize,
26 pub(crate) default_search_max_matches: usize,
28 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 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn allow_write_in_read_only_policy(&self) -> bool {
51 self.allow_write_in_read_only_policy
52 }
53
54 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 pub fn default_read_max_bytes(&self) -> usize {
65 self.default_read_max_bytes
66 }
67
68 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 pub fn default_search_max_matches(&self) -> usize {
76 self.default_search_max_matches
77 }
78
79 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 pub fn command_policy_capability_mode(&self) -> super::command::PolicyCapabilityMode {
87 self.command_policy_capability_mode
88 }
89
90 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#[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 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 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}