feat(core): report os and tool info

This commit is contained in:
James Peter
2025-09-19 14:38:32 +10:00
parent dddf1b68a3
commit 5142926c1e
5 changed files with 386 additions and 81 deletions
+2 -4
View File
@@ -30,10 +30,8 @@ impl DebugLogger {
});
}
let log_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".codex")
.join("debug_logs");
let mut log_dir = crate::config::find_codex_home()?;
log_dir.push("debug_logs");
fs::create_dir_all(&log_dir)?;
+203 -3
View File
@@ -1,6 +1,9 @@
use os_info::Type as OsType;
use os_info::Version;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display as DeriveDisplay;
use which::which;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
@@ -27,9 +30,80 @@ pub(crate) struct EnvironmentContext {
pub sandbox_mode: Option<SandboxMode>,
pub network_access: Option<NetworkAccess>,
pub writable_roots: Option<Vec<PathBuf>>,
pub operating_system: Option<OperatingSystemInfo>,
pub common_tools: Option<Vec<String>>,
pub shell: Option<Shell>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) struct OperatingSystemInfo {
pub family: Option<String>,
pub version: Option<String>,
pub architecture: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct ToolCandidate {
pub label: &'static str,
pub detection_names: &'static [&'static str],
}
pub const TOOL_CANDIDATES: &[ToolCandidate] = &[
ToolCandidate { label: "git", detection_names: &["git"] },
ToolCandidate { label: "gh", detection_names: &["gh"] },
ToolCandidate { label: "rg", detection_names: &["rg"] },
ToolCandidate { label: "fd", detection_names: &["fd", "fdfind"] },
ToolCandidate { label: "fzf", detection_names: &["fzf"] },
ToolCandidate { label: "jq", detection_names: &["jq"] },
ToolCandidate { label: "yq", detection_names: &["yq"] },
ToolCandidate { label: "sed", detection_names: &["sed"] },
ToolCandidate { label: "awk", detection_names: &["awk"] },
ToolCandidate { label: "xargs", detection_names: &["xargs"] },
ToolCandidate { label: "parallel", detection_names: &["parallel"] },
ToolCandidate { label: "curl", detection_names: &["curl"] },
ToolCandidate { label: "wget", detection_names: &["wget"] },
ToolCandidate { label: "tar", detection_names: &["tar"] },
ToolCandidate { label: "unzip", detection_names: &["unzip"] },
ToolCandidate { label: "gzip", detection_names: &["gzip"] },
ToolCandidate { label: "zstd", detection_names: &["zstd"] },
ToolCandidate { label: "make", detection_names: &["make"] },
ToolCandidate { label: "just", detection_names: &["just"] },
ToolCandidate { label: "node", detection_names: &["node"] },
ToolCandidate { label: "npm", detection_names: &["npm"] },
ToolCandidate { label: "pnpm", detection_names: &["pnpm"] },
ToolCandidate { label: "python3", detection_names: &["python3"] },
ToolCandidate { label: "pipx", detection_names: &["pipx"] },
ToolCandidate { label: "go", detection_names: &["go"] },
ToolCandidate { label: "rustup", detection_names: &["rustup"] },
ToolCandidate { label: "cargo", detection_names: &["cargo"] },
ToolCandidate { label: "rustc", detection_names: &["rustc"] },
ToolCandidate { label: "shellcheck", detection_names: &["shellcheck"] },
ToolCandidate { label: "shfmt", detection_names: &["shfmt"] },
ToolCandidate { label: "docker", detection_names: &["docker"] },
ToolCandidate { label: "docker compose", detection_names: &["docker", "docker-compose"] },
ToolCandidate { label: "sqlite3", detection_names: &["sqlite3"] },
ToolCandidate { label: "duckdb", detection_names: &["duckdb"] },
ToolCandidate { label: "rsync", detection_names: &["rsync"] },
ToolCandidate { label: "openssl", detection_names: &["openssl"] },
ToolCandidate { label: "ssh", detection_names: &["ssh"] },
ToolCandidate { label: "dig", detection_names: &["dig"] },
ToolCandidate { label: "nc", detection_names: &["nc", "netcat"] },
ToolCandidate { label: "lsof", detection_names: &["lsof"] },
ToolCandidate { label: "ripgrep-all", detection_names: &["ripgrep-all", "rga"] },
ToolCandidate { label: "entr", detection_names: &["entr"] },
ToolCandidate { label: "watchexec", detection_names: &["watchexec"] },
ToolCandidate { label: "hyperfine", detection_names: &["hyperfine"] },
ToolCandidate { label: "pv", detection_names: &["pv"] },
ToolCandidate { label: "bat", detection_names: &["bat"] },
ToolCandidate { label: "delta", detection_names: &["delta"] },
ToolCandidate { label: "tree", detection_names: &["tree"] },
ToolCandidate { label: "python", detection_names: &["python"] },
ToolCandidate { label: "deno", detection_names: &["deno"] },
ToolCandidate { label: "bun", detection_names: &["bun"] },
ToolCandidate { label: "js", detection_names: &["js"] },
];
impl EnvironmentContext {
pub fn new(
cwd: Option<PathBuf>,
@@ -68,6 +142,8 @@ impl EnvironmentContext {
}
_ => None,
},
operating_system: detect_operating_system_info(),
common_tools: detect_common_tools(),
shell,
}
}
@@ -83,6 +159,8 @@ impl EnvironmentContext {
sandbox_mode,
network_access,
writable_roots,
operating_system,
common_tools,
// should compare all fields except shell
shell: _,
} = other;
@@ -92,6 +170,8 @@ impl EnvironmentContext {
&& self.sandbox_mode == *sandbox_mode
&& self.network_access == *network_access
&& self.writable_roots == *writable_roots
&& self.operating_system == *operating_system
&& self.common_tools == *common_tools
}
}
@@ -110,6 +190,12 @@ impl EnvironmentContext {
/// <sandbox_mode>...</sandbox_mode>
/// <writable_roots>...</writable_roots>
/// <network_access>...</network_access>
/// <operating_system>
/// <family>...</family>
/// <version>...</version>
/// <architecture>...</architecture>
/// </operating_system>
/// <common_tools>...</common_tools>
/// <shell>...</shell>
/// </environment_context>
/// ```
@@ -141,6 +227,28 @@ impl EnvironmentContext {
}
lines.push(" </writable_roots>".to_string());
}
if let Some(operating_system) = self.operating_system {
lines.push(" <operating_system>".to_string());
if let Some(family) = operating_system.family {
lines.push(format!(" <family>{family}</family>"));
}
if let Some(version) = operating_system.version {
lines.push(format!(" <version>{version}</version>"));
}
if let Some(architecture) = operating_system.architecture {
lines.push(format!(" <architecture>{architecture}</architecture>"));
}
lines.push(" </operating_system>".to_string());
}
if let Some(common_tools) = self.common_tools {
if !common_tools.is_empty() {
lines.push(" <common_tools>".to_string());
for tool in common_tools {
lines.push(format!(" <tool>{tool}</tool>"));
}
lines.push(" </common_tools>".to_string());
}
}
if let Some(shell) = self.shell
&& let Some(shell_name) = shell.name()
{
@@ -163,6 +271,67 @@ impl From<EnvironmentContext> for ResponseItem {
}
}
fn detect_operating_system_info() -> Option<OperatingSystemInfo> {
let info = os_info::get();
let family = match info.os_type() {
OsType::Unknown => None,
other => Some(other.to_string()),
};
let version = match info.version() {
Version::Unknown => None,
other => {
let text = other.to_string();
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
};
let architecture = {
let arch = std::env::consts::ARCH;
if arch.is_empty() {
None
} else {
Some(arch.to_string())
}
};
if family.is_none() && version.is_none() && architecture.is_none() {
return None;
}
Some(OperatingSystemInfo {
family,
version,
architecture,
})
}
fn detect_common_tools() -> Option<Vec<String>> {
let mut available = Vec::new();
for candidate in TOOL_CANDIDATES {
let detection_names = if candidate.detection_names.is_empty() {
&[candidate.label][..]
} else {
candidate.detection_names
};
if detection_names
.iter()
.any(|name| which(name).is_ok())
{
available.push(candidate.label.to_string());
}
}
if available.is_empty() {
None
} else {
Some(available)
}
}
#[cfg(test)]
mod tests {
use crate::shell::BashShell;
@@ -182,12 +351,14 @@ mod tests {
#[test]
fn serialize_workspace_write_environment_context() {
let context = EnvironmentContext::new(
let mut context = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], false)),
None,
);
context.operating_system = None;
context.common_tools = None;
let expected = r#"<environment_context>
<cwd>/repo</cwd>
@@ -205,12 +376,14 @@ mod tests {
#[test]
fn serialize_read_only_environment_context() {
let context = EnvironmentContext::new(
let mut context = EnvironmentContext::new(
None,
Some(AskForApproval::Never),
Some(SandboxPolicy::ReadOnly),
None,
);
context.operating_system = None;
context.common_tools = None;
let expected = r#"<environment_context>
<approval_policy>never</approval_policy>
@@ -223,12 +396,14 @@ mod tests {
#[test]
fn serialize_full_access_environment_context() {
let context = EnvironmentContext::new(
let mut context = EnvironmentContext::new(
None,
Some(AskForApproval::OnFailure),
Some(SandboxPolicy::DangerFullAccess),
None,
);
context.operating_system = None;
context.common_tools = None;
let expected = r#"<environment_context>
<approval_policy>on-failure</approval_policy>
@@ -239,6 +414,31 @@ mod tests {
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn serialize_environment_context_includes_os_and_tools() {
let mut context = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
);
context.operating_system = Some(OperatingSystemInfo {
family: Some("macos".to_string()),
version: Some("14.0".to_string()),
architecture: Some("aarch64".to_string()),
});
context.common_tools = Some(vec!["rg".to_string(), "git".to_string()]);
let xml = context.serialize_to_xml();
assert!(xml.contains("<operating_system>"));
assert!(xml.contains("<family>macos</family>"));
assert!(xml.contains("<version>14.0</version>"));
assert!(xml.contains("<architecture>aarch64</architecture>"));
assert!(xml.contains("<common_tools>"));
assert!(xml.contains("<tool>rg</tool>"));
assert!(xml.contains("<tool>git</tool>"));
}
#[test]
fn equals_except_shell_compares_approval_policy() {
// Approval policy
+2
View File
@@ -113,3 +113,5 @@ pub use codex_protocol::models::LocalShellExecAction;
pub use codex_protocol::models::LocalShellStatus;
pub use codex_protocol::models::ReasoningItemContent;
pub use codex_protocol::models::ResponseItem;
pub use environment_context::ToolCandidate;
pub use environment_context::TOOL_CANDIDATES;
+152 -54
View File
@@ -1,5 +1,6 @@
#![allow(clippy::unwrap_used)]
use codex_core::environment_context::TOOL_CANDIDATES;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
@@ -17,12 +18,15 @@ use codex_core::shell::default_user_shell;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
use os_info::Type as OsType;
use os_info::Version;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use which::which;
fn text_user_input(text: String) -> serde_json::Value {
serde_json::json!({
@@ -32,19 +36,127 @@ fn text_user_input(text: String) -> serde_json::Value {
})
}
fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
format!(
r#"<environment_context>
<cwd>{}</cwd>
<approval_policy>on-request</approval_policy>
<sandbox_mode>read-only</sandbox_mode>
<network_access>restricted</network_access>
{}</environment_context>"#,
cwd,
match shell.name() {
Some(name) => format!(" <shell>{name}</shell>\n"),
None => String::new(),
fn render_env_context(
cwd: Option<String>,
approval_policy: Option<&str>,
sandbox_mode: Option<&str>,
network_access: Option<&str>,
writable_roots: Vec<String>,
shell_name: Option<String>,
) -> String {
let mut lines = vec!["<environment_context>".to_string()];
if let Some(cwd) = cwd {
lines.push(format!(" <cwd>{cwd}</cwd>"));
}
if let Some(approval_policy) = approval_policy {
lines.push(format!(" <approval_policy>{approval_policy}</approval_policy>"));
}
if let Some(sandbox_mode) = sandbox_mode {
lines.push(format!(" <sandbox_mode>{sandbox_mode}</sandbox_mode>"));
}
if let Some(network_access) = network_access {
lines.push(format!(" <network_access>{network_access}</network_access>"));
}
if !writable_roots.is_empty() {
lines.push(" <writable_roots>".to_string());
for root in writable_roots {
lines.push(format!(" <root>{root}</root>"));
}
lines.push(" </writable_roots>".to_string());
}
if let Some(os_block) = operating_system_block() {
lines.push(os_block);
}
if let Some(tools_block) = common_tools_block() {
lines.push(tools_block);
}
if let Some(shell_name) = shell_name {
lines.push(format!(" <shell>{shell_name}</shell>"));
}
lines.push("</environment_context>".to_string());
lines.join("\n")
}
fn operating_system_block() -> Option<String> {
let info = os_info::get();
let family = match info.os_type() {
OsType::Unknown => None,
other => Some(other.to_string()),
};
let version = match info.version() {
Version::Unknown => None,
other => {
let text = other.to_string();
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
};
let architecture = {
let arch = std::env::consts::ARCH;
if arch.is_empty() {
None
} else {
Some(arch.to_string())
}
};
if family.is_none() && version.is_none() && architecture.is_none() {
return None;
}
let mut lines = vec![" <operating_system>".to_string()];
if let Some(family) = family {
lines.push(format!(" <family>{family}</family>"));
}
if let Some(version) = version {
lines.push(format!(" <version>{version}</version>"));
}
if let Some(architecture) = architecture {
lines.push(format!(" <architecture>{architecture}</architecture>"));
}
lines.push(" </operating_system>".to_string());
Some(lines.join("\n"))
}
fn common_tools_block() -> Option<String> {
let mut available = Vec::new();
for candidate in TOOL_CANDIDATES {
let detection_names = if candidate.detection_names.is_empty() {
&[candidate.label][..]
} else {
candidate.detection_names
};
if detection_names
.iter()
.any(|name| which(name).is_ok())
{
available.push(candidate.label);
}
}
if available.is_empty() {
return None;
}
let mut lines = vec![" <common_tools>".to_string()];
for tool in available {
lines.push(format!(" <tool>{tool}</tool>"));
}
lines.push(" </common_tools>".to_string());
Some(lines.join("\n"))
}
fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
let shell_name = shell.name();
render_env_context(
Some(cwd.to_string()),
Some("on-request"),
Some("read-only"),
Some("restricted"),
Vec::new(),
shell_name,
)
}
@@ -296,18 +408,13 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let shell = default_user_shell().await;
let expected_env_text = format!(
r#"<environment_context>
<cwd>{}</cwd>
<approval_policy>on-request</approval_policy>
<sandbox_mode>read-only</sandbox_mode>
<network_access>restricted</network_access>
{}</environment_context>"#,
cwd.path().to_string_lossy(),
match shell.name() {
Some(name) => format!(" <shell>{name}</shell>\n"),
None => String::new(),
}
let expected_env_text = render_env_context(
Some(cwd.path().to_string_lossy().to_string()),
Some("on-request"),
Some("read-only"),
Some("restricted"),
Vec::new(),
shell.name(),
);
let expected_ui_text =
"<user_instructions>\n\nbe consistent and helpful\n\n</user_instructions>";
@@ -456,16 +563,13 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
// After overriding the turn context, the environment context should be emitted again
// reflecting the new approval policy and sandbox settings. Omit cwd because it did
// not change.
let expected_env_text_2 = format!(
r#"<environment_context>
<approval_policy>never</approval_policy>
<sandbox_mode>workspace-write</sandbox_mode>
<network_access>enabled</network_access>
<writable_roots>
<root>{}</root>
</writable_roots>
</environment_context>"#,
writable.path().to_string_lossy()
let expected_env_text_2 = render_env_context(
None,
Some("never"),
Some("workspace-write"),
Some("enabled"),
vec![writable.path().to_string_lossy().to_string()],
None,
);
let expected_env_msg_2 = serde_json::json!({
"type": "message",
@@ -578,18 +682,13 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
let expected_env_text_2 = format!(
r#"<environment_context>
<cwd>{}</cwd>
<approval_policy>never</approval_policy>
<sandbox_mode>workspace-write</sandbox_mode>
<network_access>enabled</network_access>
<writable_roots>
<root>{}</root>
</writable_roots>
</environment_context>"#,
new_cwd.path().to_string_lossy(),
writable.path().to_string_lossy(),
let expected_env_text_2 = render_env_context(
Some(new_cwd.path().to_string_lossy().to_string()),
Some("never"),
Some("workspace-write"),
Some("enabled"),
vec![writable.path().to_string_lossy().to_string()],
None,
);
let expected_env_msg_2 = serde_json::json!({
"type": "message",
@@ -818,14 +917,13 @@ async fn send_user_turn_with_changes_sends_environment_context() {
]);
assert_eq!(body1["input"], expected_input_1);
let expected_env_msg_2 = text_user_input(format!(
r#"<environment_context>
<cwd>{}</cwd>
<approval_policy>never</approval_policy>
<sandbox_mode>danger-full-access</sandbox_mode>
<network_access>enabled</network_access>
</environment_context>"#,
default_cwd.to_string_lossy()
let expected_env_msg_2 = text_user_input(render_env_context(
Some(default_cwd.to_string_lossy().to_string()),
Some("never"),
Some("danger-full-access"),
Some("enabled"),
Vec::new(),
shell.name(),
));
let expected_user_message_2 = text_user_input("hello 2".to_string());
let expected_input_2 = serde_json::Value::Array(vec![
+27 -20
View File
@@ -4474,14 +4474,33 @@ impl ChatWidget<'_> {
Ok(content) => (true, content),
Err(error) => (false, error),
};
let entry_idx = self
.tools_state
.running_custom_tools
.remove(&ToolCallId(call_id.clone()));
if tool_name == "apply_patch" && success {
if let Some(idx) = entry_idx {
if idx < self.history_cells.len() {
let is_running_tool = self.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
.is_some();
if is_running_tool {
self.history_remove_at(idx);
}
}
}
self.bottom_pane
.update_status_text("responding".to_string());
self.maybe_hide_spinner();
return;
}
if tool_name == "wait" && success {
let target = wait_target_from_params(params_string.as_ref(), &call_id);
let message = format!("Waited for {}", target);
if let Some(idx) = self
.tools_state
.running_custom_tools
.remove(&ToolCallId(call_id.clone()))
{
if let Some(idx) = entry_idx {
if idx < self.history_cells.len() {
self.history_replace_at(
idx,
@@ -4516,11 +4535,7 @@ impl ChatWidget<'_> {
kind: HistoryCellType::Error,
};
if let Some(idx) = self
.tools_state
.running_custom_tools
.remove(&ToolCallId(call_id.clone()))
{
if let Some(idx) = entry_idx {
if idx < self.history_cells.len() {
self.history_replace_at(idx, Box::new(wait_cancelled_cell));
} else {
@@ -4550,11 +4565,7 @@ impl ChatWidget<'_> {
success,
content,
);
if let Some(idx) = self
.tools_state
.running_custom_tools
.remove(&ToolCallId(call_id))
{
if let Some(idx) = entry_idx {
if idx < self.history_cells.len() {
self.history_replace_at(idx, Box::new(completed));
} else {
@@ -4577,11 +4588,7 @@ impl ChatWidget<'_> {
success,
content,
);
if let Some(idx) = self
.tools_state
.running_custom_tools
.remove(&ToolCallId(call_id))
{
if let Some(idx) = entry_idx {
if idx < self.history_cells.len() {
self.history_replace_at(idx, Box::new(completed));
} else {