mirror of
https://github.com/openai/codex.git
synced 2026-11-03 00:21:31 +00:00
feat: Constrain values for approval_policy (#7778)
Constrain `approval_policy` through new `admin_policy` config. This PR will: 1. Add a `admin_policy` section to config, with a single field (for now) `allowed_approval_policies`. This list constrains the set of user-settable `approval_policy`s. 2. Introduce a new `Constrained<T>` type, which combines a current value and a validator function. The validator function ensures disallowed values are not set. 3. Change the type of `approval_policy` on `Config` and `SessionConfiguration` from `AskForApproval` to `Constrained<AskForApproval>`. The validator function is set by the values passed into `allowed_approval_policies`. 4. `GenericDisplayRow`: add a `disabled_reason: Option<String>`. When set, it disables selection of the value and indicates as such in the menu. This also makes it unselectable with arrow keys or numbers. This is used in the `/approvals` menu. Follow ups are: 1. Do the same thing to `sandbox_policy`. 2. Propagate the allowed set of values through app-server for the extension (though already this should prevent app-server from setting this values, it's just that we want to disable UI elements that are unsettable). Happy to split this PR up if you prefer, into the logical numbered areas above. Especially if there are parts we want to gavel on separately (e.g. admin_policy). Disabled full access: <img width="1680" height="380" alt="image" src="https://github.com/user-attachments/assets/1fb61c8c-1fcb-4dc4-8355-2293edb52ba0" /> Disabled `--yolo` on startup: <img width="749" height="76" alt="image" src="https://github.com/user-attachments/assets/0a1211a0-6eb1-40d6-a1d7-439c41e94ddb" /> CODEX-4087
This commit is contained in:
1 parent
de3fa03e1c
commit
9352c6b235
20 files changed
+572
-112
No files matched your search
@@ -9,7 +9,7 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta
|
|||||||
("workdir", config.cwd.display().to_string()),
|
("workdir", config.cwd.display().to_string()),
|
||||||
("model", model.to_string()),
|
("model", model.to_string()),
|
||||||
("provider", config.model_provider_id.clone()),
|
("provider", config.model_provider_id.clone()),
|
||||||
("approval", config.approval_policy.to_string()),
|
("approval", config.approval_policy.value().to_string()),
|
||||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||||
];
|
];
|
||||||
if config.model_provider.wire_api == WireApi::Responses {
|
if config.model_provider.wire_api == WireApi::Responses {
|
||||||
|
|||||||
+117
-46
@@ -77,6 +77,9 @@ use crate::client_common::Prompt;
|
|||||||
use crate::client_common::ResponseEvent;
|
use crate::client_common::ResponseEvent;
|
||||||
use crate::compact::collect_user_messages;
|
use crate::compact::collect_user_messages;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::config::Constrained;
|
||||||
|
use crate::config::ConstraintError;
|
||||||
|
use crate::config::ConstraintResult;
|
||||||
use crate::config::GhostSnapshotConfig;
|
use crate::config::GhostSnapshotConfig;
|
||||||
use crate::config::types::ShellEnvironmentPolicy;
|
use crate::config::types::ShellEnvironmentPolicy;
|
||||||
use crate::context_manager::ContextManager;
|
use crate::context_manager::ContextManager;
|
||||||
@@ -96,6 +99,7 @@ use crate::protocol::ApplyPatchApprovalRequestEvent;
|
|||||||
use crate::protocol::AskForApproval;
|
use crate::protocol::AskForApproval;
|
||||||
use crate::protocol::BackgroundEventEvent;
|
use crate::protocol::BackgroundEventEvent;
|
||||||
use crate::protocol::DeprecationNoticeEvent;
|
use crate::protocol::DeprecationNoticeEvent;
|
||||||
|
use crate::protocol::ErrorEvent;
|
||||||
use crate::protocol::Event;
|
use crate::protocol::Event;
|
||||||
use crate::protocol::EventMsg;
|
use crate::protocol::EventMsg;
|
||||||
use crate::protocol::ExecApprovalRequestEvent;
|
use crate::protocol::ExecApprovalRequestEvent;
|
||||||
@@ -260,7 +264,7 @@ impl Codex {
|
|||||||
user_instructions,
|
user_instructions,
|
||||||
base_instructions: config.base_instructions.clone(),
|
base_instructions: config.base_instructions.clone(),
|
||||||
compact_prompt: config.compact_prompt.clone(),
|
compact_prompt: config.compact_prompt.clone(),
|
||||||
approval_policy: config.approval_policy,
|
approval_policy: config.approval_policy.clone(),
|
||||||
sandbox_policy: config.sandbox_policy.clone(),
|
sandbox_policy: config.sandbox_policy.clone(),
|
||||||
cwd: config.cwd.clone(),
|
cwd: config.cwd.clone(),
|
||||||
original_config_do_not_use: Arc::clone(&config),
|
original_config_do_not_use: Arc::clone(&config),
|
||||||
@@ -411,7 +415,7 @@ pub(crate) struct SessionConfiguration {
|
|||||||
compact_prompt: Option<String>,
|
compact_prompt: Option<String>,
|
||||||
|
|
||||||
/// When to escalate for approval for execution
|
/// When to escalate for approval for execution
|
||||||
approval_policy: AskForApproval,
|
approval_policy: Constrained<AskForApproval>,
|
||||||
/// How to sandbox commands executed in the system
|
/// How to sandbox commands executed in the system
|
||||||
sandbox_policy: SandboxPolicy,
|
sandbox_policy: SandboxPolicy,
|
||||||
|
|
||||||
@@ -434,7 +438,7 @@ pub(crate) struct SessionConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SessionConfiguration {
|
impl SessionConfiguration {
|
||||||
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> Self {
|
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult<Self> {
|
||||||
let mut next_configuration = self.clone();
|
let mut next_configuration = self.clone();
|
||||||
if let Some(model) = updates.model.clone() {
|
if let Some(model) = updates.model.clone() {
|
||||||
next_configuration.model = model;
|
next_configuration.model = model;
|
||||||
@@ -446,7 +450,7 @@ impl SessionConfiguration {
|
|||||||
next_configuration.model_reasoning_summary = summary;
|
next_configuration.model_reasoning_summary = summary;
|
||||||
}
|
}
|
||||||
if let Some(approval_policy) = updates.approval_policy {
|
if let Some(approval_policy) = updates.approval_policy {
|
||||||
next_configuration.approval_policy = approval_policy;
|
next_configuration.approval_policy.set(approval_policy)?;
|
||||||
}
|
}
|
||||||
if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
|
if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
|
||||||
next_configuration.sandbox_policy = sandbox_policy;
|
next_configuration.sandbox_policy = sandbox_policy;
|
||||||
@@ -454,7 +458,7 @@ impl SessionConfiguration {
|
|||||||
if let Some(cwd) = updates.cwd.clone() {
|
if let Some(cwd) = updates.cwd.clone() {
|
||||||
next_configuration.cwd = cwd;
|
next_configuration.cwd = cwd;
|
||||||
}
|
}
|
||||||
next_configuration
|
Ok(next_configuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +527,7 @@ impl Session {
|
|||||||
base_instructions: session_configuration.base_instructions.clone(),
|
base_instructions: session_configuration.base_instructions.clone(),
|
||||||
compact_prompt: session_configuration.compact_prompt.clone(),
|
compact_prompt: session_configuration.compact_prompt.clone(),
|
||||||
user_instructions: session_configuration.user_instructions.clone(),
|
user_instructions: session_configuration.user_instructions.clone(),
|
||||||
approval_policy: session_configuration.approval_policy,
|
approval_policy: session_configuration.approval_policy.value(),
|
||||||
sandbox_policy: session_configuration.sandbox_policy.clone(),
|
sandbox_policy: session_configuration.sandbox_policy.clone(),
|
||||||
shell_environment_policy: per_turn_config.shell_environment_policy.clone(),
|
shell_environment_policy: per_turn_config.shell_environment_policy.clone(),
|
||||||
tools_config,
|
tools_config,
|
||||||
@@ -640,7 +644,7 @@ impl Session {
|
|||||||
config.model_reasoning_summary,
|
config.model_reasoning_summary,
|
||||||
config.model_context_window,
|
config.model_context_window,
|
||||||
config.model_auto_compact_token_limit,
|
config.model_auto_compact_token_limit,
|
||||||
config.approval_policy,
|
config.approval_policy.value(),
|
||||||
config.sandbox_policy.clone(),
|
config.sandbox_policy.clone(),
|
||||||
config.mcp_servers.keys().map(String::as_str).collect(),
|
config.mcp_servers.keys().map(String::as_str).collect(),
|
||||||
config.active_profile.clone(),
|
config.active_profile.clone(),
|
||||||
@@ -690,7 +694,7 @@ impl Session {
|
|||||||
session_id: conversation_id,
|
session_id: conversation_id,
|
||||||
model: session_configuration.model.clone(),
|
model: session_configuration.model.clone(),
|
||||||
model_provider_id: config.model_provider_id.clone(),
|
model_provider_id: config.model_provider_id.clone(),
|
||||||
approval_policy: session_configuration.approval_policy,
|
approval_policy: session_configuration.approval_policy.value(),
|
||||||
sandbox_policy: session_configuration.sandbox_policy.clone(),
|
sandbox_policy: session_configuration.sandbox_policy.clone(),
|
||||||
cwd: session_configuration.cwd.clone(),
|
cwd: session_configuration.cwd.clone(),
|
||||||
reasoning_effort: session_configuration.model_reasoning_effort,
|
reasoning_effort: session_configuration.model_reasoning_effort,
|
||||||
@@ -739,8 +743,7 @@ impl Session {
|
|||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let turn_context =
|
let turn_context = sess.new_default_turn().await;
|
||||||
sess.new_turn(SessionSettingsUpdate::default()).await;
|
|
||||||
sess.send_event(turn_context.as_ref(), EventMsg::SkillsUpdateAvailable)
|
sess.send_event(turn_context.as_ref(), EventMsg::SkillsUpdateAvailable)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -784,7 +787,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn record_initial_history(&self, conversation_history: InitialHistory) {
|
async fn record_initial_history(&self, conversation_history: InitialHistory) {
|
||||||
let turn_context = self.new_turn(SessionSettingsUpdate::default()).await;
|
let turn_context = self.new_default_turn().await;
|
||||||
match conversation_history {
|
match conversation_history {
|
||||||
InitialHistory::New => {
|
InitialHistory::New => {
|
||||||
// Build and record initial items (user instructions + environment context)
|
// Build and record initial items (user instructions + environment context)
|
||||||
@@ -843,30 +846,76 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) {
|
pub(crate) async fn update_settings(
|
||||||
|
&self,
|
||||||
|
updates: SessionSettingsUpdate,
|
||||||
|
) -> ConstraintResult<()> {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
|
|
||||||
state.session_configuration = state.session_configuration.apply(&updates);
|
match state.session_configuration.apply(&updates) {
|
||||||
}
|
Ok(updated) => {
|
||||||
|
state.session_configuration = updated;
|
||||||
pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc<TurnContext> {
|
Ok(())
|
||||||
let sub_id = self.next_internal_sub_id();
|
}
|
||||||
self.new_turn_with_sub_id(sub_id, updates).await
|
Err(err) => {
|
||||||
|
let wrapped = ConstraintError {
|
||||||
|
message: format!("Could not update config: {err}"),
|
||||||
|
};
|
||||||
|
warn!(%wrapped, "rejected session settings update");
|
||||||
|
Err(wrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn new_turn_with_sub_id(
|
pub(crate) async fn new_turn_with_sub_id(
|
||||||
&self,
|
&self,
|
||||||
sub_id: String,
|
sub_id: String,
|
||||||
updates: SessionSettingsUpdate,
|
updates: SessionSettingsUpdate,
|
||||||
) -> Arc<TurnContext> {
|
) -> ConstraintResult<Arc<TurnContext>> {
|
||||||
let (session_configuration, sandbox_policy_changed) = {
|
let (session_configuration, sandbox_policy_changed) = {
|
||||||
let mut state = self.state.lock().await;
|
let mut state = self.state.lock().await;
|
||||||
let session_configuration = state.session_configuration.clone().apply(&updates);
|
match state.session_configuration.clone().apply(&updates) {
|
||||||
let sandbox_policy_changed =
|
Ok(next) => {
|
||||||
state.session_configuration.sandbox_policy != session_configuration.sandbox_policy;
|
let sandbox_policy_changed =
|
||||||
state.session_configuration = session_configuration.clone();
|
state.session_configuration.sandbox_policy != next.sandbox_policy;
|
||||||
(session_configuration, sandbox_policy_changed)
|
state.session_configuration = next.clone();
|
||||||
|
(next, sandbox_policy_changed)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
drop(state);
|
||||||
|
let wrapped = ConstraintError {
|
||||||
|
message: format!("Could not update config: {err}"),
|
||||||
|
};
|
||||||
|
self.send_event_raw(Event {
|
||||||
|
id: sub_id.clone(),
|
||||||
|
msg: EventMsg::Error(ErrorEvent {
|
||||||
|
message: wrapped.to_string(),
|
||||||
|
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
return Err(wrapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Ok(self
|
||||||
|
.new_turn_from_configuration(
|
||||||
|
sub_id,
|
||||||
|
session_configuration,
|
||||||
|
updates.final_output_json_schema,
|
||||||
|
sandbox_policy_changed,
|
||||||
|
)
|
||||||
|
.await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new_turn_from_configuration(
|
||||||
|
&self,
|
||||||
|
sub_id: String,
|
||||||
|
session_configuration: SessionConfiguration,
|
||||||
|
final_output_json_schema: Option<Option<Value>>,
|
||||||
|
sandbox_policy_changed: bool,
|
||||||
|
) -> Arc<TurnContext> {
|
||||||
let per_turn_config = Self::build_per_turn_config(&session_configuration);
|
let per_turn_config = Self::build_per_turn_config(&session_configuration);
|
||||||
|
|
||||||
if sandbox_policy_changed {
|
if sandbox_policy_changed {
|
||||||
@@ -902,12 +951,26 @@ impl Session {
|
|||||||
self.conversation_id,
|
self.conversation_id,
|
||||||
sub_id,
|
sub_id,
|
||||||
);
|
);
|
||||||
if let Some(final_schema) = updates.final_output_json_schema {
|
if let Some(final_schema) = final_output_json_schema {
|
||||||
turn_context.final_output_json_schema = final_schema;
|
turn_context.final_output_json_schema = final_schema;
|
||||||
}
|
}
|
||||||
Arc::new(turn_context)
|
Arc::new(turn_context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn new_default_turn(&self) -> Arc<TurnContext> {
|
||||||
|
self.new_default_turn_with_sub_id(self.next_internal_sub_id())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
|
||||||
|
let session_configuration = {
|
||||||
|
let state = self.state.lock().await;
|
||||||
|
state.session_configuration.clone()
|
||||||
|
};
|
||||||
|
self.new_turn_from_configuration(sub_id, session_configuration, None, false)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
fn build_environment_update_item(
|
fn build_environment_update_item(
|
||||||
&self,
|
&self,
|
||||||
previous: Option<&Arc<TurnContext>>,
|
previous: Option<&Arc<TurnContext>>,
|
||||||
@@ -1551,8 +1614,7 @@ impl Session {
|
|||||||
|
|
||||||
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
|
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
|
||||||
// Seed with context in case there is an OverrideTurnContext first.
|
// Seed with context in case there is an OverrideTurnContext first.
|
||||||
let mut previous_context: Option<Arc<TurnContext>> =
|
let mut previous_context: Option<Arc<TurnContext>> = Some(sess.new_default_turn().await);
|
||||||
Some(sess.new_turn(SessionSettingsUpdate::default()).await);
|
|
||||||
|
|
||||||
// To break out of this loop, send Op::Shutdown.
|
// To break out of this loop, send Op::Shutdown.
|
||||||
while let Ok(sub) = rx_sub.recv().await {
|
while let Ok(sub) = rx_sub.recv().await {
|
||||||
@@ -1571,6 +1633,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
|||||||
} => {
|
} => {
|
||||||
handlers::override_turn_context(
|
handlers::override_turn_context(
|
||||||
&sess,
|
&sess,
|
||||||
|
sub.id.clone(),
|
||||||
SessionSettingsUpdate {
|
SessionSettingsUpdate {
|
||||||
cwd,
|
cwd,
|
||||||
approval_policy,
|
approval_policy,
|
||||||
@@ -1688,8 +1751,21 @@ mod handlers {
|
|||||||
sess.interrupt_task().await;
|
sess.interrupt_task().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn override_turn_context(sess: &Session, updates: SessionSettingsUpdate) {
|
pub async fn override_turn_context(
|
||||||
sess.update_settings(updates).await;
|
sess: &Session,
|
||||||
|
sub_id: String,
|
||||||
|
updates: SessionSettingsUpdate,
|
||||||
|
) {
|
||||||
|
if let Err(err) = sess.update_settings(updates).await {
|
||||||
|
sess.send_event_raw(Event {
|
||||||
|
id: sub_id,
|
||||||
|
msg: EventMsg::Error(ErrorEvent {
|
||||||
|
message: err.to_string(),
|
||||||
|
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_input_or_turn(
|
pub async fn user_input_or_turn(
|
||||||
@@ -1724,7 +1800,10 @@ mod handlers {
|
|||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let current_context = sess.new_turn_with_sub_id(sub_id, updates).await;
|
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
|
||||||
|
// new_turn_with_sub_id already emits the error event.
|
||||||
|
return;
|
||||||
|
};
|
||||||
current_context
|
current_context
|
||||||
.client
|
.client
|
||||||
.get_otel_manager()
|
.get_otel_manager()
|
||||||
@@ -1751,9 +1830,7 @@ mod handlers {
|
|||||||
command: String,
|
command: String,
|
||||||
previous_context: &mut Option<Arc<TurnContext>>,
|
previous_context: &mut Option<Arc<TurnContext>>,
|
||||||
) {
|
) {
|
||||||
let turn_context = sess
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||||
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
|
|
||||||
.await;
|
|
||||||
sess.spawn_task(
|
sess.spawn_task(
|
||||||
Arc::clone(&turn_context),
|
Arc::clone(&turn_context),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -1950,17 +2027,13 @@ mod handlers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn undo(sess: &Arc<Session>, sub_id: String) {
|
pub async fn undo(sess: &Arc<Session>, sub_id: String) {
|
||||||
let turn_context = sess
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||||
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
|
|
||||||
.await;
|
|
||||||
sess.spawn_task(turn_context, Vec::new(), UndoTask::new())
|
sess.spawn_task(turn_context, Vec::new(), UndoTask::new())
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
|
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
|
||||||
let turn_context = sess
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
||||||
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
sess.spawn_task(
|
sess.spawn_task(
|
||||||
Arc::clone(&turn_context),
|
Arc::clone(&turn_context),
|
||||||
@@ -2014,9 +2087,7 @@ mod handlers {
|
|||||||
sub_id: String,
|
sub_id: String,
|
||||||
review_request: ReviewRequest,
|
review_request: ReviewRequest,
|
||||||
) {
|
) {
|
||||||
let turn_context = sess
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
|
||||||
.new_turn_with_sub_id(sub_id.clone(), SessionSettingsUpdate::default())
|
|
||||||
.await;
|
|
||||||
match resolve_review_request(review_request, config.cwd.as_path()) {
|
match resolve_review_request(review_request, config.cwd.as_path()) {
|
||||||
Ok(resolved) => {
|
Ok(resolved) => {
|
||||||
spawn_review_thread(
|
spawn_review_thread(
|
||||||
@@ -2806,7 +2877,7 @@ mod tests {
|
|||||||
user_instructions: config.user_instructions.clone(),
|
user_instructions: config.user_instructions.clone(),
|
||||||
base_instructions: config.base_instructions.clone(),
|
base_instructions: config.base_instructions.clone(),
|
||||||
compact_prompt: config.compact_prompt.clone(),
|
compact_prompt: config.compact_prompt.clone(),
|
||||||
approval_policy: config.approval_policy,
|
approval_policy: config.approval_policy.clone(),
|
||||||
sandbox_policy: config.sandbox_policy.clone(),
|
sandbox_policy: config.sandbox_policy.clone(),
|
||||||
cwd: config.cwd.clone(),
|
cwd: config.cwd.clone(),
|
||||||
original_config_do_not_use: Arc::clone(&config),
|
original_config_do_not_use: Arc::clone(&config),
|
||||||
@@ -2878,7 +2949,7 @@ mod tests {
|
|||||||
user_instructions: config.user_instructions.clone(),
|
user_instructions: config.user_instructions.clone(),
|
||||||
base_instructions: config.base_instructions.clone(),
|
base_instructions: config.base_instructions.clone(),
|
||||||
compact_prompt: config.compact_prompt.clone(),
|
compact_prompt: config.compact_prompt.clone(),
|
||||||
approval_policy: config.approval_policy,
|
approval_policy: config.approval_policy.clone(),
|
||||||
sandbox_policy: config.sandbox_policy.clone(),
|
sandbox_policy: config.sandbox_policy.clone(),
|
||||||
cwd: config.cwd.clone(),
|
cwd: config.cwd.clone(),
|
||||||
original_config_do_not_use: Arc::clone(&config),
|
original_config_do_not_use: Arc::clone(&config),
|
||||||
@@ -3082,7 +3153,7 @@ mod tests {
|
|||||||
user_instructions: config.user_instructions.clone(),
|
user_instructions: config.user_instructions.clone(),
|
||||||
base_instructions: config.base_instructions.clone(),
|
base_instructions: config.base_instructions.clone(),
|
||||||
compact_prompt: config.compact_prompt.clone(),
|
compact_prompt: config.compact_prompt.clone(),
|
||||||
approval_policy: config.approval_policy,
|
approval_policy: config.approval_policy.clone(),
|
||||||
sandbox_policy: config.sandbox_policy.clone(),
|
sandbox_policy: config.sandbox_policy.clone(),
|
||||||
cwd: config.cwd.clone(),
|
cwd: config.cwd.clone(),
|
||||||
original_config_do_not_use: Arc::clone(&config),
|
original_config_do_not_use: Arc::clone(&config),
|
||||||
@@ -3173,7 +3244,7 @@ mod tests {
|
|||||||
user_instructions: config.user_instructions.clone(),
|
user_instructions: config.user_instructions.clone(),
|
||||||
base_instructions: config.base_instructions.clone(),
|
base_instructions: config.base_instructions.clone(),
|
||||||
compact_prompt: config.compact_prompt.clone(),
|
compact_prompt: config.compact_prompt.clone(),
|
||||||
approval_policy: config.approval_policy,
|
approval_policy: config.approval_policy.clone(),
|
||||||
sandbox_policy: config.sandbox_policy.clone(),
|
sandbox_policy: config.sandbox_policy.clone(),
|
||||||
cwd: config.cwd.clone(),
|
cwd: config.cwd.clone(),
|
||||||
original_config_do_not_use: Arc::clone(&config),
|
original_config_do_not_use: Arc::clone(&config),
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
use std::fmt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
#[error("{message}")]
|
||||||
|
pub struct ConstraintError {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConstraintError {
|
||||||
|
pub fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: format!(
|
||||||
|
"value `{}` is not in the allowed set {}",
|
||||||
|
candidate.into(),
|
||||||
|
allowed.into()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ConstraintResult<T> = Result<T, ConstraintError>;
|
||||||
|
|
||||||
|
impl From<ConstraintError> for std::io::Error {
|
||||||
|
fn from(err: ConstraintError) -> Self {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidInput, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConstraintValidator<T> = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Constrained<T> {
|
||||||
|
value: T,
|
||||||
|
validator: Arc<ConstraintValidator<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Send + Sync> Constrained<T> {
|
||||||
|
pub fn new(
|
||||||
|
initial_value: T,
|
||||||
|
validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static,
|
||||||
|
) -> ConstraintResult<Self> {
|
||||||
|
let validator: Arc<ConstraintValidator<T>> = Arc::new(validator);
|
||||||
|
validator(&initial_value)?;
|
||||||
|
Ok(Self {
|
||||||
|
value: initial_value,
|
||||||
|
validator,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allow_any(initial_value: T) -> Self {
|
||||||
|
Self {
|
||||||
|
value: initial_value,
|
||||||
|
validator: Arc::new(|_| Ok(())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allow_values(initial_value: T, allowed: Vec<T>) -> ConstraintResult<Self>
|
||||||
|
where
|
||||||
|
T: PartialEq + Send + Sync + fmt::Debug + 'static,
|
||||||
|
{
|
||||||
|
Self::new(initial_value, move |candidate| {
|
||||||
|
if allowed.contains(candidate) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ConstraintError::invalid_value(
|
||||||
|
format!("{candidate:?}"),
|
||||||
|
format!("{allowed:?}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self) -> &T {
|
||||||
|
&self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> T
|
||||||
|
where
|
||||||
|
T: Copy,
|
||||||
|
{
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_set(&self, candidate: &T) -> ConstraintResult<()> {
|
||||||
|
(self.validator)(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, value: T) -> ConstraintResult<()> {
|
||||||
|
(self.validator)(&value)?;
|
||||||
|
self.value = value;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> std::ops::Deref for Constrained<T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: fmt::Debug> fmt::Debug for Constrained<T> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Constrained")
|
||||||
|
.field("value", &self.value)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: PartialEq> PartialEq for Constrained<T> {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.value == other.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constrained_allow_any_accepts_any_value() {
|
||||||
|
let mut constrained = Constrained::allow_any(5);
|
||||||
|
constrained.set(-10).expect("allow any accepts all values");
|
||||||
|
assert_eq!(constrained.value(), -10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constrained_new_rejects_invalid_initial_value() {
|
||||||
|
let result = Constrained::new(0, |value| {
|
||||||
|
if *value > 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ConstraintError::invalid_value(
|
||||||
|
value.to_string(),
|
||||||
|
"positive values",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(ConstraintError::invalid_value("0", "positive values"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constrained_set_rejects_invalid_value_and_leaves_previous() {
|
||||||
|
let mut constrained = Constrained::new(1, |value| {
|
||||||
|
if *value > 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ConstraintError::invalid_value(
|
||||||
|
value.to_string(),
|
||||||
|
"positive values",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("initial value should be accepted");
|
||||||
|
|
||||||
|
let err = constrained
|
||||||
|
.set(-5)
|
||||||
|
.expect_err("negative values should be rejected");
|
||||||
|
assert_eq!(err, ConstraintError::invalid_value("-5", "positive values"));
|
||||||
|
assert_eq!(constrained.value(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constrained_can_set_allows_probe_without_setting() {
|
||||||
|
let constrained = Constrained::new(1, |value| {
|
||||||
|
if *value > 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ConstraintError::invalid_value(
|
||||||
|
value.to_string(),
|
||||||
|
"positive values",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("initial value should be accepted");
|
||||||
|
|
||||||
|
constrained
|
||||||
|
.can_set(&2)
|
||||||
|
.expect("can_set should accept positive value");
|
||||||
|
let err = constrained
|
||||||
|
.can_set(&-1)
|
||||||
|
.expect_err("can_set should reject negative value");
|
||||||
|
assert_eq!(err, ConstraintError::invalid_value("-1", "positive values"));
|
||||||
|
assert_eq!(constrained.value(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,10 +54,14 @@ use crate::config::profile::ConfigProfile;
|
|||||||
use toml::Value as TomlValue;
|
use toml::Value as TomlValue;
|
||||||
use toml_edit::DocumentMut;
|
use toml_edit::DocumentMut;
|
||||||
|
|
||||||
|
mod constraint;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub use constraint::Constrained;
|
||||||
|
pub use constraint::ConstraintError;
|
||||||
|
pub use constraint::ConstraintResult;
|
||||||
|
|
||||||
pub use service::ConfigService;
|
pub use service::ConfigService;
|
||||||
pub use service::ConfigServiceError;
|
pub use service::ConfigServiceError;
|
||||||
@@ -106,7 +110,7 @@ pub struct Config {
|
|||||||
pub model_provider: ModelProviderInfo,
|
pub model_provider: ModelProviderInfo,
|
||||||
|
|
||||||
/// Approval policy for executing commands.
|
/// Approval policy for executing commands.
|
||||||
pub approval_policy: AskForApproval,
|
pub approval_policy: Constrained<AskForApproval>,
|
||||||
|
|
||||||
pub sandbox_policy: SandboxPolicy,
|
pub sandbox_policy: SandboxPolicy,
|
||||||
|
|
||||||
@@ -1026,15 +1030,14 @@ impl Config {
|
|||||||
.or(cfg.approval_policy)
|
.or(cfg.approval_policy)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if active_project.is_trusted() {
|
if active_project.is_trusted() {
|
||||||
// If no explicit approval policy is set, but we trust cwd, default to OnRequest
|
|
||||||
AskForApproval::OnRequest
|
AskForApproval::OnRequest
|
||||||
} else if active_project.is_untrusted() {
|
} else if active_project.is_untrusted() {
|
||||||
// If project is explicitly marked untrusted, require approval for non-safe commands
|
|
||||||
AskForApproval::UnlessTrusted
|
AskForApproval::UnlessTrusted
|
||||||
} else {
|
} else {
|
||||||
AskForApproval::default()
|
AskForApproval::default()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let approval_policy = Constrained::allow_any(approval_policy);
|
||||||
let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override
|
let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override
|
||||||
.is_some()
|
.is_some()
|
||||||
|| config_profile.approval_policy.is_some()
|
|| config_profile.approval_policy.is_some()
|
||||||
@@ -2945,7 +2948,7 @@ model_verbosity = "high"
|
|||||||
model_auto_compact_token_limit: None,
|
model_auto_compact_token_limit: None,
|
||||||
model_provider_id: "openai".to_string(),
|
model_provider_id: "openai".to_string(),
|
||||||
model_provider: fixture.openai_provider.clone(),
|
model_provider: fixture.openai_provider.clone(),
|
||||||
approval_policy: AskForApproval::Never,
|
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||||
forced_auto_mode_downgraded_on_windows: false,
|
forced_auto_mode_downgraded_on_windows: false,
|
||||||
@@ -3020,7 +3023,7 @@ model_verbosity = "high"
|
|||||||
model_auto_compact_token_limit: None,
|
model_auto_compact_token_limit: None,
|
||||||
model_provider_id: "openai-chat-completions".to_string(),
|
model_provider_id: "openai-chat-completions".to_string(),
|
||||||
model_provider: fixture.openai_chat_completions_provider.clone(),
|
model_provider: fixture.openai_chat_completions_provider.clone(),
|
||||||
approval_policy: AskForApproval::UnlessTrusted,
|
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
|
||||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||||
forced_auto_mode_downgraded_on_windows: false,
|
forced_auto_mode_downgraded_on_windows: false,
|
||||||
@@ -3110,7 +3113,7 @@ model_verbosity = "high"
|
|||||||
model_auto_compact_token_limit: None,
|
model_auto_compact_token_limit: None,
|
||||||
model_provider_id: "openai".to_string(),
|
model_provider_id: "openai".to_string(),
|
||||||
model_provider: fixture.openai_provider.clone(),
|
model_provider: fixture.openai_provider.clone(),
|
||||||
approval_policy: AskForApproval::OnFailure,
|
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||||
forced_auto_mode_downgraded_on_windows: false,
|
forced_auto_mode_downgraded_on_windows: false,
|
||||||
@@ -3186,7 +3189,7 @@ model_verbosity = "high"
|
|||||||
model_auto_compact_token_limit: None,
|
model_auto_compact_token_limit: None,
|
||||||
model_provider_id: "openai".to_string(),
|
model_provider_id: "openai".to_string(),
|
||||||
model_provider: fixture.openai_provider.clone(),
|
model_provider: fixture.openai_provider.clone(),
|
||||||
approval_policy: AskForApproval::OnFailure,
|
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||||
forced_auto_mode_downgraded_on_windows: false,
|
forced_auto_mode_downgraded_on_windows: false,
|
||||||
@@ -3500,26 +3503,21 @@ trust_level = "untrusted"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> std::io::Result<()> {
|
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
let test_project_dir = TempDir::new()?;
|
let test_project_dir = TempDir::new()?;
|
||||||
let test_path = test_project_dir.path();
|
let test_path = test_project_dir.path();
|
||||||
|
|
||||||
let mut projects = std::collections::HashMap::new();
|
|
||||||
projects.insert(
|
|
||||||
test_path.to_string_lossy().to_string(),
|
|
||||||
ProjectConfig {
|
|
||||||
trust_level: Some(TrustLevel::Untrusted),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let cfg = ConfigToml {
|
|
||||||
projects: Some(projects),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = Config::load_from_base_config_with_overrides(
|
let config = Config::load_from_base_config_with_overrides(
|
||||||
cfg,
|
ConfigToml {
|
||||||
|
projects: Some(HashMap::from([(
|
||||||
|
test_path.to_string_lossy().to_string(),
|
||||||
|
ProjectConfig {
|
||||||
|
trust_level: Some(TrustLevel::Untrusted),
|
||||||
|
},
|
||||||
|
)])),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
ConfigOverrides {
|
ConfigOverrides {
|
||||||
cwd: Some(test_path.to_path_buf()),
|
cwd: Some(test_path.to_path_buf()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -3529,7 +3527,7 @@ trust_level = "untrusted"
|
|||||||
|
|
||||||
// Verify that untrusted projects get UnlessTrusted approval policy
|
// Verify that untrusted projects get UnlessTrusted approval policy
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.approval_policy,
|
config.approval_policy.value(),
|
||||||
AskForApproval::UnlessTrusted,
|
AskForApproval::UnlessTrusted,
|
||||||
"Expected UnlessTrusted approval policy for untrusted project"
|
"Expected UnlessTrusted approval policy for untrusted project"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use codex_core::config::Constrained;
|
||||||
use codex_core::features::Feature;
|
use codex_core::features::Feature;
|
||||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||||
use codex_core::protocol::AskForApproval;
|
use codex_core::protocol::AskForApproval;
|
||||||
@@ -126,7 +127,7 @@ impl ActionKind {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let command = format!("python3 -c \"{script}\"");
|
let command = format!("python3 -c \"{script}\"");
|
||||||
let event = shell_event(call_id, &command, 3_000, sandbox_permissions)?;
|
let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?;
|
||||||
Ok((event, Some(command)))
|
Ok((event, Some(command)))
|
||||||
}
|
}
|
||||||
ActionKind::RunCommand { command } => {
|
ActionKind::RunCommand { command } => {
|
||||||
@@ -1462,7 +1463,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
|
|||||||
let model = model_override.unwrap_or("gpt-5.1");
|
let model = model_override.unwrap_or("gpt-5.1");
|
||||||
|
|
||||||
let mut builder = test_codex().with_model(model).with_config(move |config| {
|
let mut builder = test_codex().with_model(model).with_config(move |config| {
|
||||||
config.approval_policy = approval_policy;
|
config.approval_policy = Constrained::allow_any(approval_policy);
|
||||||
config.sandbox_policy = sandbox_policy.clone();
|
config.sandbox_policy = sandbox_policy.clone();
|
||||||
for feature in features {
|
for feature in features {
|
||||||
config.features.enable(feature);
|
config.features.enable(feature);
|
||||||
@@ -1568,7 +1569,7 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts
|
|||||||
let sandbox_policy = SandboxPolicy::ReadOnly;
|
let sandbox_policy = SandboxPolicy::ReadOnly;
|
||||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||||
let mut builder = test_codex().with_config(move |config| {
|
let mut builder = test_codex().with_config(move |config| {
|
||||||
config.approval_policy = approval_policy;
|
config.approval_policy = Constrained::allow_any(approval_policy);
|
||||||
config.sandbox_policy = sandbox_policy_for_config;
|
config.sandbox_policy = sandbox_policy_for_config;
|
||||||
});
|
});
|
||||||
let test = builder.build(&server).await?;
|
let test = builder.build(&server).await?;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use codex_core::config::Constrained;
|
||||||
use codex_core::protocol::AskForApproval;
|
use codex_core::protocol::AskForApproval;
|
||||||
use codex_core::protocol::EventMsg;
|
use codex_core::protocol::EventMsg;
|
||||||
use codex_core::protocol::Op;
|
use codex_core::protocol::Op;
|
||||||
@@ -61,7 +62,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() {
|
|||||||
// Build a conversation configured to require approvals so the delegate
|
// Build a conversation configured to require approvals so the delegate
|
||||||
// routes ExecApprovalRequest via the parent.
|
// routes ExecApprovalRequest via the parent.
|
||||||
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
|
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::OnRequest;
|
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
config.sandbox_policy = SandboxPolicy::ReadOnly;
|
config.sandbox_policy = SandboxPolicy::ReadOnly;
|
||||||
});
|
});
|
||||||
let test = builder.build(&server).await.expect("build test codex");
|
let test = builder.build(&server).await.expect("build test codex");
|
||||||
@@ -137,7 +138,7 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() {
|
|||||||
mount_sse_sequence(&server, vec![sse1, sse2]).await;
|
mount_sse_sequence(&server, vec![sse1, sse2]).await;
|
||||||
|
|
||||||
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
|
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::OnRequest;
|
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
// Use a restricted sandbox so patch approval is required
|
// Use a restricted sandbox so patch approval is required
|
||||||
config.sandbox_policy = SandboxPolicy::ReadOnly;
|
config.sandbox_policy = SandboxPolicy::ReadOnly;
|
||||||
config.include_apply_patch_tool = true;
|
config.include_apply_patch_tool = true;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use codex_core::config::Constrained;
|
||||||
use codex_core::features::Feature;
|
use codex_core::features::Feature;
|
||||||
use codex_protocol::protocol::AskForApproval;
|
use codex_protocol::protocol::AskForApproval;
|
||||||
use codex_protocol::protocol::EventMsg;
|
use codex_protocol::protocol::EventMsg;
|
||||||
@@ -933,7 +934,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
|||||||
|
|
||||||
let TestCodex { codex, .. } = test_codex()
|
let TestCodex { codex, .. } = test_codex()
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::OnRequest;
|
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||||
})
|
})
|
||||||
.build(&server)
|
.build(&server)
|
||||||
@@ -982,7 +983,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
|
|||||||
|
|
||||||
let TestCodex { codex, .. } = test_codex()
|
let TestCodex { codex, .. } = test_codex()
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||||
})
|
})
|
||||||
.build(&server)
|
.build(&server)
|
||||||
.await
|
.await
|
||||||
@@ -1040,7 +1041,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
|
|||||||
|
|
||||||
let TestCodex { codex, .. } = test_codex()
|
let TestCodex { codex, .. } = test_codex()
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||||
})
|
})
|
||||||
.build(&server)
|
.build(&server)
|
||||||
.await
|
.await
|
||||||
@@ -1098,7 +1099,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
|
|||||||
|
|
||||||
let TestCodex { codex, .. } = test_codex()
|
let TestCodex { codex, .. } = test_codex()
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||||
})
|
})
|
||||||
.build(&server)
|
.build(&server)
|
||||||
.await
|
.await
|
||||||
@@ -1156,7 +1157,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
|
|||||||
.await;
|
.await;
|
||||||
let TestCodex { codex, .. } = test_codex()
|
let TestCodex { codex, .. } = test_codex()
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||||
})
|
})
|
||||||
.build(&server)
|
.build(&server)
|
||||||
.await
|
.await
|
||||||
@@ -1214,7 +1215,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
|
|||||||
|
|
||||||
let TestCodex { codex, .. } = test_codex()
|
let TestCodex { codex, .. } = test_codex()
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||||
})
|
})
|
||||||
.build(&server)
|
.build(&server)
|
||||||
.await
|
.await
|
||||||
@@ -1273,7 +1274,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
|
|||||||
|
|
||||||
let TestCodex { codex, .. } = test_codex()
|
let TestCodex { codex, .. } = test_codex()
|
||||||
.with_config(|config| {
|
.with_config(|config| {
|
||||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||||
})
|
})
|
||||||
.build(&server)
|
.build(&server)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let default_cwd = config.cwd.clone();
|
let default_cwd = config.cwd.clone();
|
||||||
let default_approval_policy = config.approval_policy;
|
let default_approval_policy = config.approval_policy.value();
|
||||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||||
let default_model = session_configured.model;
|
let default_model = session_configured.model;
|
||||||
let default_effort = config.model_reasoning_effort;
|
let default_effort = config.model_reasoning_effort;
|
||||||
@@ -685,7 +685,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let default_cwd = config.cwd.clone();
|
let default_cwd = config.cwd.clone();
|
||||||
let default_approval_policy = config.approval_policy;
|
let default_approval_policy = config.approval_policy.value();
|
||||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||||
let default_model = session_configured.model;
|
let default_model = session_configured.model;
|
||||||
let default_effort = config.model_reasoning_effort;
|
let default_effort = config.model_reasoning_effort;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ fn resume_history(
|
|||||||
) -> InitialHistory {
|
) -> InitialHistory {
|
||||||
let turn_ctx = TurnContextItem {
|
let turn_ctx = TurnContextItem {
|
||||||
cwd: config.cwd.clone(),
|
cwd: config.cwd.clone(),
|
||||||
approval_policy: config.approval_policy,
|
approval_policy: config.approval_policy.value(),
|
||||||
sandbox_policy: config.sandbox_policy.clone(),
|
sandbox_policy: config.sandbox_policy.clone(),
|
||||||
model: previous_model.to_string(),
|
model: previous_model.to_string(),
|
||||||
effort: config.model_reasoning_effort,
|
effort: config.model_reasoning_effort,
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
|||||||
}
|
}
|
||||||
|
|
||||||
let default_cwd = config.cwd.to_path_buf();
|
let default_cwd = config.cwd.to_path_buf();
|
||||||
let default_approval_policy = config.approval_policy;
|
let default_approval_policy = config.approval_policy.value();
|
||||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||||
let default_effort = config.model_reasoning_effort;
|
let default_effort = config.model_reasoning_effort;
|
||||||
let default_summary = config.model_reasoning_summary;
|
let default_summary = config.model_reasoning_summary;
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ impl CommandPopup {
|
|||||||
display_shortcut: None,
|
display_shortcut: None,
|
||||||
description: Some(description),
|
description: Some(description),
|
||||||
wrap_indent: None,
|
wrap_indent: None,
|
||||||
|
disabled_reason: None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ impl WidgetRef for &FileSearchPopup {
|
|||||||
display_shortcut: None,
|
display_shortcut: None,
|
||||||
description: None,
|
description: None,
|
||||||
wrap_indent: None,
|
wrap_indent: None,
|
||||||
|
disabled_reason: None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub(crate) struct SelectionItem {
|
|||||||
pub actions: Vec<SelectionAction>,
|
pub actions: Vec<SelectionAction>,
|
||||||
pub dismiss_on_select: bool,
|
pub dismiss_on_select: bool,
|
||||||
pub search_value: Option<String>,
|
pub search_value: Option<String>,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct SelectionViewParams {
|
pub(crate) struct SelectionViewParams {
|
||||||
@@ -217,6 +218,7 @@ impl ListSelectionView {
|
|||||||
match_indices: None,
|
match_indices: None,
|
||||||
description,
|
description,
|
||||||
wrap_indent,
|
wrap_indent,
|
||||||
|
disabled_reason: item.disabled_reason.clone(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -228,6 +230,7 @@ impl ListSelectionView {
|
|||||||
self.state.move_up_wrap(len);
|
self.state.move_up_wrap(len);
|
||||||
let visible = Self::max_visible_rows(len);
|
let visible = Self::max_visible_rows(len);
|
||||||
self.state.ensure_visible(len, visible);
|
self.state.ensure_visible(len, visible);
|
||||||
|
self.skip_disabled_up();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_down(&mut self) {
|
fn move_down(&mut self) {
|
||||||
@@ -235,12 +238,14 @@ impl ListSelectionView {
|
|||||||
self.state.move_down_wrap(len);
|
self.state.move_down_wrap(len);
|
||||||
let visible = Self::max_visible_rows(len);
|
let visible = Self::max_visible_rows(len);
|
||||||
self.state.ensure_visible(len, visible);
|
self.state.ensure_visible(len, visible);
|
||||||
|
self.skip_disabled_down();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accept(&mut self) {
|
fn accept(&mut self) {
|
||||||
if let Some(idx) = self.state.selected_idx
|
if let Some(idx) = self.state.selected_idx
|
||||||
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||||
&& let Some(item) = self.items.get(*actual_idx)
|
&& let Some(item) = self.items.get(*actual_idx)
|
||||||
|
&& item.disabled_reason.is_none()
|
||||||
{
|
{
|
||||||
self.last_selected_actual_idx = Some(*actual_idx);
|
self.last_selected_actual_idx = Some(*actual_idx);
|
||||||
for act in &item.actions {
|
for act in &item.actions {
|
||||||
@@ -267,6 +272,40 @@ impl ListSelectionView {
|
|||||||
fn rows_width(total_width: u16) -> u16 {
|
fn rows_width(total_width: u16) -> u16 {
|
||||||
total_width.saturating_sub(2)
|
total_width.saturating_sub(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn skip_disabled_down(&mut self) {
|
||||||
|
let len = self.visible_len();
|
||||||
|
for _ in 0..len {
|
||||||
|
if let Some(idx) = self.state.selected_idx
|
||||||
|
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||||
|
&& self
|
||||||
|
.items
|
||||||
|
.get(*actual_idx)
|
||||||
|
.is_some_and(|item| item.disabled_reason.is_some())
|
||||||
|
{
|
||||||
|
self.state.move_down_wrap(len);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_disabled_up(&mut self) {
|
||||||
|
let len = self.visible_len();
|
||||||
|
for _ in 0..len {
|
||||||
|
if let Some(idx) = self.state.selected_idx
|
||||||
|
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||||
|
&& self
|
||||||
|
.items
|
||||||
|
.get(*actual_idx)
|
||||||
|
.is_some_and(|item| item.disabled_reason.is_some())
|
||||||
|
{
|
||||||
|
self.state.move_up_wrap(len);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BottomPaneView for ListSelectionView {
|
impl BottomPaneView for ListSelectionView {
|
||||||
@@ -348,6 +387,10 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
.map(|d| d as usize)
|
.map(|d| d as usize)
|
||||||
.and_then(|d| d.checked_sub(1))
|
.and_then(|d| d.checked_sub(1))
|
||||||
&& idx < self.items.len()
|
&& idx < self.items.len()
|
||||||
|
&& self
|
||||||
|
.items
|
||||||
|
.get(idx)
|
||||||
|
.is_some_and(|item| item.disabled_reason.is_none())
|
||||||
{
|
{
|
||||||
self.state.selected_idx = Some(idx);
|
self.state.selected_idx = Some(idx);
|
||||||
self.accept();
|
self.accept();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub(crate) struct GenericDisplayRow {
|
|||||||
pub display_shortcut: Option<KeyBinding>,
|
pub display_shortcut: Option<KeyBinding>,
|
||||||
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
|
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
|
||||||
pub description: Option<String>, // optional grey text after the name
|
pub description: Option<String>, // optional grey text after the name
|
||||||
|
pub disabled_reason: Option<String>, // optional disabled message
|
||||||
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
|
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +38,13 @@ fn compute_desc_col(
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(i, _)| visible_range.contains(i))
|
.filter(|(i, _)| visible_range.contains(i))
|
||||||
.map(|(_, r)| Line::from(r.name.clone()).width())
|
.map(|(_, r)| {
|
||||||
|
let mut spans: Vec<Span> = vec![r.name.clone().into()];
|
||||||
|
if r.disabled_reason.is_some() {
|
||||||
|
spans.push(" (disabled)".dim());
|
||||||
|
}
|
||||||
|
Line::from(spans).width()
|
||||||
|
})
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let mut desc_col = max_name_width.saturating_add(2);
|
let mut desc_col = max_name_width.saturating_add(2);
|
||||||
@@ -51,7 +58,7 @@ fn compute_desc_col(
|
|||||||
fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize {
|
fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize {
|
||||||
let max_indent = max_width.saturating_sub(1) as usize;
|
let max_indent = max_width.saturating_sub(1) as usize;
|
||||||
let indent = row.wrap_indent.unwrap_or_else(|| {
|
let indent = row.wrap_indent.unwrap_or_else(|| {
|
||||||
if row.description.is_some() {
|
if row.description.is_some() || row.disabled_reason.is_some() {
|
||||||
desc_col
|
desc_col
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
@@ -64,10 +71,16 @@ fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usiz
|
|||||||
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
|
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
|
||||||
/// dims the description.
|
/// dims the description.
|
||||||
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||||
|
let combined_description = match (&row.description, &row.disabled_reason) {
|
||||||
|
(Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")),
|
||||||
|
(Some(desc), None) => Some(desc.clone()),
|
||||||
|
(None, Some(reason)) => Some(format!("disabled: {reason}")),
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
// Enforce single-line name: allow at most desc_col - 2 cells for name,
|
// Enforce single-line name: allow at most desc_col - 2 cells for name,
|
||||||
// reserving two spaces before the description column.
|
// reserving two spaces before the description column.
|
||||||
let name_limit = row
|
let name_limit = combined_description
|
||||||
.description
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|_| desc_col.saturating_sub(2))
|
.map(|_| desc_col.saturating_sub(2))
|
||||||
.unwrap_or(usize::MAX);
|
.unwrap_or(usize::MAX);
|
||||||
@@ -113,6 +126,10 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
|||||||
name_spans.push("…".into());
|
name_spans.push("…".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if row.disabled_reason.is_some() {
|
||||||
|
name_spans.push(" (disabled)".dim());
|
||||||
|
}
|
||||||
|
|
||||||
let this_name_width = Line::from(name_spans.clone()).width();
|
let this_name_width = Line::from(name_spans.clone()).width();
|
||||||
let mut full_spans: Vec<Span> = name_spans;
|
let mut full_spans: Vec<Span> = name_spans;
|
||||||
if let Some(display_shortcut) = row.display_shortcut {
|
if let Some(display_shortcut) = row.display_shortcut {
|
||||||
@@ -120,7 +137,7 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
|||||||
full_spans.push(display_shortcut.into());
|
full_spans.push(display_shortcut.into());
|
||||||
full_spans.push(")".into());
|
full_spans.push(")".into());
|
||||||
}
|
}
|
||||||
if let Some(desc) = row.description.as_ref() {
|
if let Some(desc) = combined_description.as_ref() {
|
||||||
let gap = desc_col.saturating_sub(this_name_width);
|
let gap = desc_col.saturating_sub(this_name_width);
|
||||||
if gap > 0 {
|
if gap > 0 {
|
||||||
full_spans.push(" ".repeat(gap).into());
|
full_spans.push(" ".repeat(gap).into());
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ impl SkillPopup {
|
|||||||
match_indices: indices,
|
match_indices: indices,
|
||||||
display_shortcut: None,
|
display_shortcut: None,
|
||||||
description: Some(description),
|
description: Some(description),
|
||||||
|
disabled_reason: None,
|
||||||
wrap_indent: None,
|
wrap_indent: None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2556,7 +2556,7 @@ impl ChatWidget {
|
|||||||
|
|
||||||
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
||||||
pub(crate) fn open_approvals_popup(&mut self) {
|
pub(crate) fn open_approvals_popup(&mut self) {
|
||||||
let current_approval = self.config.approval_policy;
|
let current_approval = self.config.approval_policy.value();
|
||||||
let current_sandbox = self.config.sandbox_policy.clone();
|
let current_sandbox = self.config.sandbox_policy.clone();
|
||||||
let mut items: Vec<SelectionItem> = Vec::new();
|
let mut items: Vec<SelectionItem> = Vec::new();
|
||||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||||
@@ -2564,8 +2564,11 @@ impl ChatWidget {
|
|||||||
let is_current =
|
let is_current =
|
||||||
Self::preset_matches_current(current_approval, ¤t_sandbox, &preset);
|
Self::preset_matches_current(current_approval, ¤t_sandbox, &preset);
|
||||||
let name = preset.label.to_string();
|
let name = preset.label.to_string();
|
||||||
let description_text = preset.description;
|
let description = Some(preset.description.to_string());
|
||||||
let description = Some(description_text.to_string());
|
let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) {
|
||||||
|
Ok(()) => None,
|
||||||
|
Err(err) => Some(err.to_string()),
|
||||||
|
};
|
||||||
let requires_confirmation = preset.id == "full-access"
|
let requires_confirmation = preset.id == "full-access"
|
||||||
&& !self
|
&& !self
|
||||||
.config
|
.config
|
||||||
@@ -2618,6 +2621,7 @@ impl ChatWidget {
|
|||||||
is_current,
|
is_current,
|
||||||
actions,
|
actions,
|
||||||
dismiss_on_select: true,
|
dismiss_on_select: true,
|
||||||
|
disabled_reason,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2954,7 +2958,9 @@ impl ChatWidget {
|
|||||||
|
|
||||||
/// Set the approval policy in the widget's config copy.
|
/// Set the approval policy in the widget's config copy.
|
||||||
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
||||||
self.config.approval_policy = policy;
|
if let Err(err) = self.config.approval_policy.set(policy) {
|
||||||
|
tracing::warn!(%err, "failed to set approval_policy on chat config");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the sandbox policy in the widget's config copy.
|
/// Set the sandbox policy in the widget's config copy.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use codex_core::CodexAuth;
|
|||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config::ConfigOverrides;
|
use codex_core::config::ConfigOverrides;
|
||||||
use codex_core::config::ConfigToml;
|
use codex_core::config::ConfigToml;
|
||||||
|
use codex_core::config::Constrained;
|
||||||
|
use codex_core::config::ConstraintError;
|
||||||
use codex_core::openai_models::models_manager::ModelsManager;
|
use codex_core::openai_models::models_manager::ModelsManager;
|
||||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||||
use codex_core::protocol::AgentMessageEvent;
|
use codex_core::protocol::AgentMessageEvent;
|
||||||
@@ -2039,17 +2041,125 @@ fn disabled_slash_command_while_task_running_snapshot() {
|
|||||||
assert_snapshot!(blob);
|
assert_snapshot!(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approvals_popup_shows_disabled_presets() {
|
||||||
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
|
|
||||||
|
chat.config.approval_policy =
|
||||||
|
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
|
||||||
|
AskForApproval::OnRequest => Ok(()),
|
||||||
|
_ => Err(ConstraintError {
|
||||||
|
message: "this message should be printed in the description".to_string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.expect("construct constrained approval policy");
|
||||||
|
|
||||||
|
chat.open_approvals_popup();
|
||||||
|
|
||||||
|
let width = 80;
|
||||||
|
let height = chat.desired_height(width);
|
||||||
|
let mut terminal =
|
||||||
|
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||||
|
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||||
|
terminal
|
||||||
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||||
|
.expect("render approvals popup");
|
||||||
|
|
||||||
|
let screen = terminal.backend().vt100().screen().contents();
|
||||||
|
let collapsed = screen.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
assert!(
|
||||||
|
collapsed.contains("(disabled)"),
|
||||||
|
"disabled preset label should be shown"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
collapsed.contains("this message should be printed in the description"),
|
||||||
|
"disabled preset reason should be shown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approvals_popup_navigation_skips_disabled() {
|
||||||
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None);
|
||||||
|
|
||||||
|
chat.config.approval_policy =
|
||||||
|
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
|
||||||
|
AskForApproval::OnRequest => Ok(()),
|
||||||
|
_ => Err(ConstraintError {
|
||||||
|
message: "disabled preset".to_string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.expect("construct constrained approval policy");
|
||||||
|
|
||||||
|
chat.open_approvals_popup();
|
||||||
|
|
||||||
|
// The approvals popup is the active bottom-pane view; drive navigation via chat handle_key_event.
|
||||||
|
// Start selected at idx 0 (enabled), move down twice; the disabled option should be skipped
|
||||||
|
// and selection should wrap back to idx 0 (also enabled).
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||||
|
|
||||||
|
// Press numeric shortcut for the disabled row (3 => idx 2); should not close or accept.
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Char('3')));
|
||||||
|
|
||||||
|
// Ensure the popup remains open and no selection actions were sent.
|
||||||
|
let width = 80;
|
||||||
|
let height = chat.desired_height(width);
|
||||||
|
let mut terminal =
|
||||||
|
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||||
|
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||||
|
terminal
|
||||||
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||||
|
.expect("render approvals popup after disabled selection");
|
||||||
|
let screen = terminal.backend().vt100().screen().contents();
|
||||||
|
assert!(
|
||||||
|
screen.contains("Select Approval Mode"),
|
||||||
|
"popup should remain open after selecting a disabled entry"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
op_rx.try_recv().is_err(),
|
||||||
|
"no actions should be dispatched yet"
|
||||||
|
);
|
||||||
|
assert!(rx.try_recv().is_err(), "no history should be emitted");
|
||||||
|
|
||||||
|
// Press Enter; selection should land on an enabled preset and dispatch updates.
|
||||||
|
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||||
|
let mut app_events = Vec::new();
|
||||||
|
while let Ok(ev) = rx.try_recv() {
|
||||||
|
app_events.push(ev);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
app_events.iter().any(|ev| matches!(
|
||||||
|
ev,
|
||||||
|
AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||||
|
approval_policy: Some(AskForApproval::OnRequest),
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)),
|
||||||
|
"enter should select an enabled preset"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!app_events.iter().any(|ev| matches!(
|
||||||
|
ev,
|
||||||
|
AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||||
|
approval_policy: Some(AskForApproval::Never),
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)),
|
||||||
|
"disabled preset should not be selected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Snapshot test: command approval modal
|
// Snapshot test: command approval modal
|
||||||
//
|
//
|
||||||
// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal
|
// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal
|
||||||
// and snapshots the visual output using the ratatui TestBackend.
|
// and snapshots the visual output using the ratatui TestBackend.
|
||||||
#[test]
|
#[test]
|
||||||
fn approval_modal_exec_snapshot() {
|
fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
||||||
// Build a chat widget with manual channels to avoid spawning the agent.
|
// Build a chat widget with manual channels to avoid spawning the agent.
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
|
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||||
// Inject an exec approval request to display the approval modal.
|
// Inject an exec approval request to display the approval modal.
|
||||||
let ev = ExecApprovalRequestEvent {
|
let ev = ExecApprovalRequestEvent {
|
||||||
call_id: "call-approve-cmd".into(),
|
call_id: "call-approve-cmd".into(),
|
||||||
@@ -2095,14 +2205,16 @@ fn approval_modal_exec_snapshot() {
|
|||||||
"approval_modal_exec",
|
"approval_modal_exec",
|
||||||
terminal.backend().vt100().screen().contents()
|
terminal.backend().vt100().screen().contents()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot test: command approval modal without a reason
|
// Snapshot test: command approval modal without a reason
|
||||||
// Ensures spacing looks correct when no reason text is provided.
|
// Ensures spacing looks correct when no reason text is provided.
|
||||||
#[test]
|
#[test]
|
||||||
fn approval_modal_exec_without_reason_snapshot() {
|
fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||||
|
|
||||||
let ev = ExecApprovalRequestEvent {
|
let ev = ExecApprovalRequestEvent {
|
||||||
call_id: "call-approve-cmd-noreason".into(),
|
call_id: "call-approve-cmd-noreason".into(),
|
||||||
@@ -2134,13 +2246,15 @@ fn approval_modal_exec_without_reason_snapshot() {
|
|||||||
"approval_modal_exec_no_reason",
|
"approval_modal_exec_no_reason",
|
||||||
terminal.backend().vt100().screen().contents()
|
terminal.backend().vt100().screen().contents()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot test: patch approval modal
|
// Snapshot test: patch approval modal
|
||||||
#[test]
|
#[test]
|
||||||
fn approval_modal_patch_snapshot() {
|
fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||||
|
|
||||||
// Build a small changeset and a reason/grant_root to exercise the prompt text.
|
// Build a small changeset and a reason/grant_root to exercise the prompt text.
|
||||||
let mut changes = HashMap::new();
|
let mut changes = HashMap::new();
|
||||||
@@ -2174,6 +2288,8 @@ fn approval_modal_patch_snapshot() {
|
|||||||
"approval_modal_patch",
|
"approval_modal_patch",
|
||||||
terminal.backend().vt100().screen().contents()
|
terminal.backend().vt100().screen().contents()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2736,10 +2852,10 @@ fn apply_patch_full_flow_integration_like() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_patch_untrusted_shows_approval_modal() {
|
fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
// Ensure approval policy is untrusted (OnRequest)
|
// Ensure approval policy is untrusted (OnRequest)
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||||
|
|
||||||
// Simulate a patch approval request from backend
|
// Simulate a patch approval request from backend
|
||||||
let mut changes = HashMap::new();
|
let mut changes = HashMap::new();
|
||||||
@@ -2778,14 +2894,16 @@ fn apply_patch_untrusted_shows_approval_modal() {
|
|||||||
contains_title,
|
contains_title,
|
||||||
"expected approval modal to be visible with title 'Would you like to make the following edits?'"
|
"expected approval modal to be visible with title 'Would you like to make the following edits?'"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn apply_patch_request_shows_diff_summary() {
|
fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> {
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
|
|
||||||
// Ensure we are in OnRequest so an approval is surfaced
|
// Ensure we are in OnRequest so an approval is surfaced
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||||
|
|
||||||
// Simulate backend asking to apply a patch adding two lines to README.md
|
// Simulate backend asking to apply a patch adding two lines to README.md
|
||||||
let mut changes = HashMap::new();
|
let mut changes = HashMap::new();
|
||||||
@@ -2844,6 +2962,8 @@ fn apply_patch_request_shows_diff_summary() {
|
|||||||
saw_line1 && saw_line2,
|
saw_line1 && saw_line2,
|
||||||
"expected modal to show per-line diff summary"
|
"expected modal to show per-line diff summary"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub struct VT100Backend {
|
|||||||
impl VT100Backend {
|
impl VT100Backend {
|
||||||
/// Creates a new `TestBackend` with the specified width and height.
|
/// Creates a new `TestBackend` with the specified width and height.
|
||||||
pub fn new(width: u16, height: u16) -> Self {
|
pub fn new(width: u16, height: u16) -> Self {
|
||||||
|
crossterm::style::force_color_output(true);
|
||||||
Self {
|
Self {
|
||||||
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
|
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2556,7 +2556,7 @@ impl ChatWidget {
|
|||||||
|
|
||||||
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
||||||
pub(crate) fn open_approvals_popup(&mut self) {
|
pub(crate) fn open_approvals_popup(&mut self) {
|
||||||
let current_approval = self.config.approval_policy;
|
let current_approval = self.config.approval_policy.value();
|
||||||
let current_sandbox = self.config.sandbox_policy.clone();
|
let current_sandbox = self.config.sandbox_policy.clone();
|
||||||
let mut items: Vec<SelectionItem> = Vec::new();
|
let mut items: Vec<SelectionItem> = Vec::new();
|
||||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||||
@@ -2954,7 +2954,9 @@ impl ChatWidget {
|
|||||||
|
|
||||||
/// Set the approval policy in the widget's config copy.
|
/// Set the approval policy in the widget's config copy.
|
||||||
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
||||||
self.config.approval_policy = policy;
|
if let Err(err) = self.config.approval_policy.set(policy) {
|
||||||
|
tracing::warn!(%err, "failed to set approval_policy on chat config");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the sandbox policy in the widget's config copy.
|
/// Set the sandbox policy in the widget's config copy.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use codex_core::CodexAuth;
|
|||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config::ConfigOverrides;
|
use codex_core::config::ConfigOverrides;
|
||||||
use codex_core::config::ConfigToml;
|
use codex_core::config::ConfigToml;
|
||||||
|
use codex_core::config::Constrained;
|
||||||
use codex_core::openai_models::models_manager::ModelsManager;
|
use codex_core::openai_models::models_manager::ModelsManager;
|
||||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||||
use codex_core::protocol::AgentMessageEvent;
|
use codex_core::protocol::AgentMessageEvent;
|
||||||
@@ -2049,7 +2050,7 @@ fn approval_modal_exec_snapshot() {
|
|||||||
// Build a chat widget with manual channels to avoid spawning the agent.
|
// Build a chat widget with manual channels to avoid spawning the agent.
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
|
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
// Inject an exec approval request to display the approval modal.
|
// Inject an exec approval request to display the approval modal.
|
||||||
let ev = ExecApprovalRequestEvent {
|
let ev = ExecApprovalRequestEvent {
|
||||||
call_id: "call-approve-cmd".into(),
|
call_id: "call-approve-cmd".into(),
|
||||||
@@ -2102,7 +2103,7 @@ fn approval_modal_exec_snapshot() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn approval_modal_exec_without_reason_snapshot() {
|
fn approval_modal_exec_without_reason_snapshot() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
|
|
||||||
let ev = ExecApprovalRequestEvent {
|
let ev = ExecApprovalRequestEvent {
|
||||||
call_id: "call-approve-cmd-noreason".into(),
|
call_id: "call-approve-cmd-noreason".into(),
|
||||||
@@ -2140,7 +2141,7 @@ fn approval_modal_exec_without_reason_snapshot() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn approval_modal_patch_snapshot() {
|
fn approval_modal_patch_snapshot() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
|
|
||||||
// Build a small changeset and a reason/grant_root to exercise the prompt text.
|
// Build a small changeset and a reason/grant_root to exercise the prompt text.
|
||||||
let mut changes = HashMap::new();
|
let mut changes = HashMap::new();
|
||||||
@@ -2739,7 +2740,7 @@ fn apply_patch_full_flow_integration_like() {
|
|||||||
fn apply_patch_untrusted_shows_approval_modal() {
|
fn apply_patch_untrusted_shows_approval_modal() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
// Ensure approval policy is untrusted (OnRequest)
|
// Ensure approval policy is untrusted (OnRequest)
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
|
|
||||||
// Simulate a patch approval request from backend
|
// Simulate a patch approval request from backend
|
||||||
let mut changes = HashMap::new();
|
let mut changes = HashMap::new();
|
||||||
@@ -2785,7 +2786,7 @@ fn apply_patch_request_shows_diff_summary() {
|
|||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||||
|
|
||||||
// Ensure we are in OnRequest so an approval is surfaced
|
// Ensure we are in OnRequest so an approval is surfaced
|
||||||
chat.config.approval_policy = AskForApproval::OnRequest;
|
chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||||
|
|
||||||
// Simulate backend asking to apply a patch adding two lines to README.md
|
// Simulate backend asking to apply a patch adding two lines to README.md
|
||||||
let mut changes = HashMap::new();
|
let mut changes = HashMap::new();
|
||||||
|
|||||||
Reference in new issue
Block a user