mirror of
https://github.com/openai/codex.git
synced 2026-03-03 21:23:18 +00:00
Compare commits
1 Commits
dh--rules-
...
fix/notify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1a8040c7 |
6
.github/workflows/shell-tool-mcp.yml
vendored
6
.github/workflows/shell-tool-mcp.yml
vendored
@@ -146,8 +146,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
|
||||
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
|
||||
cd /tmp/bash
|
||||
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
|
||||
./configure --without-bash-malloc
|
||||
@@ -187,8 +188,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
|
||||
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
|
||||
cd /tmp/bash
|
||||
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
|
||||
./configure --without-bash-malloc
|
||||
|
||||
@@ -36,7 +36,7 @@ use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_codex_jsonrpc_conversation_flow() -> Result<()> {
|
||||
|
||||
@@ -625,7 +625,6 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: model.to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
@@ -207,12 +207,13 @@ tmp_path.replace(payload_path)
|
||||
let notify_script = notify_script
|
||||
.to_str()
|
||||
.expect("notify script path should be valid UTF-8");
|
||||
let notify_command = if cfg!(windows) { "python" } else { "python3" };
|
||||
create_config_toml_with_extra(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&format!(
|
||||
"notify = [\"python3\", {}]",
|
||||
"notify = [\"{notify_command}\", {}]",
|
||||
toml_basic_string(notify_script)
|
||||
),
|
||||
)?;
|
||||
@@ -261,7 +262,12 @@ tmp_path.replace(payload_path)
|
||||
)
|
||||
.await??;
|
||||
|
||||
fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?;
|
||||
let notify_timeout = if cfg!(windows) {
|
||||
Duration::from_secs(15)
|
||||
} else {
|
||||
Duration::from_secs(5)
|
||||
};
|
||||
fs_wait::wait_for_path_exists(¬ify_file, notify_timeout).await?;
|
||||
let payload_raw = tokio::fs::read_to_string(¬ify_file).await?;
|
||||
let payload: Value = serde_json::from_str(&payload_raw)?;
|
||||
assert_eq!(payload["client"], "xcode");
|
||||
|
||||
@@ -155,7 +155,6 @@ use crate::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
use crate::exec::StreamOutput;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_execpolicy::Policy;
|
||||
|
||||
mod rollout_reconstruction;
|
||||
#[cfg(test)]
|
||||
@@ -771,24 +770,7 @@ impl TurnContext {
|
||||
.unwrap_or(compact::SUMMARIZATION_PROMPT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
|
||||
self.to_turn_context_item_with_approved_prefix_rules(None)
|
||||
}
|
||||
|
||||
pub(crate) fn to_turn_context_item_with_exec_policy(
|
||||
&self,
|
||||
exec_policy: &Policy,
|
||||
) -> TurnContextItem {
|
||||
self.to_turn_context_item_with_approved_prefix_rules(format_allow_prefixes(
|
||||
exec_policy.get_allowed_prefixes(),
|
||||
))
|
||||
}
|
||||
|
||||
fn to_turn_context_item_with_approved_prefix_rules(
|
||||
&self,
|
||||
approved_prefix_rules: Option<String>,
|
||||
) -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
cwd: self.cwd.clone(),
|
||||
@@ -797,7 +779,6 @@ impl TurnContext {
|
||||
approval_policy: self.approval_policy.value(),
|
||||
sandbox_policy: self.sandbox_policy.get().clone(),
|
||||
network: self.turn_context_network_item(),
|
||||
approved_prefix_rules,
|
||||
model: self.model_info.slug.clone(),
|
||||
personality: self.personality,
|
||||
collaboration_mode: Some(self.collaboration_mode.clone()),
|
||||
@@ -1806,11 +1787,7 @@ impl Session {
|
||||
self.record_conversation_items(&turn_context, &items).await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_reference_context_item(Some(
|
||||
turn_context.to_turn_context_item_with_exec_policy(
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
),
|
||||
));
|
||||
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
|
||||
}
|
||||
self.set_previous_turn_settings(None).await;
|
||||
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
|
||||
@@ -1923,11 +1900,7 @@ impl Session {
|
||||
.await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_reference_context_item(Some(
|
||||
turn_context.to_turn_context_item_with_exec_policy(
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
),
|
||||
));
|
||||
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
|
||||
}
|
||||
|
||||
// Forked threads should remain file-backed immediately after startup.
|
||||
@@ -3114,13 +3087,9 @@ impl Session {
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
EnvironmentContext::from_turn_context(
|
||||
turn_context,
|
||||
shell.as_ref(),
|
||||
format_allow_prefixes(self.services.exec_policy.current().get_allowed_prefixes()),
|
||||
)
|
||||
.with_subagents(subagents)
|
||||
.serialize_to_xml(),
|
||||
EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
.with_subagents(subagents)
|
||||
.serialize_to_xml(),
|
||||
);
|
||||
|
||||
let mut items = Vec::with_capacity(2);
|
||||
@@ -3188,8 +3157,7 @@ impl Session {
|
||||
self.build_settings_update_items(reference_context_item.as_ref(), turn_context)
|
||||
.await
|
||||
};
|
||||
let turn_context_item = turn_context
|
||||
.to_turn_context_item_with_exec_policy(self.services.exec_policy.current().as_ref());
|
||||
let turn_context_item = turn_context.to_turn_context_item();
|
||||
if !context_items.is_empty() {
|
||||
self.record_conversation_items(turn_context, &context_items)
|
||||
.await;
|
||||
@@ -7495,7 +7463,6 @@ mod tests {
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
|
||||
@@ -46,7 +46,6 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -85,7 +84,6 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -747,7 +745,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -819,7 +816,6 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -848,7 +844,6 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -954,7 +949,6 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: current_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -1056,7 +1050,6 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -1200,7 +1193,6 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
|
||||
@@ -211,11 +211,7 @@ async fn run_compact_task_inner(
|
||||
new_history.extend(ghost_snapshots);
|
||||
let reference_context_item = match initial_context_injection {
|
||||
InitialContextInjection::DoNotInject => None,
|
||||
InitialContextInjection::BeforeLastUserMessage => {
|
||||
Some(turn_context.to_turn_context_item_with_exec_policy(
|
||||
sess.services.exec_policy.current().as_ref(),
|
||||
))
|
||||
}
|
||||
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
|
||||
};
|
||||
let compacted_item = CompactedItem {
|
||||
message: summary_text.clone(),
|
||||
|
||||
@@ -135,11 +135,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
}
|
||||
let reference_context_item = match initial_context_injection {
|
||||
InitialContextInjection::DoNotInject => None,
|
||||
InitialContextInjection::BeforeLastUserMessage => {
|
||||
Some(turn_context.to_turn_context_item_with_exec_policy(
|
||||
sess.services.exec_policy.current().as_ref(),
|
||||
))
|
||||
}
|
||||
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
|
||||
};
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
|
||||
@@ -6,6 +6,7 @@ mod macos;
|
||||
mod tests;
|
||||
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::deserialize_config_toml_with_base;
|
||||
use crate::config_loader::layer_io::LoadedConfigLayers;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
@@ -575,11 +576,6 @@ struct ProjectTrustContext {
|
||||
user_config_file: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProjectTrustConfigToml {
|
||||
projects: Option<std::collections::HashMap<String, crate::config::ProjectConfig>>,
|
||||
}
|
||||
|
||||
struct ProjectTrustDecision {
|
||||
trust_level: Option<TrustLevel>,
|
||||
trust_key: String,
|
||||
@@ -670,16 +666,10 @@ async fn project_trust_context(
|
||||
config_base_dir: &Path,
|
||||
user_config_file: &AbsolutePathBuf,
|
||||
) -> io::Result<ProjectTrustContext> {
|
||||
let project_trust_config: ProjectTrustConfigToml = {
|
||||
let _guard = AbsolutePathBufGuard::new(config_base_dir);
|
||||
merged_config
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
|
||||
};
|
||||
let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?;
|
||||
|
||||
let project_root = find_project_root(cwd, project_root_markers).await?;
|
||||
let projects = project_trust_config.projects.unwrap_or_default();
|
||||
let projects = config_toml.projects.unwrap_or_default();
|
||||
|
||||
let project_root_key = project_root.as_path().to_string_lossy().to_string();
|
||||
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());
|
||||
|
||||
@@ -1114,91 +1114,6 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_override_can_update_project_local_mcp_server_when_project_is_trusted()
|
||||
-> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
let dot_codex = project_root.join(".codex");
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&nested).await?;
|
||||
tokio::fs::create_dir_all(&dot_codex).await?;
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(
|
||||
dot_codex.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[mcp_servers.sentry]
|
||||
url = "https://mcp.sentry.dev/mcp"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.cli_overrides(vec![(
|
||||
"mcp_servers.sentry.enabled".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.fallback_cwd(Some(nested))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let server = config
|
||||
.mcp_servers
|
||||
.get()
|
||||
.get("sentry")
|
||||
.expect("trusted project MCP server should load");
|
||||
assert!(server.enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_override_for_disabled_project_local_mcp_server_returns_invalid_transport()
|
||||
-> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
let dot_codex = project_root.join(".codex");
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&nested).await?;
|
||||
tokio::fs::create_dir_all(&dot_codex).await?;
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(
|
||||
dot_codex.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[mcp_servers.sentry]
|
||||
url = "https://mcp.sentry.dev/mcp"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let err = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.cli_overrides(vec![(
|
||||
"mcp_servers.sentry.enabled".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.fallback_cwd(Some(nested))
|
||||
.build()
|
||||
.await
|
||||
.expect_err("untrusted project layer should not provide MCP transport");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("invalid transport")
|
||||
&& err.to_string().contains("mcp_servers.sentry"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -8,7 +8,6 @@ use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::format_allow_prefixes;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
|
||||
@@ -16,19 +15,16 @@ fn build_environment_update_item(
|
||||
previous: Option<&TurnContextItem>,
|
||||
next: &TurnContext,
|
||||
shell: &Shell,
|
||||
exec_policy: &Policy,
|
||||
) -> Option<ResponseItem> {
|
||||
let prev = previous?;
|
||||
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell);
|
||||
let approved_prefix_rules = format_allow_prefixes(exec_policy.get_allowed_prefixes());
|
||||
let next_context =
|
||||
EnvironmentContext::from_turn_context(next, shell, approved_prefix_rules.clone());
|
||||
let next_context = EnvironmentContext::from_turn_context(next, shell);
|
||||
if prev_context.equals_except_shell(&next_context) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ResponseItem::from(
|
||||
EnvironmentContext::diff_from_turn_context_item(prev, next, shell, approved_prefix_rules),
|
||||
EnvironmentContext::diff_from_turn_context_item(prev, next, shell),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -185,7 +181,7 @@ pub(crate) fn build_settings_update_items(
|
||||
exec_policy: &Policy,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let contextual_user_message = build_environment_update_item(previous, next, shell, exec_policy);
|
||||
let contextual_user_message = build_environment_update_item(previous, next, shell);
|
||||
let developer_update_sections = [
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
// any other context diffs on this turn.
|
||||
|
||||
@@ -16,7 +16,6 @@ pub(crate) struct EnvironmentContext {
|
||||
pub current_date: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub network: Option<NetworkContext>,
|
||||
pub approved_prefix_rules: Option<String>,
|
||||
pub subagents: Option<String>,
|
||||
}
|
||||
|
||||
@@ -33,7 +32,6 @@ impl EnvironmentContext {
|
||||
current_date: Option<String>,
|
||||
timezone: Option<String>,
|
||||
network: Option<NetworkContext>,
|
||||
approved_prefix_rules: Option<String>,
|
||||
subagents: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -42,7 +40,6 @@ impl EnvironmentContext {
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
approved_prefix_rules,
|
||||
subagents,
|
||||
}
|
||||
}
|
||||
@@ -56,7 +53,6 @@ impl EnvironmentContext {
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
approved_prefix_rules,
|
||||
subagents,
|
||||
shell: _,
|
||||
} = other;
|
||||
@@ -64,7 +60,6 @@ impl EnvironmentContext {
|
||||
&& self.current_date == *current_date
|
||||
&& self.timezone == *timezone
|
||||
&& self.network == *network
|
||||
&& self.approved_prefix_rules == *approved_prefix_rules
|
||||
&& self.subagents == *subagents
|
||||
}
|
||||
|
||||
@@ -72,11 +67,9 @@ impl EnvironmentContext {
|
||||
before: &TurnContextItem,
|
||||
after: &TurnContext,
|
||||
shell: &Shell,
|
||||
approved_prefix_rules: Option<String>,
|
||||
) -> Self {
|
||||
let before_network = Self::network_from_turn_context_item(before);
|
||||
let after_network = Self::network_from_turn_context(after);
|
||||
let before_approved_prefix_rules = before.approved_prefix_rules.clone();
|
||||
let cwd = if before.cwd != after.cwd {
|
||||
Some(after.cwd.clone())
|
||||
} else {
|
||||
@@ -89,34 +82,16 @@ impl EnvironmentContext {
|
||||
} else {
|
||||
before_network
|
||||
};
|
||||
let approved_prefix_rules = if before_approved_prefix_rules != approved_prefix_rules {
|
||||
approved_prefix_rules
|
||||
} else {
|
||||
before_approved_prefix_rules
|
||||
};
|
||||
EnvironmentContext::new(
|
||||
cwd,
|
||||
shell.clone(),
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
approved_prefix_rules,
|
||||
None,
|
||||
)
|
||||
EnvironmentContext::new(cwd, shell.clone(), current_date, timezone, network, None)
|
||||
}
|
||||
|
||||
pub fn from_turn_context(
|
||||
turn_context: &TurnContext,
|
||||
shell: &Shell,
|
||||
approved_prefix_rules: Option<String>,
|
||||
) -> Self {
|
||||
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
|
||||
Self::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
shell.clone(),
|
||||
turn_context.current_date.clone(),
|
||||
turn_context.timezone.clone(),
|
||||
Self::network_from_turn_context(turn_context),
|
||||
approved_prefix_rules,
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -128,7 +103,6 @@ impl EnvironmentContext {
|
||||
turn_context_item.current_date.clone(),
|
||||
turn_context_item.timezone.clone(),
|
||||
Self::network_from_turn_context_item(turn_context_item),
|
||||
turn_context_item.approved_prefix_rules.clone(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -209,13 +183,6 @@ impl EnvironmentContext {
|
||||
// lines.push(" <network enabled=\"false\" />".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(approved_prefix_rules) = self.approved_prefix_rules {
|
||||
lines.push(" <approved_prefix_rules>".to_string());
|
||||
for line in approved_prefix_rules.lines() {
|
||||
lines.push(format!(" {line}"));
|
||||
}
|
||||
lines.push(" </approved_prefix_rules>".to_string());
|
||||
}
|
||||
if let Some(subagents) = self.subagents {
|
||||
lines.push(" <subagents>".to_string());
|
||||
lines.extend(subagents.lines().map(|line| format!(" {line}")));
|
||||
@@ -257,7 +224,6 @@ mod tests {
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
@@ -286,7 +252,6 @@ mod tests {
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
Some(network),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
@@ -316,7 +281,6 @@ mod tests {
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
@@ -337,7 +301,6 @@ mod tests {
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
@@ -358,7 +321,6 @@ mod tests {
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
@@ -379,7 +341,6 @@ mod tests {
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
@@ -391,35 +352,6 @@ mod tests {
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_environment_context_with_approved_prefix_rules() {
|
||||
let context = EnvironmentContext::new(
|
||||
Some(test_path_buf("/repo")),
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
Some("- [\"mkdir\"]\n- [\"gh\", \"api\"]".to_string()),
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
<approved_prefix_rules>
|
||||
- ["mkdir"]
|
||||
- ["gh", "api"]
|
||||
</approved_prefix_rules>
|
||||
</environment_context>"#,
|
||||
test_path_buf("/repo").display()
|
||||
);
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_cwd() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
@@ -429,7 +361,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
@@ -438,7 +369,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
@@ -452,7 +382,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
@@ -461,7 +390,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
@@ -476,7 +404,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo2")),
|
||||
@@ -485,7 +412,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
@@ -504,7 +430,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
@@ -517,7 +442,6 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
@@ -531,7 +455,6 @@ mod tests {
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
Some("- agent-1: atlas\n- agent-2".to_string()),
|
||||
);
|
||||
|
||||
@@ -551,28 +474,4 @@ mod tests {
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_approved_prefix_rules() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("- [\"mkdir\"]".to_string()),
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("- [\"gh\", \"api\"]".to_string()),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,7 +1401,6 @@ mod tests {
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: "test-model".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
@@ -360,17 +360,19 @@ alias_count=$(alias -p | wc -l | tr -d ' ')
|
||||
echo "# aliases $alias_count"
|
||||
alias -p
|
||||
echo ''
|
||||
export_lines=$(
|
||||
while IFS= read -r name; do
|
||||
if [[ "$name" =~ ^(EXCLUDED_EXPORTS)$ ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
||||
continue
|
||||
fi
|
||||
declare -xp "$name" 2>/dev/null || true
|
||||
done < <(compgen -e)
|
||||
)
|
||||
export_lines=$(export -p | awk '
|
||||
/^(export|declare -x|typeset -x) / {
|
||||
line=$0
|
||||
name=line
|
||||
sub(/^(export|declare -x|typeset -x) /, "", name)
|
||||
sub(/=.*/, "", name)
|
||||
if (name ~ /^(EXCLUDED_EXPORTS)$/) {
|
||||
next
|
||||
}
|
||||
if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
|
||||
print line
|
||||
}
|
||||
}')
|
||||
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
echo "# exports $export_count"
|
||||
if [ -n "$export_lines" ]; then
|
||||
@@ -669,46 +671,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bash_snapshot_preserves_multiline_exports() -> Result<()> {
|
||||
let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----";
|
||||
let output = Command::new("/bin/bash")
|
||||
.arg("-c")
|
||||
.arg(bash_snapshot_script())
|
||||
.env("BASH_ENV", "/dev/null")
|
||||
.env("MULTILINE_CERT", multiline_cert)
|
||||
.output()?;
|
||||
|
||||
assert!(output.status.success());
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"),
|
||||
"snapshot should include the multiline export name"
|
||||
);
|
||||
|
||||
let dir = tempdir()?;
|
||||
let snapshot_path = dir.path().join("snapshot.sh");
|
||||
std::fs::write(&snapshot_path, stdout.as_bytes())?;
|
||||
|
||||
let validate = Command::new("/bin/bash")
|
||||
.arg("-c")
|
||||
.arg("set -e; . \"$1\"")
|
||||
.arg("bash")
|
||||
.arg(&snapshot_path)
|
||||
.env("BASH_ENV", "/dev/null")
|
||||
.output()?;
|
||||
|
||||
assert!(
|
||||
validate.status.success(),
|
||||
"snapshot validation failed: {}",
|
||||
String::from_utf8_lossy(&validate.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> {
|
||||
|
||||
@@ -33,7 +33,6 @@ fn resume_history(
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
@@ -352,7 +352,7 @@ impl DeveloperInstructions {
|
||||
|
||||
pub fn from(
|
||||
approval_policy: AskForApproval,
|
||||
_exec_policy: &Policy,
|
||||
exec_policy: &Policy,
|
||||
request_permission_enabled: bool,
|
||||
) -> DeveloperInstructions {
|
||||
let on_request_instructions = || {
|
||||
@@ -361,7 +361,15 @@ impl DeveloperInstructions {
|
||||
} else {
|
||||
APPROVAL_POLICY_ON_REQUEST_RULE
|
||||
};
|
||||
on_request_rule.to_string()
|
||||
let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes());
|
||||
match command_prefixes {
|
||||
Some(prefixes) => {
|
||||
format!(
|
||||
"{on_request_rule}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
|
||||
)
|
||||
}
|
||||
None => on_request_rule.to_string(),
|
||||
}
|
||||
};
|
||||
let text = match approval_policy {
|
||||
AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(),
|
||||
@@ -525,10 +533,7 @@ impl DeveloperInstructions {
|
||||
SandboxMode::WorkspaceWrite => SANDBOX_MODE_WORKSPACE_WRITE.trim_end(),
|
||||
SandboxMode::ReadOnly => SANDBOX_MODE_READ_ONLY.trim_end(),
|
||||
};
|
||||
let text = format!(
|
||||
"{} Approved command prefix rules (if any) are provided in `<environment_context>` under `<approved_prefix_rules>`.",
|
||||
template.replace("{network_access}", &network_access.to_string())
|
||||
);
|
||||
let text = template.replace("{network_access}", &network_access.to_string());
|
||||
|
||||
DeveloperInstructions::new(text)
|
||||
}
|
||||
@@ -570,10 +575,6 @@ pub fn format_allow_prefixes(prefixes: Vec<Vec<String>>) -> Option<String> {
|
||||
output = output[..byte_idx].to_string();
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if truncated {
|
||||
Some(format!("{output}{TRUNCATED_MARKER}"))
|
||||
} else {
|
||||
@@ -1330,7 +1331,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
workspace_write,
|
||||
DeveloperInstructions::new(
|
||||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted. Approved command prefix rules (if any) are provided in `<environment_context>` under `<approved_prefix_rules>`."
|
||||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted."
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1338,7 +1339,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
read_only,
|
||||
DeveloperInstructions::new(
|
||||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted. Approved command prefix rules (if any) are provided in `<environment_context>` under `<approved_prefix_rules>`."
|
||||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted."
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1407,8 +1408,8 @@ mod tests {
|
||||
|
||||
let text = instructions.into_text();
|
||||
assert!(text.contains("prefix_rule"));
|
||||
assert!(text.contains("<approved_prefix_rules>"));
|
||||
assert!(!text.contains("Approved command prefixes"));
|
||||
assert!(text.contains("Approved command prefixes"));
|
||||
assert!(text.contains(r#"["git", "pull"]"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,6 +5,7 @@ Commands are run outside the sandbox if they are approved by the user, or match
|
||||
- Pipes: |
|
||||
- Logical operators: &&, ||
|
||||
- Command separators: ;
|
||||
- Subshell boundaries: (...), $(...)
|
||||
|
||||
Each resulting segment is evaluated independently for sandbox restrictions and approval requirements.
|
||||
|
||||
@@ -18,8 +19,6 @@ This is treated as two command segments:
|
||||
|
||||
["tee", "output.txt"]
|
||||
|
||||
Commands that use more advanced shell features like redirection (>, >>, <), substitutions ($(...), ...), environment variables (FOO=bar), or wildcard patterns (*, ?) will not be evaluated against rules, to limit the scope of what an approved rule allows.
|
||||
|
||||
## How to request escalation
|
||||
|
||||
IMPORTANT: To request approval to execute a command that will require escalated privileges:
|
||||
|
||||
@@ -2146,8 +2146,6 @@ pub struct TurnContextItem {
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub network: Option<TurnContextNetworkItem>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approved_prefix_rules: Option<String>,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<Personality>,
|
||||
@@ -3382,7 +3380,6 @@ mod tests {
|
||||
allowed_domains: vec!["api.example.com".to_string()],
|
||||
denied_domains: vec!["blocked.example.com".to_string()],
|
||||
}),
|
||||
approved_prefix_rules: None,
|
||||
model: "gpt-5".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
@@ -20,7 +20,7 @@ decision to the shell-escalation protocol over a shared file descriptor (specifi
|
||||
We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually:
|
||||
|
||||
```bash
|
||||
git clone https://git.savannah.gnu.org/git/bash
|
||||
git clone https://github.com/bminor/bash
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply /path/to/patches/bash-exec-wrapper.patch
|
||||
./configure --without-bash-malloc
|
||||
|
||||
@@ -258,7 +258,6 @@ mod tests {
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: "gpt-5".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
@@ -296,7 +295,6 @@ mod tests {
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model: "gpt-5".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
@@ -1201,15 +1201,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
let has_non_primary_agent_thread = self
|
||||
.agent_picker_threads
|
||||
.keys()
|
||||
.any(|thread_id| Some(*thread_id) != self.primary_thread_id);
|
||||
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
|
||||
self.chat_widget.open_multi_agent_enable_prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.agent_picker_threads.is_empty() {
|
||||
self.chat_widget
|
||||
.add_info_message("No agents available yet.".to_string(), None);
|
||||
@@ -3610,7 +3601,6 @@ mod tests {
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_session_info;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
@@ -3976,51 +3966,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
app.open_agent_picker().await;
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
app_event_rx.try_recv(),
|
||||
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
|
||||
);
|
||||
let cell = match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||||
};
|
||||
let rendered = cell
|
||||
.display_lines(120)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
|
||||
{
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.thread_event_channels
|
||||
.insert(thread_id, ThreadEventChannel::new(1));
|
||||
|
||||
app.open_agent_picker().await;
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
app_event_rx.try_recv(),
|
||||
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
|
||||
let mut app = make_test_app().await;
|
||||
|
||||
@@ -4108,10 +4108,10 @@ impl ChatComposer {
|
||||
!footer_props.is_task_running && self.collaboration_mode_indicator.is_some();
|
||||
let show_shortcuts_hint = match footer_props.mode {
|
||||
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
};
|
||||
let show_queue_hint = match footer_props.mode {
|
||||
FooterMode::ComposerHasDraft => footer_props.is_task_running,
|
||||
@@ -4141,13 +4141,10 @@ impl ChatComposer {
|
||||
.as_ref()
|
||||
.map(|line| line.clone().dim());
|
||||
let status_line_candidate = footer_props.status_line_enabled
|
||||
&& match footer_props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => !footer_props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
&& matches!(
|
||||
footer_props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
);
|
||||
let mut truncated_status_line = if status_line_candidate {
|
||||
status_line.as_ref().map(|line| {
|
||||
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
|
||||
@@ -4213,7 +4210,7 @@ impl ChatComposer {
|
||||
can_show_left_with_context(hint_rect, left_width, right_width);
|
||||
let has_override =
|
||||
self.footer_flash_visible() || self.footer_hint_override.is_some();
|
||||
let single_line_layout = if has_override || status_line_active {
|
||||
let single_line_layout = if has_override {
|
||||
None
|
||||
} else {
|
||||
match footer_props.mode {
|
||||
|
||||
@@ -172,10 +172,10 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
||||
pub(crate) fn footer_height(props: &FooterProps) -> u16 {
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
|
||||
false
|
||||
}
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running,
|
||||
@@ -562,18 +562,13 @@ fn footer_from_props_lines(
|
||||
show_shortcuts_hint: bool,
|
||||
show_queue_hint: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
// If status line content is present, show it for passive composer states.
|
||||
// Active draft states still prefer the queue hint over the passive status
|
||||
// line so the footer stays actionable while a task is running.
|
||||
// If status line content is present, show it for base modes.
|
||||
if props.status_line_enabled
|
||||
&& let Some(status_line) = &props.status_line_value
|
||||
&& match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => !props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
}
|
||||
&& matches!(
|
||||
props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
)
|
||||
{
|
||||
return vec![status_line.clone().dim()];
|
||||
}
|
||||
@@ -606,8 +601,6 @@ fn footer_from_props_lines(
|
||||
let state = LeftSideState {
|
||||
hint: if show_queue_hint {
|
||||
SummaryHintKind::QueueMessage
|
||||
} else if show_shortcuts_hint {
|
||||
SummaryHintKind::Shortcuts
|
||||
} else {
|
||||
SummaryHintKind::None
|
||||
},
|
||||
@@ -1020,10 +1013,10 @@ mod tests {
|
||||
let show_cycle_hint = !props.is_task_running;
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running,
|
||||
@@ -1032,21 +1025,13 @@ mod tests {
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let status_line_active = props.status_line_enabled
|
||||
&& match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => !props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let left_mode_indicator = if status_line_active {
|
||||
let left_mode_indicator = if props.status_line_enabled {
|
||||
None
|
||||
} else {
|
||||
collaboration_mode_indicator
|
||||
};
|
||||
let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
|
||||
let mut truncated_status_line = if status_line_active
|
||||
let mut truncated_status_line = if props.status_line_enabled
|
||||
&& matches!(
|
||||
props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
@@ -1059,7 +1044,7 @@ mod tests {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut left_width = if status_line_active {
|
||||
let mut left_width = if props.status_line_enabled {
|
||||
truncated_status_line
|
||||
.as_ref()
|
||||
.map(|line| line.width() as u16)
|
||||
@@ -1073,7 +1058,7 @@ mod tests {
|
||||
show_queue_hint,
|
||||
)
|
||||
};
|
||||
let right_line = if status_line_active {
|
||||
let right_line = if props.status_line_enabled {
|
||||
let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint);
|
||||
let compact = mode_indicator_line(collaboration_mode_indicator, false);
|
||||
let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0);
|
||||
@@ -1092,7 +1077,7 @@ mod tests {
|
||||
.as_ref()
|
||||
.map(|line| line.width() as u16)
|
||||
.unwrap_or(0);
|
||||
if status_line_active
|
||||
if props.status_line_enabled
|
||||
&& let Some(max_left) = max_left_width_for_right(area, right_width)
|
||||
&& left_width > max_left
|
||||
&& let Some(line) = props
|
||||
@@ -1112,24 +1097,21 @@ mod tests {
|
||||
props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
) {
|
||||
if status_line_active {
|
||||
if let Some(line) = truncated_status_line.clone() {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
if can_show_left_and_context && let Some(line) = &right_line {
|
||||
render_context_right(area, f.buffer_mut(), line);
|
||||
}
|
||||
} else {
|
||||
let (summary_left, show_context) = single_line_footer_layout(
|
||||
area,
|
||||
right_width,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
let (summary_left, show_context) = single_line_footer_layout(
|
||||
area,
|
||||
right_width,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
if props.status_line_enabled {
|
||||
if let Some(line) = truncated_status_line.clone() {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
area,
|
||||
f.buffer_mut(),
|
||||
@@ -1140,14 +1122,14 @@ mod tests {
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
SummaryLeft::Custom(line) => {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
SummaryLeft::None => {}
|
||||
}
|
||||
if show_context && let Some(line) = &right_line {
|
||||
render_context_right(area, f.buffer_mut(), line);
|
||||
SummaryLeft::Custom(line) => {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
SummaryLeft::None => {}
|
||||
}
|
||||
if show_context && let Some(line) = &right_line {
|
||||
render_context_right(area, f.buffer_mut(), line);
|
||||
}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
@@ -1434,38 +1416,6 @@ mod tests {
|
||||
|
||||
snapshot_footer("footer_status_line_overrides_shortcuts", props);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ComposerHasDraft,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
};
|
||||
|
||||
snapshot_footer("footer_status_line_yields_to_queue_hint", props);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ComposerHasDraft,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
};
|
||||
|
||||
snapshot_footer("footer_status_line_overrides_draft_idle", props);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Status line content "
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" tab to queue message 100% context left "
|
||||
@@ -164,10 +164,6 @@ const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
|
||||
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
|
||||
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
|
||||
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
|
||||
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable multi-agent?";
|
||||
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
|
||||
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
|
||||
const MULTI_AGENT_ENABLE_NOTICE: &str = "Multi-agent will be enabled in the next session.";
|
||||
const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
|
||||
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
|
||||
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
|
||||
@@ -1572,41 +1568,6 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: MULTI_AGENT_ENABLE_YES.to_string(),
|
||||
description: Some(
|
||||
"Save the setting now. You will need a new session to use it.".to_string(),
|
||||
),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateFeatureFlags {
|
||||
updates: vec![(Feature::Collab, true)],
|
||||
});
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_warning_event(MULTI_AGENT_ENABLE_NOTICE.to_string()),
|
||||
)));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: MULTI_AGENT_ENABLE_NO.to_string(),
|
||||
description: Some("Keep multi-agent disabled.".to_string()),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some(MULTI_AGENT_ENABLE_TITLE.to_string()),
|
||||
subtitle: Some("Multi-agent is currently disabled in your config.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
match info {
|
||||
Some(info) => self.apply_token_info(info),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 6001
|
||||
expression: popup
|
||||
---
|
||||
Enable multi-agent?
|
||||
Multi-agent is currently disabled in your config.
|
||||
|
||||
› 1. Yes, enable Save the setting now. You will need a new session to use it.
|
||||
2. Not now Keep multi-agent disabled.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -5991,35 +5991,6 @@ async fn experimental_popup_shows_js_repl_node_requirement() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_enable_prompt_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.open_multi_agent_enable_prompt();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert_snapshot!("multi_agent_enable_prompt", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_enable_prompt_updates_feature_and_emits_notice() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.open_multi_agent_enable_prompt();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
|
||||
);
|
||||
let cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||||
};
|
||||
let rendered = lines_to_single_string(&cell.display_lines(120));
|
||||
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
|
||||
|
||||
@@ -1277,7 +1277,6 @@ mod tests {
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
approved_prefix_rules: None,
|
||||
model,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
Reference in New Issue
Block a user