@@ -92,6 +92,12 @@ pub struct RunSummary {
9292
9393pub ( crate ) const CONTAINER_DISABLE_MARKER : & str = "DECAPOD_CONTAINER_RUNTIME_DISABLED=true" ;
9494
95+ pub ( crate ) enum ContainerRuntimeOverrideHeal {
96+ Added ,
97+ Cleared ,
98+ Unchanged ,
99+ }
100+
95101pub fn run_container_cli ( store : & Store , cli : ContainerCli ) -> Result < ( ) , error:: DecapodError > {
96102 let summary = match cli. command {
97103 ContainerCommand :: Run {
@@ -218,15 +224,6 @@ fn run_container(
218224 local_only : bool ,
219225) -> Result < RunSummary , error:: DecapodError > {
220226 let repo = resolve_repo_path ( repo_override) ?;
221- if container_runtime_disabled ( & repo) ? {
222- return Err ( error:: DecapodError :: ValidationError (
223- "Container subsystem is disabled by .decapod/OVERRIDE.md. \
224- Remove the disable marker after installing Docker/Podman and configuring a dedicated local SSH key. \
225- Warning: running without isolated containers means concurrent agents can step on each other."
226- . to_string ( ) ,
227- ) ) ;
228- }
229-
230227 let docker = match find_container_runtime ( ) {
231228 Ok ( runtime) => runtime,
232229 Err ( _) => {
@@ -235,15 +232,21 @@ Warning: running without isolated containers means concurrent agents can step on
235232 "No docker/podman runtime found" ,
236233 "Install Docker or Podman first, then re-run the task." ,
237234 ) ?;
238- return Err ( error:: DecapodError :: ValidationError (
239- "No container runtime found (docker/podman).\n \
235+ let message = "No container runtime found (docker/podman).\n \
240236 Please install Docker or Podman.\n \
241237 I also wrote .decapod/OVERRIDE.md with container runtime disabled so agent runs stay safe by default.\n \
242- Warning: without isolated containers, concurrent agents can step on each other."
243- . to_string ( ) ,
244- ) ) ;
238+ Warning: without isolated containers, concurrent agents can step on each other.";
239+ return Err ( error:: DecapodError :: ValidationError ( message. to_string ( ) ) ) ;
245240 }
246241 } ;
242+ clear_container_runtime_override ( & repo) ?;
243+ if container_runtime_disabled ( & repo) ? {
244+ return Err ( error:: DecapodError :: ValidationError (
245+ "Container subsystem is disabled by .decapod/OVERRIDE.md even though a runtime is available. \
246+ Clear the disable marker or let Decapod self-heal the override file before retrying."
247+ . to_string ( ) ,
248+ ) ) ;
249+ }
247250
248251 ensure_container_runtime_access ( & docker) ?;
249252
@@ -418,11 +421,94 @@ fn container_runtime_disabled(repo_root: &Path) -> Result<bool, error::DecapodEr
418421 Ok ( content. contains ( CONTAINER_DISABLE_MARKER ) )
419422}
420423
424+ fn clear_container_runtime_override ( repo_root : & Path ) -> Result < bool , error:: DecapodError > {
425+ let path = override_file_path ( repo_root) ;
426+ if !path. exists ( ) {
427+ return Ok ( false ) ;
428+ }
429+ let content = fs:: read_to_string ( & path) . map_err ( error:: DecapodError :: IoError ) ?;
430+ if !content. contains ( CONTAINER_DISABLE_MARKER ) {
431+ return Ok ( false ) ;
432+ }
433+
434+ let lines: Vec < & str > = content. lines ( ) . collect ( ) ;
435+ let marker_index = lines
436+ . iter ( )
437+ . position ( |line| line. trim ( ) == CONTAINER_DISABLE_MARKER )
438+ . ok_or_else ( || {
439+ error:: DecapodError :: ValidationError (
440+ "container override marker exists but could not be located" . to_string ( ) ,
441+ )
442+ } ) ?;
443+
444+ let mut start = marker_index;
445+ while start > 0 {
446+ let candidate = lines[ start - 1 ] . trim ( ) ;
447+ if candidate == "### plugins/CONTAINER.md" {
448+ start -= 1 ;
449+ break ;
450+ }
451+ if candidate. is_empty ( ) {
452+ start -= 1 ;
453+ continue ;
454+ }
455+ break ;
456+ }
457+
458+ let mut end = marker_index + 1 ;
459+ while end < lines. len ( ) {
460+ let candidate = lines[ end] . trim ( ) ;
461+ if candidate. starts_with ( "reason:" )
462+ || candidate. starts_with ( "remediation:" )
463+ || candidate. starts_with ( "warning:" )
464+ || candidate == "## Runtime Guard Override (auto-generated)"
465+ || candidate. is_empty ( )
466+ {
467+ end += 1 ;
468+ continue ;
469+ }
470+ break ;
471+ }
472+
473+ let mut rebuilt: Vec < & str > = Vec :: with_capacity ( lines. len ( ) . saturating_sub ( end - start) ) ;
474+ rebuilt. extend_from_slice ( & lines[ ..start] ) ;
475+ rebuilt. extend_from_slice ( & lines[ end..] ) ;
476+ let mut cleaned = rebuilt. join ( "\n " ) ;
477+ if !cleaned. is_empty ( ) {
478+ cleaned. push ( '\n' ) ;
479+ }
480+ fs:: write ( path, cleaned) . map_err ( error:: DecapodError :: IoError ) ?;
481+ Ok ( true )
482+ }
483+
484+ pub ( crate ) fn heal_container_runtime_override (
485+ repo_root : & Path ,
486+ reason : & str ,
487+ remediation : & str ,
488+ ) -> Result < ContainerRuntimeOverrideHeal , error:: DecapodError > {
489+ match find_container_runtime ( ) {
490+ Ok ( runtime) if ensure_container_runtime_access ( & runtime) . is_ok ( ) => {
491+ if clear_container_runtime_override ( repo_root) ? {
492+ Ok ( ContainerRuntimeOverrideHeal :: Cleared )
493+ } else {
494+ Ok ( ContainerRuntimeOverrideHeal :: Unchanged )
495+ }
496+ }
497+ _ => {
498+ if disable_container_runtime_override ( repo_root, reason, remediation) ? {
499+ Ok ( ContainerRuntimeOverrideHeal :: Added )
500+ } else {
501+ Ok ( ContainerRuntimeOverrideHeal :: Unchanged )
502+ }
503+ }
504+ }
505+ }
506+
421507fn disable_container_runtime_override (
422508 repo_root : & Path ,
423509 reason : & str ,
424510 remediation : & str ,
425- ) -> Result < ( ) , error:: DecapodError > {
511+ ) -> Result < bool , error:: DecapodError > {
426512 let path = override_file_path ( repo_root) ;
427513 if let Some ( parent) = path. parent ( ) {
428514 fs:: create_dir_all ( parent) . map_err ( error:: DecapodError :: IoError ) ?;
@@ -433,7 +519,7 @@ fn disable_container_runtime_override(
433519 String :: new ( )
434520 } ;
435521 if content. contains ( CONTAINER_DISABLE_MARKER ) {
436- return Ok ( ( ) ) ;
522+ return Ok ( false ) ;
437523 }
438524 if !content. ends_with ( '\n' ) && !content. is_empty ( ) {
439525 content. push ( '\n' ) ;
@@ -449,7 +535,7 @@ fn disable_container_runtime_override(
449535 content. push_str ( & format ! ( "remediation: {}\n " , remediation) ) ;
450536 content. push_str ( "warning: disabling isolated containers increases risk of concurrent agents stepping on each other.\n " ) ;
451537 fs:: write ( path, content) . map_err ( error:: DecapodError :: IoError ) ?;
452- Ok ( ( ) )
538+ Ok ( true )
453539}
454540
455541fn repo_root_from_store ( store : & Store ) -> Result < PathBuf , error:: DecapodError > {
@@ -1354,4 +1440,29 @@ mod tests {
13541440 assert ! ( container_runtime_disabled( & root) . expect( "disabled check" ) ) ;
13551441 let _ = fs:: remove_dir_all ( root) ;
13561442 }
1443+
1444+ #[ test]
1445+ fn clear_override_strips_container_runtime_disabled_marker ( ) {
1446+ let root = std:: env:: temp_dir ( ) . join ( format ! (
1447+ "decapod-container-clear-{}" ,
1448+ Ulid :: new( ) . to_string( ) . to_lowercase( )
1449+ ) ) ;
1450+ fs:: create_dir_all ( & root) . expect ( "mkdir" ) ;
1451+ let wrote = disable_container_runtime_override ( & root, "test-reason" , "test-remediation" )
1452+ . expect ( "disable override" ) ;
1453+ assert ! ( wrote, "override should be written" ) ;
1454+ let cleared = clear_container_runtime_override ( & root) . expect ( "clear override" ) ;
1455+ assert ! ( cleared, "disable marker should be removed" ) ;
1456+ assert ! (
1457+ !container_runtime_disabled( & root) . expect( "disabled check" ) ,
1458+ "container disable marker should be cleared"
1459+ ) ;
1460+ let content = fs:: read_to_string ( root. join ( ".decapod" ) . join ( "OVERRIDE.md" ) ) . expect ( "read" ) ;
1461+ assert ! (
1462+ !content. contains( CONTAINER_DISABLE_MARKER ) ,
1463+ "override should no longer contain the disable marker"
1464+ ) ;
1465+
1466+ let _ = fs:: remove_dir_all ( root) ;
1467+ }
13571468}
0 commit comments