mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## What changed - Add model message metadata for built-in tool descriptions. - Use the active step model's catalog description for `send_user_message_async`, including after a mid-turn model change. - Fall back to the built-in description when catalog metadata is absent, while preserving an explicitly empty description. ## Testing - Cover catalog serialization, fallback and empty-value behavior, and model changes within a turn. GitOrigin-RevId: 72b953214ab9708931065321debf12133f8d2d40
842 lines
28 KiB
Rust
842 lines
28 KiB
Rust
use anyhow::Result;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_core::ForkSnapshot;
|
|
use codex_core::TurnInputRequest;
|
|
use codex_core::config::Constrained;
|
|
use codex_core::context::ApprovalPromptContext;
|
|
use codex_core::context::ContextualUserFragment;
|
|
use codex_core::context::PermissionsInstructions;
|
|
use codex_core::load_exec_policy;
|
|
use codex_models_manager::model_info::model_info_from_slug;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::openai_models::ApprovalMessages;
|
|
use codex_protocol::openai_models::ModelMessages;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::openai_models::PermissionMessages;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use core_test_support::responses::ResponsesRequest;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_sse_once;
|
|
use core_test_support::responses::sse;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::wait_for_event;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::HashSet;
|
|
use tempfile::TempDir;
|
|
|
|
fn permissions_texts(request: &ResponsesRequest) -> Vec<String> {
|
|
request
|
|
.message_input_texts("developer")
|
|
.into_iter()
|
|
.filter(|text| text.contains("<permissions instructions>"))
|
|
.collect()
|
|
}
|
|
|
|
fn model_with_approval_messages(
|
|
slug: &str,
|
|
on_request: &str,
|
|
on_request_auto_review: &str,
|
|
) -> codex_protocol::openai_models::ModelInfo {
|
|
let mut model = model_info_from_slug(slug);
|
|
model.model_messages = Some(ModelMessages {
|
|
persistent_instructions: None,
|
|
tools: None,
|
|
instructions_template: None,
|
|
instructions_variables: None,
|
|
approvals: Some(ApprovalMessages {
|
|
on_request: Some(on_request.to_string()),
|
|
on_request_auto_review: Some(on_request_auto_review.to_string()),
|
|
never: None,
|
|
unless_trusted: None,
|
|
}),
|
|
collaboration_modes: None,
|
|
auto_review: None,
|
|
permissions: None,
|
|
multi_agent: None,
|
|
token_budget: None,
|
|
confirmation_policies: None,
|
|
guardian_v2: None,
|
|
});
|
|
model
|
|
}
|
|
|
|
fn model_with_permission_messages(
|
|
slug: &str,
|
|
permissions: PermissionMessages,
|
|
) -> codex_protocol::openai_models::ModelInfo {
|
|
let mut model = model_info_from_slug(slug);
|
|
model.model_messages = Some(ModelMessages {
|
|
persistent_instructions: None,
|
|
tools: None,
|
|
instructions_template: None,
|
|
instructions_variables: None,
|
|
approvals: None,
|
|
collaboration_modes: None,
|
|
auto_review: None,
|
|
permissions: Some(permissions),
|
|
multi_agent: None,
|
|
token_budget: None,
|
|
confirmation_policies: None,
|
|
guardian_v2: None,
|
|
});
|
|
model
|
|
}
|
|
|
|
async fn submit_text_turn(
|
|
test: &core_test_support::test_codex::TestCodex,
|
|
text: &str,
|
|
) -> Result<()> {
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let req = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let model_slug = "catalog-approvals-model";
|
|
let override_instructions = "literal {{ personality }} override";
|
|
let model = model_with_approval_messages(
|
|
model_slug,
|
|
"catalog user approval instructions",
|
|
"catalog auto-review approval instructions",
|
|
);
|
|
let mut builder = test_codex()
|
|
.with_model(model_slug)
|
|
.with_config(move |config| {
|
|
config.base_instructions = Some(override_instructions.to_string());
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.model_catalog = Some(ModelsResponse {
|
|
models: vec![model],
|
|
});
|
|
});
|
|
let test = builder.build_with_auto_env(&server).await?;
|
|
|
|
submit_text_turn(&test, "hello").await?;
|
|
|
|
let request = req.single_request();
|
|
assert_eq!(request.instructions_text(), override_instructions);
|
|
let permissions = permissions_texts(&request);
|
|
assert_eq!(permissions.len(), 1);
|
|
assert!(permissions[0].contains("catalog user approval instructions"));
|
|
assert!(permissions[0].contains("Filesystem sandboxing defines"));
|
|
assert!(!permissions[0].contains("How to request escalation"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn model_change_appends_new_catalog_approval_message() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let _req1 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let req2 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
let first_slug = "catalog-approvals-model-a";
|
|
let second_slug = "catalog-approvals-model-b";
|
|
let first = model_with_approval_messages(first_slug, "model A approvals", "model A auto");
|
|
let second = model_with_approval_messages(second_slug, "model B approvals", "model B auto");
|
|
let mut builder = test_codex()
|
|
.with_model(first_slug)
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.model_catalog = Some(ModelsResponse {
|
|
models: vec![first, second],
|
|
});
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
submit_text_turn(&test, "first").await?;
|
|
|
|
core_test_support::submit_thread_settings(
|
|
&test.codex,
|
|
ThreadSettingsOverrides {
|
|
model: Some(second_slug.to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
submit_text_turn(&test, "second").await?;
|
|
|
|
let permissions = permissions_texts(&req2.single_request());
|
|
assert_eq!(permissions.len(), 2);
|
|
assert!(
|
|
permissions
|
|
.last()
|
|
.is_some_and(|text| text.contains("model B approvals"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permissions() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
for (approval_policy, approvals, expected, unexpected) in [
|
|
(
|
|
AskForApproval::Never,
|
|
ApprovalMessages {
|
|
on_request: None,
|
|
on_request_auto_review: None,
|
|
never: Some("catalog never approval instructions".to_string()),
|
|
unless_trusted: None,
|
|
},
|
|
"catalog never approval instructions",
|
|
"Approval policy is currently never",
|
|
),
|
|
(
|
|
AskForApproval::UnlessTrusted,
|
|
ApprovalMessages {
|
|
on_request: None,
|
|
on_request_auto_review: None,
|
|
never: None,
|
|
unless_trusted: Some("catalog unless-trusted approval instructions".to_string()),
|
|
},
|
|
"catalog unless-trusted approval instructions",
|
|
"`approval_policy` is `unless-trusted`",
|
|
),
|
|
] {
|
|
let server = start_mock_server().await;
|
|
let req = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let model_slug = "catalog-non-on-request-approvals-model";
|
|
let mut model = model_info_from_slug(model_slug);
|
|
model.model_messages = Some(ModelMessages {
|
|
persistent_instructions: None,
|
|
tools: None,
|
|
instructions_template: None,
|
|
instructions_variables: None,
|
|
approvals: Some(approvals),
|
|
collaboration_modes: None,
|
|
auto_review: None,
|
|
permissions: None,
|
|
multi_agent: None,
|
|
token_budget: None,
|
|
confirmation_policies: None,
|
|
guardian_v2: None,
|
|
});
|
|
let mut builder = test_codex()
|
|
.with_model(model_slug)
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
|
config.model_catalog = Some(ModelsResponse {
|
|
models: vec![model],
|
|
});
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
submit_text_turn(&test, "hello").await?;
|
|
|
|
let permissions = permissions_texts(&req.single_request());
|
|
assert_eq!(permissions.len(), 1);
|
|
assert!(permissions[0].contains(expected));
|
|
assert!(!permissions[0].contains(unexpected));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn catalog_permission_message_is_sent_initially_and_after_model_change() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let _req1 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let req2 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
let first_slug = "catalog-permissions-model-a";
|
|
let second_slug = "catalog-permissions-model-b";
|
|
let first = model_with_permission_messages(
|
|
first_slug,
|
|
PermissionMessages {
|
|
danger_full_access: None,
|
|
workspace_write: None,
|
|
read_only: Some("model A permissions: {{ network_access }}".to_string()),
|
|
},
|
|
);
|
|
let second = model_with_permission_messages(
|
|
second_slug,
|
|
PermissionMessages {
|
|
danger_full_access: None,
|
|
workspace_write: None,
|
|
read_only: Some("model B permissions: {{ network_access }}".to_string()),
|
|
},
|
|
);
|
|
let mut builder = test_codex()
|
|
.with_model(first_slug)
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::read_only())
|
|
.expect("read-only permission profile should be allowed");
|
|
config.model_catalog = Some(ModelsResponse {
|
|
models: vec![first, second],
|
|
});
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
submit_text_turn(&test, "first").await?;
|
|
|
|
core_test_support::submit_thread_settings(
|
|
&test.codex,
|
|
ThreadSettingsOverrides {
|
|
model: Some(second_slug.to_string()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
submit_text_turn(&test, "second").await?;
|
|
|
|
let permissions = permissions_texts(&req2.single_request());
|
|
assert_eq!(permissions.len(), 2);
|
|
assert!(permissions[0].contains("model A permissions: restricted"));
|
|
assert!(
|
|
permissions
|
|
.last()
|
|
.is_some_and(|text| text.contains("model B permissions: restricted"))
|
|
);
|
|
assert!(
|
|
permissions
|
|
.last()
|
|
.is_some_and(|text| text.contains("Approval policy is currently never"))
|
|
);
|
|
assert!(
|
|
!permissions
|
|
.iter()
|
|
.any(|text| text.contains("`sandbox_mode`"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn empty_catalog_permission_message_preserves_approval_instructions() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let req = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let model_slug = "empty-catalog-permissions-model";
|
|
let model = model_with_permission_messages(
|
|
model_slug,
|
|
PermissionMessages {
|
|
danger_full_access: None,
|
|
workspace_write: None,
|
|
read_only: Some(String::new()),
|
|
},
|
|
);
|
|
let mut builder = test_codex()
|
|
.with_model(model_slug)
|
|
.with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::read_only())
|
|
.expect("read-only permission profile should be allowed");
|
|
config.model_catalog = Some(ModelsResponse {
|
|
models: vec![model],
|
|
});
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
submit_text_turn(&test, "hello").await?;
|
|
|
|
let permissions = permissions_texts(&req.single_request());
|
|
assert_eq!(permissions.len(), 1);
|
|
assert!(permissions[0].contains("Approval policy is currently never"));
|
|
assert!(!permissions[0].contains("Filesystem sandboxing defines"));
|
|
assert!(!permissions[0].contains("`sandbox_mode`"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn permissions_message_sent_once_on_start() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let req = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
assert_eq!(permissions_texts(&req.single_request()).len(), 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn permissions_message_added_on_override_change() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let req1 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let req2 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 1".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
core_test_support::submit_thread_settings(
|
|
&test.codex,
|
|
ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 2".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let permissions_1 = permissions_texts(&req1.single_request());
|
|
let permissions_2 = permissions_texts(&req2.single_request());
|
|
|
|
assert_eq!(permissions_1.len(), 1);
|
|
assert_eq!(permissions_2.len(), 2);
|
|
let unique = permissions_2.into_iter().collect::<HashSet<String>>();
|
|
assert_eq!(unique.len(), 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn permissions_message_not_added_when_no_change() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let req1 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let req2 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 1".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 2".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let permissions_1 = permissions_texts(&req1.single_request());
|
|
let permissions_2 = permissions_texts(&req2.single_request());
|
|
|
|
assert_eq!(permissions_1.len(), 1);
|
|
assert_eq!(permissions_2.len(), 1);
|
|
assert_eq!(permissions_1, permissions_2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn permissions_message_omitted_when_disabled() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let req1 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let req2 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.include_permissions_instructions = false;
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 1".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
core_test_support::submit_thread_settings(
|
|
&test.codex,
|
|
ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 2".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
assert_eq!(
|
|
permissions_texts(&req1.single_request()),
|
|
Vec::<String>::new()
|
|
);
|
|
assert_eq!(
|
|
permissions_texts(&req2.single_request()),
|
|
Vec::<String>::new()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn resume_replays_permissions_messages() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let _req1 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let _req2 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
let req3 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
});
|
|
let initial = builder.build(&server).await?;
|
|
|
|
initial
|
|
.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 1".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
core_test_support::submit_thread_settings(
|
|
&initial.codex,
|
|
ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
initial
|
|
.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 2".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let resumed = builder.restart(&server, &initial).await?;
|
|
resumed
|
|
.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "after resume".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let permissions = permissions_texts(&req3.single_request());
|
|
assert_eq!(permissions.len(), 3);
|
|
let unique = permissions.into_iter().collect::<HashSet<String>>();
|
|
assert_eq!(unique.len(), 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let _req1 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let req2 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
)
|
|
.await;
|
|
let req3 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]),
|
|
)
|
|
.await;
|
|
let req4 = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-4"), ev_completed("resp-4")]),
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
});
|
|
let initial = builder.build(&server).await?;
|
|
let rollout_path = initial
|
|
.session_configured
|
|
.rollout_path
|
|
.clone()
|
|
.expect("rollout path");
|
|
|
|
initial
|
|
.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 1".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
core_test_support::submit_thread_settings(
|
|
&initial.codex,
|
|
ThreadSettingsOverrides {
|
|
approval_policy: Some(AskForApproval::Never),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
initial
|
|
.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello 2".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let permissions_base = permissions_texts(&req2.single_request());
|
|
assert_eq!(permissions_base.len(), 2);
|
|
|
|
builder = builder.with_config(|config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
|
});
|
|
let resumed = builder.restart(&server, &initial).await?;
|
|
resumed
|
|
.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "after resume".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let permissions_resume = permissions_texts(&req3.single_request());
|
|
assert_eq!(permissions_resume.len(), permissions_base.len() + 1);
|
|
assert_eq!(
|
|
&permissions_resume[..permissions_base.len()],
|
|
permissions_base.as_slice()
|
|
);
|
|
assert!(!permissions_base.contains(permissions_resume.last().expect("new permissions")));
|
|
|
|
let mut fork_config = initial.config.clone();
|
|
fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
|
let forked = initial
|
|
.thread_manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
fork_config.clone(),
|
|
rollout_path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await?;
|
|
forked
|
|
.thread
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "after fork".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let permissions_fork = permissions_texts(&req4.single_request());
|
|
assert_eq!(permissions_fork.len(), permissions_base.len() + 1);
|
|
assert_eq!(
|
|
&permissions_fork[..permissions_base.len()],
|
|
permissions_base.as_slice()
|
|
);
|
|
let new_permissions = &permissions_fork[permissions_base.len()..];
|
|
assert_eq!(new_permissions.len(), 1);
|
|
assert_eq!(permissions_fork, permissions_resume);
|
|
assert!(!permissions_base.contains(&new_permissions[0]));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn permissions_message_includes_writable_roots() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let req = mount_sse_once(
|
|
&server,
|
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
)
|
|
.await;
|
|
let writable = TempDir::new()?;
|
|
let writable_root = AbsolutePathBuf::try_from(writable.path())?;
|
|
let writable_root_for_config = writable_root.clone();
|
|
let permission_profile = PermissionProfile::workspace_write_with(
|
|
std::slice::from_ref(&writable_root),
|
|
NetworkSandboxPolicy::Restricted,
|
|
/*exclude_tmpdir_env_var*/ false,
|
|
/*exclude_slash_tmp*/ false,
|
|
);
|
|
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config
|
|
.permissions
|
|
.set_permission_profile(permission_profile)
|
|
.expect("test permission profile should be allowed");
|
|
let workspace_roots = vec![config.cwd.clone(), writable_root_for_config];
|
|
config.workspace_roots = workspace_roots.clone();
|
|
config.permissions.set_workspace_roots(workspace_roots);
|
|
config.config_layer_stack = ConfigLayerStack::default();
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
|
text: "hello".into(),
|
|
text_elements: Vec::new(),
|
|
}]))
|
|
.await?;
|
|
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
|
|
let permissions = permissions_texts(&req.single_request());
|
|
let normalize_line_endings = |s: &str| s.replace("\r\n", "\n");
|
|
let exec_policy = load_exec_policy(&test.config.config_layer_stack).await?;
|
|
let permission_profile = test.config.permissions.effective_permission_profile();
|
|
let expected = PermissionsInstructions::from_permission_profile(
|
|
&permission_profile,
|
|
AskForApproval::OnRequest,
|
|
ApprovalPromptContext::new(
|
|
test.config.approvals_reviewer,
|
|
/*messages*/ None,
|
|
/*permission_messages*/ None,
|
|
),
|
|
&exec_policy,
|
|
test.config.cwd.as_path(),
|
|
/*exec_permission_approvals_enabled*/ false,
|
|
/*request_permissions_tool_enabled*/ false,
|
|
)
|
|
.render();
|
|
let expected_normalized = normalize_line_endings(&expected);
|
|
let actual_normalized: Vec<String> = permissions
|
|
.iter()
|
|
.map(|s| normalize_line_endings(s))
|
|
.collect();
|
|
assert_eq!(actual_normalized, vec![expected_normalized]);
|
|
|
|
Ok(())
|
|
}
|