Skip to content

Commit 49c70d9

Browse files
authored
Merge pull request #473 from DecapodLabs/agent/codex/code_01kk3za2ft9n96jq-master-cleanup-override
Avoid dirtying protected host checkouts with OVERRIDE writes
2 parents 46e7538 + c0bccad commit 49c70d9

4 files changed

Lines changed: 214 additions & 60 deletions

File tree

.decapod/OVERRIDE.md

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,17 +127,10 @@
127127

128128
### plugins/HEARTBEAT.md
129129

130-
### plugins/TEAMMATE.md
130+
### plugins/APTITUDE.md
131131

132132
### plugins/VERIFY.md
133133

134134
### plugins/DECIDE.md
135135

136136
### plugins/AUTOUPDATE.md
137-
138-
### plugins/CONTAINER.md
139-
## Runtime Guard Override (auto-generated)
140-
DECAPOD_CONTAINER_RUNTIME_DISABLED=true
141-
reason: No docker/podman runtime found during validation self-heal.
142-
remediation: Install Docker or Podman, then remove this override if you want strict container gating restored.
143-
warning: disabling isolated containers increases risk of concurrent agents stepping on each other.

src/lib.rs

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3054,42 +3054,23 @@ fn heal_override_checksum(
30543054
fn heal_container_runtime_override(
30553055
project_root: &Path,
30563056
) -> Result<Option<ValidationHealAction>, error::DecapodError> {
3057-
let override_path = project_root.join(".decapod").join("OVERRIDE.md");
3058-
let existing = if override_path.exists() {
3059-
fs::read_to_string(&override_path).map_err(error::DecapodError::IoError)?
3060-
} else {
3061-
String::new()
3062-
};
3063-
if existing.contains(container::CONTAINER_DISABLE_MARKER) {
3064-
return Ok(None);
3065-
}
3066-
3067-
let mut content = existing;
3068-
if !content.ends_with('\n') && !content.is_empty() {
3069-
content.push('\n');
3057+
match container::heal_container_runtime_override(
3058+
project_root,
3059+
"No docker/podman runtime found during validation self-heal.",
3060+
"Install Docker or Podman, then remove this override if you want strict container gating restored.",
3061+
)? {
3062+
container::ContainerRuntimeOverrideHeal::Added => Ok(Some(ValidationHealAction {
3063+
action: "heal_container_runtime_override".to_string(),
3064+
outcome: "updated".to_string(),
3065+
detail: "Disabled strict container-runtime enforcement because no local container runtime is available.".to_string(),
3066+
})),
3067+
container::ContainerRuntimeOverrideHeal::Cleared => Ok(Some(ValidationHealAction {
3068+
action: "heal_container_runtime_override".to_string(),
3069+
outcome: "cleared".to_string(),
3070+
detail: "Removed stale container-runtime override because Docker/Podman support is available.".to_string(),
3071+
})),
3072+
container::ContainerRuntimeOverrideHeal::Unchanged => Ok(None),
30703073
}
3071-
content.push_str(
3072-
"\n### plugins/CONTAINER.md\n\
3073-
## Runtime Guard Override (auto-generated)\n",
3074-
);
3075-
content.push_str(container::CONTAINER_DISABLE_MARKER);
3076-
content.push('\n');
3077-
content.push_str("reason: No docker/podman runtime found during validation self-heal.\n");
3078-
content.push_str("remediation: Install Docker or Podman, then remove this override if you want strict container gating restored.\n");
3079-
content.push_str("warning: disabling isolated containers increases risk of concurrent agents stepping on each other.\n");
3080-
let parent = override_path.parent().ok_or_else(|| {
3081-
error::DecapodError::ValidationError(
3082-
"OVERRIDE.md path missing parent directory".to_string(),
3083-
)
3084-
})?;
3085-
fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;
3086-
atomic_write_file(&override_path, &content)?;
3087-
3088-
Ok(Some(ValidationHealAction {
3089-
action: "heal_container_runtime_override".to_string(),
3090-
outcome: "updated".to_string(),
3091-
detail: "Disabled strict container-runtime enforcement because no local container runtime is available.".to_string(),
3092-
}))
30933074
}
30943075

30953076
fn attempt_validation_failure_heal(
@@ -3233,6 +3214,9 @@ fn run_validate_command(
32333214
if let Some(action) = heal_agents_contract(project_root)? {
32343215
heal_actions.push(action);
32353216
}
3217+
if let Some(action) = heal_container_runtime_override(project_root)? {
3218+
heal_actions.push(action);
3219+
}
32363220

32373221
let mut report = run_validation_bounded(&store, &decapod_root, validate_cli.verbose)?;
32383222
for _ in 0..2 {

src/plugins/container.rs

Lines changed: 128 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ pub struct RunSummary {
9292

9393
pub(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+
95101
pub 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+
421507
fn 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

455541
fn 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
}

tests/validate_termination.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,72 @@ fn validate_json_reports_self_heal_and_structured_summary() {
237237
assert!(payload["self_heal"].is_array());
238238
}
239239

240+
#[test]
241+
fn validate_clears_stale_container_override_when_runtime_is_available() {
242+
let (_tmp, dir, password) = setup_repo();
243+
let override_path = dir.join(".decapod").join("OVERRIDE.md");
244+
fs::write(
245+
&override_path,
246+
concat!(
247+
"### plugins/CONTAINER.md\n",
248+
"## Runtime Guard Override (auto-generated)\n",
249+
"DECAPOD_CONTAINER_RUNTIME_DISABLED=true\n",
250+
"reason: stale test marker\n",
251+
"remediation: remove when runtime is healthy\n",
252+
"warning: disabling isolated containers increases risk of concurrent agents stepping on each other.\n",
253+
),
254+
)
255+
.expect("write override");
256+
257+
let fake_bin = dir.join("fake-bin");
258+
fs::create_dir_all(&fake_bin).expect("mkdir fake-bin");
259+
let fake_docker = fake_bin.join("docker");
260+
fs::write(
261+
&fake_docker,
262+
"#!/bin/sh\nif [ \"$1\" = \"info\" ]; then exit 0; fi\nexit 0\n",
263+
)
264+
.expect("write fake docker");
265+
let chmod = Command::new("chmod")
266+
.args(["+x", fake_docker.to_str().expect("fake docker path")])
267+
.status()
268+
.expect("chmod fake docker");
269+
assert!(chmod.success(), "chmod should succeed");
270+
271+
let path = std::env::var("PATH").unwrap_or_default();
272+
let runtime_path = format!("{}:{}", fake_bin.display(), path);
273+
let validate = run_decapod(
274+
&dir,
275+
&["validate", "--format", "json"],
276+
&[
277+
("DECAPOD_AGENT_ID", "unknown"),
278+
("DECAPOD_SESSION_PASSWORD", &password),
279+
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
280+
("PATH", &runtime_path),
281+
],
282+
);
283+
assert!(
284+
validate.status.success(),
285+
"validate should clear stale runtime override; stderr:\n{}",
286+
String::from_utf8_lossy(&validate.stderr)
287+
);
288+
289+
let payload: Value =
290+
serde_json::from_slice(&validate.stdout).expect("validate json payload should parse");
291+
let heals = payload["self_heal"].as_array().expect("self_heal array");
292+
assert!(
293+
heals.iter().any(|action| {
294+
action["action"] == "heal_container_runtime_override" && action["outcome"] == "cleared"
295+
}),
296+
"expected stale container override to be cleared; payload: {payload}"
297+
);
298+
299+
let override_content = fs::read_to_string(&override_path).expect("read override");
300+
assert!(
301+
!override_content.contains("DECAPOD_CONTAINER_RUNTIME_DISABLED=true"),
302+
"container disable marker should be removed"
303+
);
304+
}
305+
240306
#[test]
241307
fn validate_parallel_contention_emits_typed_reasoned_diagnostics() {
242308
let (_tmp, dir, password) = setup_repo();

0 commit comments

Comments
 (0)