feat(validation): add patch harness with TUI controls

This commit is contained in:
James Peter
2025-09-21 13:41:23 +10:00
parent 4aaf192f94
commit ea8e9b56c6
18 changed files with 1445 additions and 2 deletions
+20
View File
@@ -949,6 +949,7 @@ dependencies = [
"serde",
"serde_bytes",
"serde_json",
"serde_yaml",
"sha1",
"shlex",
"similar",
@@ -5048,6 +5049,19 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.10.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serial2"
version = "0.2.31"
@@ -6237,6 +6251,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
+1
View File
@@ -35,6 +35,7 @@ regex-lite = "0.1.7"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
+99
View File
@@ -1,4 +1,5 @@
use crate::codex::Session;
use crate::patch_harness::run_patch_harness;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use crate::protocol::FileChange;
@@ -7,6 +8,7 @@ use crate::safety::SafetyCheck;
use crate::safety::assess_patch_safety;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -31,6 +33,7 @@ pub(crate) enum InternalApplyPatchInvocation {
pub(crate) struct ApplyPatchExec {
pub(crate) action: ApplyPatchAction,
pub(crate) user_explicitly_approved_this_action: bool,
pub(crate) harness_summary_json: Option<String>,
}
impl From<ResponseInputItem> for InternalApplyPatchInvocation {
@@ -45,6 +48,100 @@ pub(crate) async fn apply_patch(
call_id: &str,
action: ApplyPatchAction,
) -> InternalApplyPatchInvocation {
// Run the validation harness before assessing safety so we can surface a
// concise status message and capture structured findings for the model.
let (harness_summary_json, harness_status_message) = {
let mut summary_json: Option<String> = None;
let mut status_message: Option<String> = None;
let validation_cfg = sess.validation_config();
let github_cfg = sess.get_github_config();
if let (Ok(validation_cfg), Ok(github_cfg)) = (validation_cfg.read(), github_cfg.read()) {
if let Some((mut findings, mut ran_checks)) = run_patch_harness(
&action,
sess.get_cwd(),
&*validation_cfg,
&*github_cfg,
) {
const MAX_ISSUES: usize = 12;
let total_issues = findings.len();
let truncated = total_issues > MAX_ISSUES;
if truncated {
findings.truncate(MAX_ISSUES);
}
findings.retain(|finding| {
finding.tool.trim().len() <= 120 && finding.message.trim().len() <= 800
});
let issues_json: Vec<serde_json::Value> = findings
.iter()
.map(|finding| {
let relative_file = finding
.file
.as_ref()
.and_then(|path| path.strip_prefix(sess.get_cwd()).ok())
.map(|path| path.display().to_string());
json!({
"tool": finding.tool,
"file": relative_file,
"msg": finding.message,
})
})
.collect();
summary_json = Some(
json!({
"validation": {
"issues": issues_json,
"checks": ran_checks,
"issue_count": total_issues,
"truncated": truncated,
}
})
.to_string(),
);
let mut lines: Vec<String> = Vec::new();
if total_issues == 0 {
lines.push("✅ Validate New Code: no issues".to_string());
} else {
lines.push(format!("❌ Validate New Code: {total_issues} issue(s)"));
for finding in findings.iter() {
let mut parts = vec![finding.tool.clone()];
if let Some(rel) = finding
.file
.as_ref()
.and_then(|p| p.strip_prefix(sess.get_cwd()).ok())
.map(|p| p.display().to_string())
{
parts.push(rel);
}
let mut msg = finding.message.clone();
if msg.len() > 160 {
msg.truncate(157);
msg.push_str("…");
}
parts.push(msg);
lines.push(format!("• {}", parts.join(" — ")));
}
if truncated {
let remaining = total_issues - findings.len();
lines.push(format!("… plus {remaining} more issue(s)"));
}
}
if ran_checks.is_empty() {
lines.push("Checks run: none".to_string());
} else {
ran_checks.sort();
lines.push(format!("Checks run: {}", ran_checks.join(", ")));
}
status_message = Some(lines.join("\n"));
}
}
(summary_json, status_message)
};
if let Some(message) = harness_status_message.as_ref() {
sess.notify_background_event(sub_id, message.clone()).await;
}
match assess_patch_safety(
&action,
sess.get_approval_policy(),
@@ -55,6 +152,7 @@ pub(crate) async fn apply_patch(
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
action,
user_explicitly_approved_this_action: false,
harness_summary_json: harness_summary_json.clone(),
})
}
SafetyCheck::AskUser => {
@@ -73,6 +171,7 @@ pub(crate) async fn apply_patch(
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
action,
user_explicitly_approved_this_action: true,
harness_summary_json,
})
}
ReviewDecision::Denied | ReviewDecision::Abort => {
+70 -2
View File
@@ -8,6 +8,7 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use std::sync::atomic::AtomicU64;
use std::time::Duration;
@@ -823,6 +824,8 @@ pub(crate) struct Session {
/// Track the last screenshot path and hash to detect changes
last_screenshot_info: Mutex<Option<(PathBuf, Vec<u8>, Vec<u8>)>>, // (path, phash, dhash)
confirm_guard: ConfirmGuardRuntime,
github: Arc<RwLock<crate::config_types::GithubConfig>>,
validation: Arc<RwLock<crate::config_types::ValidationConfig>>,
}
#[derive(Debug, Clone)]
@@ -861,6 +864,43 @@ impl Session {
&self.sandbox_policy
}
pub(crate) fn get_github_config(&self) -> Arc<RwLock<crate::config_types::GithubConfig>> {
Arc::clone(&self.github)
}
pub(crate) fn validation_config(&self) -> Arc<RwLock<crate::config_types::ValidationConfig>> {
Arc::clone(&self.validation)
}
pub(crate) fn update_validation_patch_harness(&self, enabled: bool) {
if let Ok(mut cfg) = self.validation.write() {
cfg.patch_harness = enabled;
}
}
pub(crate) fn update_validation_tool(&self, name: &str, enable: bool) {
if name == "actionlint" {
if let Ok(mut github) = self.github.write() {
github.actionlint_on_patch = enable;
}
return;
}
if let Ok(mut cfg) = self.validation.write() {
let tools = &mut cfg.tools;
match name {
"shellcheck" => tools.shellcheck = Some(enable),
"markdownlint" => tools.markdownlint = Some(enable),
"hadolint" => tools.hadolint = Some(enable),
"yamllint" => tools.yamllint = Some(enable),
"rustfmt" => tools.rustfmt = Some(enable),
"shfmt" => tools.shfmt = Some(enable),
"prettier" => tools.prettier = Some(enable),
_ => {}
}
}
}
fn resolve_path(&self, path: Option<String>) -> PathBuf {
path.as_ref()
.map(PathBuf::from)
@@ -1505,7 +1545,7 @@ impl Session {
/// Helper that emits a BackgroundEvent with the given message. This keeps
/// the call‑sites terse so adding more diagnostics does not clutter the
/// core agent logic.
async fn notify_background_event(&self, sub_id: &str, message: impl Into<String>) {
pub(crate) async fn notify_background_event(&self, sub_id: &str, message: impl Into<String>) {
let event = self.make_event(
sub_id,
EventMsg::BackgroundEvent(BackgroundEventEvent { message: message.into() }),
@@ -2198,6 +2238,8 @@ async fn submission_loop(
last_system_status: Mutex::new(None),
last_screenshot_info: Mutex::new(None),
confirm_guard: ConfirmGuardRuntime::from_config(&config.confirm_guard),
github: Arc::new(RwLock::new(config.github.clone())),
validation: Arc::new(RwLock::new(config.validation.clone())),
}));
if let Some(sess_arc) = &sess {
if !config.always_allow_commands.is_empty() {
@@ -2382,6 +2424,20 @@ async fn submission_loop(
other => sess.notify_approval(&id, other),
}
}
Op::UpdateValidationPatchHarness { enabled } => {
if let Some(sess) = sess.as_ref() {
sess.update_validation_patch_harness(enabled);
} else {
send_no_session_event(sub.id).await;
}
}
Op::UpdateValidationTool { name, enable } => {
if let Some(sess) = sess.as_ref() {
sess.update_validation_tool(&name, enable);
} else {
send_no_session_event(sub.id).await;
}
}
Op::AddToHistory { text } => {
// TODO: What should we do if we got AddToHistory before ConfigureSession?
// currently, if ConfigureSession has resume path, this history will be ignored
@@ -6203,6 +6259,7 @@ async fn handle_container_exec_with_params(
Some(ApplyPatchExec {
action: ApplyPatchAction { patch, cwd, .. },
user_explicitly_approved_this_action,
..
}) => {
let path_to_codex = std::env::current_exe()
.ok()
@@ -6262,6 +6319,10 @@ async fn handle_container_exec_with_params(
}
};
let harness_summary_json = apply_patch_exec
.as_ref()
.and_then(|exec| exec.harness_summary_json.clone());
let sandbox_type = match safety {
SafetyCheck::AutoApprove { sandbox_type } => sandbox_type,
SafetyCheck::AskUser => {
@@ -6319,6 +6380,7 @@ async fn handle_container_exec_with_params(
|ApplyPatchExec {
action,
user_explicitly_approved_this_action,
..
}| ApplyPatchCommandContext {
user_explicitly_approved_this_action,
changes: convert_apply_patch_to_protocol(&action),
@@ -6480,7 +6542,13 @@ async fn handle_container_exec_with_params(
};
if let Some(done) = done_opt {
let is_success = done.exit_code == 0;
let content = format_exec_output_with_limit(sess, &sub_id, &call_id, &done);
let mut content = format_exec_output_with_limit(sess, &sub_id, &call_id, &done);
if let Some(harness) = harness_summary_json.as_ref() {
if !harness.is_empty() {
content.push('\n');
content.push_str(harness);
}
}
return ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { content, success: Some(is_success) } };
} else {
// Fallback (should not happen): indicate completion without detail
+76
View File
@@ -7,6 +7,7 @@ use crate::config_types::AllowedCommandMatchKind;
use crate::config_types::BrowserConfig;
use crate::config_types::History;
use crate::config_types::GithubConfig;
use crate::config_types::ValidationConfig;
use crate::config_types::ThemeName;
use crate::config_types::ThemeColors;
use crate::config_types::McpServerConfig;
@@ -234,6 +235,9 @@ pub struct Config {
/// GitHub integration configuration.
pub github: GithubConfig,
/// Validation harness configuration.
pub validation: ValidationConfig,
/// Resolved subagent command configurations (including custom ones).
/// If a command with name `plan|solve|code` exists here, it overrides
/// the built-in defaults for that slash command.
@@ -817,6 +821,70 @@ pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Res
Ok(())
}
/// Persist `github.actionlint_on_patch = <enabled>`.
pub fn set_github_actionlint_on_patch(
codex_home: &Path,
enabled: bool,
) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
let mut doc = match std::fs::read_to_string(&read_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
doc["github"]["actionlint_on_patch"] = toml_edit::value(enabled);
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
Ok(())
}
/// Persist `[validation].patch_harness = <enabled>`.
pub fn set_validation_patch_harness(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
let mut doc = match std::fs::read_to_string(&read_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
doc["validation"]["patch_harness"] = toml_edit::value(enabled);
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
Ok(())
}
/// Persist `[validation.tools.<tool>] = <enabled>`.
pub fn set_validation_tool_enabled(
codex_home: &Path,
tool: &str,
enabled: bool,
) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
let mut doc = match std::fs::read_to_string(&read_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
doc["validation"]["tools"][tool] = toml_edit::value(enabled);
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
Ok(())
}
/// Persist per-project access mode under `[projects."<path>"]` with
/// `approval_policy` and `sandbox_mode`.
pub fn set_project_access_mode(
@@ -1354,6 +1422,9 @@ pub struct ConfigToml {
/// GitHub integration configuration.
pub github: Option<GithubConfig>,
/// Validation harness configuration.
pub validation: Option<ValidationConfig>,
/// Configuration for subagent commands (built-ins and custom).
#[serde(default)]
pub subagents: Option<crate::config_types::SubagentsToml>,
@@ -1810,6 +1881,7 @@ impl Config {
// Already computed before moving codex_home
using_chatgpt_auth,
github: cfg.github.unwrap_or_default(),
validation: cfg.validation.unwrap_or_default(),
subagent_commands: cfg
.subagents
.map(|s| s.commands)
@@ -2475,6 +2547,7 @@ model_verbosity = "high"
debug: false,
using_chatgpt_auth: false,
github: GithubConfig::default(),
validation: ValidationConfig::default(),
experimental_resume: None,
tui_notifications: Default::default(),
},
@@ -2541,6 +2614,9 @@ model_verbosity = "high"
debug: false,
using_chatgpt_auth: false,
github: GithubConfig::default(),
validation: ValidationConfig::default(),
validation: ValidationConfig::default(),
validation: ValidationConfig::default(),
experimental_resume: None,
tui_notifications: Default::default(),
};
+53
View File
@@ -217,6 +217,59 @@ pub struct GithubConfig {
/// `[github]` with `check_workflows_on_push = false`.
#[serde(default = "default_true")]
pub check_workflows_on_push: bool,
/// When true, run `actionlint` on modified workflows during apply_patch.
#[serde(default)]
pub actionlint_on_patch: bool,
/// Optional explicit executable path for actionlint.
#[serde(default)]
pub actionlint_path: Option<PathBuf>,
/// Treat actionlint findings as blocking when composing approval text.
#[serde(default)]
pub actionlint_strict: bool,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct ValidationConfig {
/// Master toggle for the patch validation harness.
#[serde(default = "default_true")]
pub patch_harness: bool,
/// Optional allowlist restricting which external tools may run.
#[serde(default)]
pub tools_allowlist: Option<Vec<String>>,
/// Timeout (seconds) for each external tool invocation.
#[serde(default)]
pub timeout_seconds: Option<u64>,
/// Per-tool enable flags (unset implies enabled).
#[serde(default)]
pub tools: ValidationTools,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
patch_harness: true,
tools_allowlist: None,
timeout_seconds: None,
tools: ValidationTools::default(),
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct ValidationTools {
pub shellcheck: Option<bool>,
pub markdownlint: Option<bool>,
pub hadolint: Option<bool>,
pub yamllint: Option<bool>,
pub rustfmt: Option<bool>,
pub shfmt: Option<bool>,
pub prettier: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
+2
View File
@@ -66,6 +66,7 @@ pub mod default_client;
pub mod model_family;
mod openai_model_info;
mod openai_tools;
mod patch_harness;
pub mod plan_tool;
pub mod project_doc;
mod rollout;
@@ -75,6 +76,7 @@ pub mod shell;
pub mod spawn;
pub mod terminal;
mod tool_apply_patch;
mod workflow_validation;
pub mod turn_diff_tracker;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::RolloutRecorder;
+307
View File
@@ -0,0 +1,307 @@
use crate::config_types::{GithubConfig, ValidationConfig};
use crate::workflow_validation::maybe_run_actionlint;
use codex_apply_patch::{ApplyPatchAction, ApplyPatchFileChange};
use serde_json as json;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
#[derive(Debug, Clone)]
pub struct HarnessFinding {
pub tool: String,
pub file: Option<PathBuf>,
pub message: String,
}
/// Run fast validations on the files touched by a patch. Returns `None` when the
/// harness is disabled and no checks were executed.
pub fn run_patch_harness(
action: &ApplyPatchAction,
cwd: &Path,
cfg: &ValidationConfig,
github: &GithubConfig,
) -> Option<(Vec<HarnessFinding>, Vec<String>)> {
if !cfg.patch_harness {
return None;
}
let mut findings: Vec<HarnessFinding> = Vec::new();
let mut ran: Vec<String> = Vec::new();
let mut record_ran = |name: &str| {
if !ran.iter().any(|existing| existing == name) {
ran.push(name.to_string());
}
};
// 1) Built-in structural parses (JSON/TOML/YAML).
for (path, change) in action.changes() {
let (ext, contents_opt) = match change {
ApplyPatchFileChange::Add { content } => (path.extension().and_then(|e| e.to_str()), Some(content)),
ApplyPatchFileChange::Update { new_content, .. } => (path.extension().and_then(|e| e.to_str()), Some(new_content)),
ApplyPatchFileChange::Delete { .. } => (path.extension().and_then(|e| e.to_str()), None),
};
let Some(contents) = contents_opt else { continue };
match ext.unwrap_or("") {
"json" => {
record_ran("json-parse");
if let Err(err) = json::from_str::<json::Value>(contents) {
findings.push(HarnessFinding {
tool: "json-parse".to_string(),
file: Some(path.clone()),
message: format!("invalid JSON: {err}"),
});
}
}
"toml" => {
record_ran("toml-parse");
if let Err(err) = toml::from_str::<toml::Value>(contents) {
findings.push(HarnessFinding {
tool: "toml-parse".to_string(),
file: Some(path.clone()),
message: format!("invalid TOML: {err}"),
});
}
}
"yml" | "yaml" => {
record_ran("yaml-parse");
if let Err(err) = serde_yaml::from_str::<serde_yaml::Value>(contents) {
findings.push(HarnessFinding {
tool: "yaml-parse".to_string(),
file: Some(path.clone()),
message: format!("invalid YAML: {err}"),
});
}
}
_ => {}
}
}
// 2) Workflow checks (actionlint plugin).
if let Some(lines) = maybe_run_actionlint(action, cwd, github) {
if !lines.is_empty() {
record_ran("actionlint");
for line in lines.into_iter().take(24) {
findings.push(HarnessFinding { tool: "actionlint".to_string(), file: None, message: line });
}
}
}
// 3) External tools (shellcheck, markdownlint, etc.).
let allow = cfg.tools_allowlist.clone().unwrap_or_default();
let timeout = cfg.timeout_seconds.unwrap_or(6);
// Stage touched files into a temporary workspace so external tools can run safely.
let temp = TempDir::new().ok()?;
let staged_root = temp.path();
for (path, change) in action.changes() {
let rel = path.strip_prefix(cwd).unwrap_or(path);
let dest = staged_root.join(rel);
let _ = fs::create_dir_all(dest.parent().unwrap_or(staged_root));
match change {
ApplyPatchFileChange::Add { content } | ApplyPatchFileChange::Update { new_content: content, .. } => {
if let Ok(mut file) = fs::File::create(&dest) {
let _ = file.write_all(content.as_bytes());
}
}
ApplyPatchFileChange::Delete { .. } => {
let _ = fs::remove_file(&dest);
}
}
}
let is_allowed = |tool: &str| allow.is_empty() || allow.iter().any(|entry| entry == tool);
let run_tool = |tool: &str, args: &[&str], files: &[PathBuf]| -> Vec<HarnessFinding> {
if files.is_empty() || !is_allowed(tool) {
return Vec::new();
}
let Some(exe) = which(Path::new(tool)) else { return Vec::new() };
let mut cmd = std::process::Command::new(exe);
cmd.current_dir(staged_root);
cmd.args(args);
cmd.args(files);
let output = run_with_timeout(cmd, timeout);
output
.into_iter()
.flat_map(|(stdout, stderr)| {
let mut lines: Vec<String> = Vec::new();
if !stdout.is_empty() {
lines.extend(String::from_utf8_lossy(&stdout).lines().map(|s| s.to_string()));
}
if !stderr.is_empty() {
lines.extend(String::from_utf8_lossy(&stderr).lines().map(|s| s.to_string()));
}
lines
})
.map(|message| HarnessFinding { tool: tool.to_string(), file: None, message })
.collect()
};
let changed_paths: Vec<&Path> = action
.changes()
.iter()
.filter_map(|(path, change)| matches!(change, ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Update { .. }).then_some(path.as_path()))
.collect();
let shell_scripts: Vec<PathBuf> = changed_paths
.iter()
.filter(|path| is_shell_script(path))
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
.collect();
if cfg.tools.shellcheck.unwrap_or(true) && !shell_scripts.is_empty() {
if which(Path::new("shellcheck")).is_some() {
record_ran("shellcheck");
}
findings.extend(run_tool("shellcheck", &["-f", "gcc"], &shell_scripts));
}
let markdown_files: Vec<PathBuf> = changed_paths
.iter()
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
.collect();
if cfg.tools.markdownlint.unwrap_or(true) && !markdown_files.is_empty() {
if which(Path::new("markdownlint")).is_some() || which(Path::new("markdownlint-cli2")).is_some() {
record_ran("markdownlint");
}
let mut lines = run_tool("markdownlint", &[], &markdown_files);
if lines.is_empty() {
lines = run_tool("markdownlint-cli2", &[], &markdown_files);
}
findings.extend(lines);
}
let docker_files: Vec<PathBuf> = changed_paths
.iter()
.filter(|path| is_dockerfile(path))
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
.collect();
if cfg.tools.hadolint.unwrap_or(true) && !docker_files.is_empty() {
if which(Path::new("hadolint")).is_some() {
record_ran("hadolint");
}
findings.extend(run_tool("hadolint", &[], &docker_files));
}
let yaml_files: Vec<PathBuf> = changed_paths
.iter()
.filter(|path| matches!(path.extension().and_then(|ext| ext.to_str()), Some("yml" | "yaml")))
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
.collect();
if cfg.tools.yamllint.unwrap_or(true) && !yaml_files.is_empty() {
if which(Path::new("yamllint")).is_some() {
record_ran("yamllint");
}
findings.extend(run_tool("yamllint", &["-f", "parsable"], &yaml_files));
}
let rust_files: Vec<PathBuf> = changed_paths
.iter()
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("rs"))
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
.collect();
if cfg.tools.rustfmt.unwrap_or(true) && !rust_files.is_empty() {
if which(Path::new("rustfmt")).is_some() {
record_ran("rustfmt");
}
findings.extend(run_tool("rustfmt", &["--check"], &rust_files));
}
if cfg.tools.shfmt.unwrap_or(true) && !shell_scripts.is_empty() {
if which(Path::new("shfmt")).is_some() {
record_ran("shfmt");
}
findings.extend(run_tool("shfmt", &["-d"], &shell_scripts));
}
let prettier_exts = [
"js", "jsx", "ts", "tsx", "json", "css", "scss", "less", "html", "yml", "yaml",
];
let prettier_files: Vec<PathBuf> = changed_paths
.iter()
.filter(|path| path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| prettier_exts.contains(&ext))
.unwrap_or(false))
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
.collect();
if cfg.tools.prettier.unwrap_or(true) && !prettier_files.is_empty() {
if which(Path::new("prettier")).is_some() {
record_ran("prettier");
}
findings.extend(run_tool("prettier", &["--check"], &prettier_files));
}
if findings.is_empty() && ran.is_empty() {
None
} else {
Some((findings, ran))
}
}
fn is_shell_script(path: &Path) -> bool {
match path.extension().and_then(|ext| ext.to_str()) {
Some("sh") => true,
_ => {
std::fs::read(path)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.map(|contents| contents.starts_with("#!/"))
.unwrap_or(false)
}
}
}
fn is_dockerfile(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
name.eq_ignore_ascii_case("Dockerfile") || name.starts_with("Dockerfile.")
}
fn which(exe: &Path) -> Option<PathBuf> {
if exe.is_absolute() {
return exe.exists().then(|| exe.to_path_buf());
}
let name = exe.as_os_str();
let paths: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_else(Vec::new);
for dir in paths {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Option<(Vec<u8>, Vec<u8>)> {
use std::process::Stdio;
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let Ok(mut child) = cmd.spawn() else { return None };
let start = std::time::Instant::now();
loop {
if let Some(status) = child.try_wait().ok().flatten() {
let _ = status; // success/failure surfaces through captured output
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(mut out) = child.stdout.take() {
let _ = std::io::Read::read_to_end(&mut out, &mut stdout);
}
if let Some(mut err) = child.stderr.take() {
let _ = std::io::Read::read_to_end(&mut err, &mut stderr);
}
return Some((stdout, stderr));
}
if start.elapsed().as_secs() >= timeout_secs {
let _ = child.kill();
return None;
}
std::thread::sleep(std::time::Duration::from_millis(40));
}
}
+11
View File
@@ -137,6 +137,17 @@ pub enum Op {
decision: ReviewDecision,
},
/// Update the validation harness toggle in the running session.
UpdateValidationPatchHarness {
enabled: bool,
},
/// Update a specific validation tool toggle for the session.
UpdateValidationTool {
name: String,
enable: bool,
},
/// Append an entry to the persistent cross-session message history.
///
/// Note the entry is not guaranteed to be logged if the user has
+154
View File
@@ -0,0 +1,154 @@
use codex_apply_patch::{ApplyPatchAction, ApplyPatchFileChange};
use crate::config_types::GithubConfig;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn is_workflow_path(path: &Path, cwd: &Path) -> bool {
let absolute = if path.is_absolute() { path.to_path_buf() } else { cwd.join(path) };
let mut saw_dot_github = false;
let mut saw_workflows = false;
for component in absolute.components() {
let segment = component.as_os_str().to_string_lossy();
if !saw_dot_github && segment == ".github" {
saw_dot_github = true;
continue;
}
if saw_dot_github && !saw_workflows && segment == "workflows" {
saw_workflows = true;
continue;
}
if saw_dot_github && saw_workflows {
return matches!(
absolute.extension().and_then(|ext| ext.to_str()),
Some("yml" | "yaml")
);
}
}
false
}
/// Run `actionlint` against a temporary workspace containing the proposed
/// workflow changes. Returns diagnostic lines when the tool is available and
/// produced output.
pub fn maybe_run_actionlint(
action: &ApplyPatchAction,
cwd: &Path,
config: &GithubConfig,
) -> Option<Vec<String>> {
if !config.actionlint_on_patch {
return None;
}
let exe = config
.actionlint_path
.clone()
.unwrap_or_else(|| PathBuf::from("actionlint"));
let executable = if exe.is_absolute() {
exe.exists().then_some(exe)
} else {
which(&exe)
};
let Some(executable) = executable else {
return None;
};
let touches_workflow = action
.changes()
.keys()
.any(|path| is_workflow_path(path, cwd));
if !touches_workflow {
return None;
}
let temp = TempDir::new().ok()?;
let temp_root = temp.path();
let temp_workflows = temp_root.join(".github/workflows");
let _ = fs::create_dir_all(&temp_workflows);
let source_workflows = cwd.join(".github/workflows");
if source_workflows.exists() {
let _ = copy_dir_flat(&source_workflows, &temp_workflows);
}
for (path, change) in action.changes() {
if !is_workflow_path(path, cwd) {
continue;
}
let relative = path.strip_prefix(cwd).unwrap_or(path);
let destination = temp_root.join(relative);
let _ = fs::create_dir_all(destination.parent().unwrap_or(&temp_workflows));
match change {
ApplyPatchFileChange::Add { content } | ApplyPatchFileChange::Update { new_content: content, .. } => {
if let Ok(mut file) = fs::File::create(&destination) {
let _ = file.write_all(content.as_bytes());
}
}
ApplyPatchFileChange::Delete { .. } => {
let _ = fs::remove_file(&destination);
}
}
}
let output = std::process::Command::new(executable)
.arg("-color")
.arg("never")
.current_dir(temp_root)
.output()
.ok()?;
let mut lines: Vec<String> = Vec::new();
if !output.stdout.is_empty() {
lines.extend(String::from_utf8_lossy(&output.stdout).lines().map(|s| s.to_string()));
}
if !output.stderr.is_empty() {
lines.extend(String::from_utf8_lossy(&output.stderr).lines().map(|s| s.to_string()));
}
if lines.is_empty() {
None
} else {
Some(lines)
}
}
fn copy_dir_flat(src: &Path, dst: &Path) -> std::io::Result<()> {
if !dst.exists() {
fs::create_dir_all(dst)?;
}
for entry in fs::read_dir(src)? {
let entry = entry?;
if entry.file_type()?.is_file() {
let target = dst.join(entry.file_name());
let _ = fs::copy(entry.path(), target);
}
}
Ok(())
}
fn which(exe: &Path) -> Option<PathBuf> {
let name = exe.as_os_str();
let paths: Vec<PathBuf> = std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).collect())
.unwrap_or_else(Vec::new);
for dir in paths {
let candidate = dir.join(name);
if candidate.is_file() && is_executable(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path)
.map(|meta| meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
+15
View File
@@ -1296,6 +1296,11 @@ impl App<'_> {
widget.handle_github_command(command_args);
}
}
SlashCommand::Validation => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_validation_command(command_args);
}
}
SlashCommand::Mcp => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_mcp_command(command_args);
@@ -1452,6 +1457,16 @@ impl App<'_> {
widget.set_github_watcher(enabled);
}
}
AppEvent::UpdateValidationPatchHarness(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_validation_patch_harness(enabled);
}
}
AppEvent::UpdateValidationTool { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_validation_tool(&name, enable);
}
}
AppEvent::SetTerminalTitle { title } => {
self.terminal_title_override = title;
self.apply_terminal_title();
+4
View File
@@ -119,6 +119,10 @@ pub(crate) enum AppEvent {
/// Update GitHub workflow monitoring toggle
UpdateGithubWatcher(bool),
/// Update validation harness master toggle
UpdateValidationPatchHarness(bool),
/// Enable/disable a specific validation tool
UpdateValidationTool { name: String, enable: bool },
/// Enable/disable a specific MCP server
UpdateMcpServer { name: String, enable: bool },
+14
View File
@@ -38,6 +38,7 @@ mod textarea;
pub mod form_text_field;
mod theme_selection_view;
mod verbosity_selection_view;
pub(crate) mod validation_settings_view;
#[cfg(not(debug_assertions))]
mod update_settings_view;
@@ -562,6 +563,19 @@ impl BottomPane<'_> {
self.request_redraw();
}
/// Show validation harness settings (master toggle + per-tool toggles).
pub fn show_validation_settings(
&mut self,
patch_harness: bool,
tools: Vec<(validation_settings_view::ToolStatus, bool)>,
) {
use validation_settings_view::ValidationSettingsView;
let view = ValidationSettingsView::new(patch_harness, tools, self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
}
/// Show Subagent editor UI
pub fn show_subagent_editor(
&mut self,
@@ -0,0 +1,367 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::BottomPaneView;
use super::BottomPane;
#[derive(Clone, Debug)]
pub(crate) struct ToolStatus {
pub name: &'static str,
pub installed: bool,
pub install_hint: String,
}
pub(crate) struct ValidationSettingsView {
patch_harness: bool,
tools: Vec<(ToolStatus, bool)>,
app_event_tx: AppEventSender,
is_complete: bool,
selected_row: usize,
}
impl ValidationSettingsView {
pub fn new(
patch_harness: bool,
tools: Vec<(ToolStatus, bool)>,
app_event_tx: AppEventSender,
) -> Self {
Self {
patch_harness,
tools,
app_event_tx,
is_complete: false,
selected_row: 0,
}
}
fn toggle_patch_harness(&mut self) {
self.patch_harness = !self.patch_harness;
self.app_event_tx
.send(AppEvent::UpdateValidationPatchHarness(self.patch_harness));
}
fn toggle_tool(&mut self, idx: usize) {
if let Some((status, enabled)) = self.tools.get_mut(idx) {
let new_value = !*enabled;
*enabled = new_value;
self.app_event_tx.send(AppEvent::UpdateValidationTool {
name: status.name.to_string(),
enable: new_value,
});
}
}
}
impl<'a> BottomPaneView<'a> for ValidationSettingsView {
fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) {
let tool_rows = self.tools.len();
let max_row = tool_rows + 1; // include close row
match key_event {
KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row > 0 {
self.selected_row -= 1;
}
}
KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row < max_row {
self.selected_row += 1;
}
}
KeyEvent { code: KeyCode::Left | KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle_patch_harness();
} else if (1..=tool_rows).contains(&self.selected_row) {
self.toggle_tool(self.selected_row - 1);
}
}
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle_patch_harness();
} else if self.selected_row == max_row {
self.is_complete = true;
} else if let Some((status, _)) = self.tools.get(self.selected_row - 1) {
if !status.installed && !status.install_hint.is_empty() {
pane.flash_footer_notice(format!("Prefilled install command for {}", status.name));
self.app_event_tx
.send(AppEvent::PrefillComposer(status.install_hint.clone()));
}
}
}
KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle_patch_harness();
} else if (1..=tool_rows).contains(&self.selected_row) {
self.toggle_tool(self.selected_row - 1);
}
}
KeyEvent { code: KeyCode::Esc, .. } => {
self.is_complete = true;
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.is_complete
}
fn desired_height(&self, _width: u16) -> u16 {
(8 + self.tools.len() as u16).min(20)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title(" Validation Settings ")
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let mut lines: Vec<Line> = Vec::new();
let max_row = self.tools.len().saturating_add(1);
lines.push(Line::from(Span::styled(
"Validation runs before patches apply; toggles affect which checks run.",
Style::default().fg(crate::colors::text_dim()),
)));
lines.push(Line::from(""));
let mut status_style = Style::default().fg(crate::colors::text());
if self.selected_row == 0 {
status_style = status_style.bg(crate::colors::selection()).add_modifier(Modifier::BOLD);
}
lines.push(Line::from(vec![
Span::styled("Validate New Code: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
if self.patch_harness { "Enabled" } else { "Disabled" },
status_style,
),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Tools:", Style::default().fg(crate::colors::text_dim()))));
for (index, (status, enabled)) in self.tools.iter().enumerate() {
let selected = self.selected_row == index + 1;
let mut name_style = if status.installed {
Style::default().fg(crate::colors::success())
} else {
Style::default().fg(crate::colors::warning())
};
if selected {
name_style = name_style.bg(crate::colors::selection()).add_modifier(Modifier::BOLD);
}
let extra = if status.installed {
""
} else {
" • missing (Enter: copy install command)"
};
lines.push(Line::from(vec![
Span::raw("• "),
Span::styled(status.name, name_style),
Span::raw(" — "),
Span::styled(if *enabled { "on" } else { "off" }, Style::default().fg(crate::colors::dim())),
Span::raw(extra),
]));
}
lines.push(Line::from(""));
let close_selected = self.selected_row == max_row;
let close_style = if close_selected {
Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![Span::styled(
if close_selected { "› Close" } else { " Close" },
close_style,
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("←→/Space", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle / Copy install "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Close"),
]));
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(
Rect {
x: inner.x.saturating_add(1),
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
},
buf,
);
}
}
pub(crate) fn detect_tools() -> Vec<ToolStatus> {
let mut result = Vec::new();
let tools = [
("actionlint", actionlint_hint()),
("shellcheck", shellcheck_hint()),
("markdownlint", markdownlint_hint()),
("hadolint", hadolint_hint()),
("yamllint", yamllint_hint()),
("rustfmt", rustfmt_hint()),
("shfmt", shfmt_hint()),
("prettier", prettier_hint()),
];
for (name, hint) in tools.into_iter() {
let installed = which(name).is_some();
result.push(ToolStatus { name, installed, install_hint: hint });
}
result
}
fn which(exe: &str) -> Option<std::path::PathBuf> {
let name = std::ffi::OsStr::new(exe);
let paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).collect())
.unwrap_or_else(Vec::new);
for dir in paths {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn has(cmd: &str) -> bool {
which(cmd).is_some()
}
fn is_macos() -> bool {
cfg!(target_os = "macos")
}
pub fn actionlint_hint() -> String {
if is_macos() && has("brew") {
return "brew install actionlint".to_string();
}
if has("brew") {
return "brew install actionlint".to_string();
}
"See: https://github.com/rhysd/actionlint#installation".to_string()
}
pub fn shellcheck_hint() -> String {
if is_macos() && has("brew") {
return "brew install shellcheck".to_string();
}
if has("apt-get") {
return "sudo apt-get update && sudo apt-get install -y shellcheck".to_string();
}
if has("dnf") {
return "sudo dnf install -y ShellCheck".to_string();
}
if has("yum") {
return "sudo yum install -y ShellCheck".to_string();
}
if has("brew") {
return "brew install shellcheck".to_string();
}
"https://www.shellcheck.net/".to_string()
}
pub fn markdownlint_hint() -> String {
if has("npm") {
return "npm i -g markdownlint-cli2".to_string();
}
if is_macos() && has("brew") {
return "brew install markdownlint-cli2".to_string();
}
"npm i -g markdownlint-cli2".to_string()
}
pub fn hadolint_hint() -> String {
if is_macos() && has("brew") {
return "brew install hadolint".to_string();
}
if has("apt-get") {
return "sudo apt-get update && sudo apt-get install -y hadolint".to_string();
}
if has("dnf") {
return "sudo dnf install -y hadolint".to_string();
}
if has("yum") {
return "sudo yum install -y hadolint".to_string();
}
if has("brew") {
return "brew install hadolint".to_string();
}
"https://github.com/hadolint/hadolint".to_string()
}
pub fn yamllint_hint() -> String {
if is_macos() && has("brew") {
return "brew install yamllint".to_string();
}
if has("apt-get") {
return "sudo apt-get update && sudo apt-get install -y yamllint".to_string();
}
if has("dnf") {
return "sudo dnf install -y yamllint".to_string();
}
if has("yum") {
return "sudo yum install -y yamllint".to_string();
}
if has("brew") {
return "brew install yamllint".to_string();
}
"https://yamllint.readthedocs.io/".to_string()
}
pub fn rustfmt_hint() -> String {
if has("rustup") {
return "rustup component add rustfmt".to_string();
}
"Install Rust (rustup) then run: rustup component add rustfmt".to_string()
}
pub fn shfmt_hint() -> String {
if is_macos() && has("brew") {
return "brew install shfmt".to_string();
}
if has("apt-get") {
return "sudo apt-get update && sudo apt-get install -y shfmt".to_string();
}
if has("dnf") {
return "sudo dnf install -y shfmt".to_string();
}
if has("yum") {
return "sudo yum install -y shfmt".to_string();
}
if has("brew") {
return "brew install shfmt".to_string();
}
"https://github.com/mvdan/sh".to_string()
}
pub fn prettier_hint() -> String {
if has("npm") {
return "npx --yes prettier --write <path>".to_string();
}
if is_macos() && has("brew") {
return "brew install prettier".to_string();
}
"npm install --global prettier".to_string()
}
+218
View File
@@ -108,6 +108,8 @@ use crate::app_event::{
TerminalRunController,
};
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::validation_settings_view;
use crate::bottom_pane::validation_settings_view::ToolStatus;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
@@ -137,7 +139,10 @@ use codex_ansi_escape::ansi_escape_line;
use codex_browser::BrowserManager;
use codex_core::config::find_codex_home;
use codex_core::config::resolve_codex_path_for_read;
use codex_core::config::set_github_actionlint_on_patch;
use codex_core::config::set_github_check_on_push;
use codex_core::config::set_validation_patch_harness;
use codex_core::config::set_validation_tool_enabled;
use codex_file_search::FileMatch;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -9341,6 +9346,219 @@ impl ChatWidget<'_> {
});
}
fn validation_tool_flag_mut(
&mut self,
name: &str,
) -> Option<&mut Option<bool>> {
let tools = &mut self.config.validation.tools;
match name {
"shellcheck" => Some(&mut tools.shellcheck),
"markdownlint" => Some(&mut tools.markdownlint),
"hadolint" => Some(&mut tools.hadolint),
"yamllint" => Some(&mut tools.yamllint),
"rustfmt" => Some(&mut tools.rustfmt),
"shfmt" => Some(&mut tools.shfmt),
"prettier" => Some(&mut tools.prettier),
_ => None,
}
}
fn validation_tool_enabled(&self, name: &str) -> bool {
let tools = &self.config.validation.tools;
match name {
"actionlint" => self.config.github.actionlint_on_patch,
"shellcheck" => tools.shellcheck.unwrap_or(true),
"markdownlint" => tools.markdownlint.unwrap_or(true),
"hadolint" => tools.hadolint.unwrap_or(true),
"yamllint" => tools.yamllint.unwrap_or(true),
"rustfmt" => tools.rustfmt.unwrap_or(true),
"shfmt" => tools.shfmt.unwrap_or(true),
"prettier" => tools.prettier.unwrap_or(true),
_ => true,
}
}
pub(crate) fn apply_validation_patch_harness(&mut self, enabled: bool) {
if self.config.validation.patch_harness == enabled {
self.history_push(history_cell::new_background_event(format!(
"ℹ️ Validate New Code already {}",
if enabled { "enabled" } else { "disabled" }
)));
return;
}
self.config.validation.patch_harness = enabled;
if let Err(err) = self
.codex_op_tx
.send(Op::UpdateValidationPatchHarness { enabled })
{
tracing::warn!("failed to send validation patch harness update: {err}");
}
let persist_result = match find_codex_home() {
Ok(home) => set_validation_patch_harness(&home, enabled)
.map_err(|e| e.to_string()),
Err(err) => Err(err.to_string()),
};
match persist_result {
Ok(()) => self.history_push(history_cell::new_background_event(format!(
"✅ Validate New Code {}",
if enabled { "enabled" } else { "disabled" }
))),
Err(err) => self.history_push(history_cell::new_background_event(format!(
"⚠️ Validate New Code {} (persist failed: {err})",
if enabled { "enabled" } else { "disabled" }
))),
}
}
fn apply_validation_tool_toggle(&mut self, name: &str, enable: bool) {
if name == "actionlint" {
if self.config.github.actionlint_on_patch == enable {
self.history_push(history_cell::new_background_event(format!(
"ℹ️ {} already {}",
name,
if enable { "on" } else { "off" }
)));
return;
}
self.config.github.actionlint_on_patch = enable;
if let Err(err) = self
.codex_op_tx
.send(Op::UpdateValidationTool { name: name.to_string(), enable })
{
tracing::warn!("failed to send validation tool update: {err}");
}
let persist_result = match find_codex_home() {
Ok(home) => set_github_actionlint_on_patch(&home, enable)
.map_err(|e| e.to_string()),
Err(err) => Err(err.to_string()),
};
match persist_result {
Ok(()) => self.history_push(history_cell::new_background_event(format!(
"✅ {}: {}",
name,
if enable { "on" } else { "off" }
))),
Err(err) => self.history_push(history_cell::new_background_event(format!(
"⚠️ {}: {} (persist failed: {err})",
name,
if enable { "on" } else { "off" }
))),
}
return;
}
let Some(flag) = self.validation_tool_flag_mut(name) else {
self.history_push(history_cell::new_background_event(format!(
"⚠️ Unknown validation tool '{name}'"
)));
return;
};
if flag.unwrap_or(true) == enable {
self.history_push(history_cell::new_background_event(format!(
"ℹ️ {} already {}",
name,
if enable { "on" } else { "off" }
)));
return;
}
*flag = Some(enable);
if let Err(err) = self
.codex_op_tx
.send(Op::UpdateValidationTool { name: name.to_string(), enable })
{
tracing::warn!("failed to send validation tool update: {err}");
}
let persist_result = match find_codex_home() {
Ok(home) => set_validation_tool_enabled(&home, name, enable)
.map_err(|e| e.to_string()),
Err(err) => Err(err.to_string()),
};
match persist_result {
Ok(()) => self.history_push(history_cell::new_background_event(format!(
"✅ {}: {}",
name,
if enable { "on" } else { "off" }
))),
Err(err) => self.history_push(history_cell::new_background_event(format!(
"⚠️ {}: {} (persist failed: {err})",
name,
if enable { "on" } else { "off" }
))),
}
}
fn build_validation_status_message(&self) -> String {
let mut lines = Vec::new();
lines.push(format!(
"Validate New Code: {}",
if self.config.validation.patch_harness { "enabled" } else { "disabled" }
));
lines.push("".to_string());
lines.push("Tools:".to_string());
for status in validation_settings_view::detect_tools() {
let enabled = self.validation_tool_enabled(status.name);
let suffix = if status.installed { "" } else { " (missing)" };
lines.push(format!(
"• {} — {}{}",
status.name,
if enabled { "on" } else { "off" },
suffix
));
}
lines.join("\n")
}
pub(crate) fn toggle_validation_tool(&mut self, name: &str, enable: bool) {
self.apply_validation_tool_toggle(name, enable);
}
pub(crate) fn handle_validation_command(&mut self, command_text: String) {
let trimmed = command_text.trim();
if trimmed.is_empty() {
let tools: Vec<(ToolStatus, bool)> = validation_settings_view::detect_tools()
.into_iter()
.map(|status| {
let enabled = self.validation_tool_enabled(status.name);
(status, enabled)
})
.collect();
self.bottom_pane
.show_validation_settings(self.config.validation.patch_harness, tools);
return;
}
let mut parts = trimmed.split_whitespace();
match parts.next().unwrap_or("") {
"status" => {
let message = self.build_validation_status_message();
self.history_push(history_cell::new_background_event(message));
}
"on" => self.apply_validation_patch_harness(true),
"off" => self.apply_validation_patch_harness(false),
tool => {
let Some(state) = parts.next() else {
self.history_push(history_cell::new_background_event(
"Usage: /validation <tool> on|off".to_string(),
));
return;
};
match state {
"on" | "enable" => self.apply_validation_tool_toggle(tool, true),
"off" | "disable" => self.apply_validation_tool_toggle(tool, false),
_ => self.history_push(history_cell::new_background_event(format!(
"⚠️ Unknown validation command '{}'. Use on|off.",
state
))),
}
}
}
}
/// Handle `/mcp` command: manage MCP servers (status/on/off/add).
pub(crate) fn handle_mcp_command(&mut self, command_text: String) {
let trimmed = command_text.trim();
+2
View File
@@ -35,6 +35,7 @@ pub enum SlashCommand {
Branch,
Merge,
Github,
Validation,
Mcp,
Resume,
// Prompt-expanding commands
@@ -76,6 +77,7 @@ impl SlashCommand {
}
SlashCommand::Merge => "merge current worktree branch back to default",
SlashCommand::Github => "GitHub Actions watcher (status/on/off)",
SlashCommand::Validation => "control validation harness (status/on/off)",
SlashCommand::Mcp => "manage MCP servers (status/on/off/add)",
SlashCommand::Perf => "performance tracing (on/off/show/reset)",
SlashCommand::Logout => "log out of Code",
+30
View File
@@ -377,6 +377,36 @@ env = { "API_KEY" = "value" }
startup_timeout_ms = 20_000
```
## validation
Controls the quick validation harness that runs before applying patches. The
master toggle lives under `[validation]` with per-tool overrides in the nested
`[validation.tools]` table:
```toml
[validation]
patch_harness = true
[validation.tools]
shellcheck = true
markdownlint = true
hadolint = true
yamllint = true
rustfmt = true
shfmt = true
prettier = true
```
When enabled, Codex can also run `actionlint` against modified workflows. This
is configured under `[github]`:
```toml
[github]
actionlint_on_patch = true
# Optional: provide an explicit binary path
actionlint_path = "/usr/local/bin/actionlint"
```
## disable_response_storage
Currently, customers whose accounts are set to use Zero Data Retention (ZDR) must set `disable_response_storage` to `true` so that Codex uses an alternative to the Responses API that works with ZDR:
+2
View File
@@ -41,6 +41,8 @@ Notes
- `/update`: check the installed version, detect available upgrades, and run the
appropriate installer when possible.
- `/mcp`: manage MCP servers (status/on/off/add).
- `/validation [status|on|off|<tool> (on|off)]`: inspect or toggle validation
harness settings.
## Search & Mentions