mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## Why History recording used the model captured at turn start, so switching models within a turn could apply stale image preparation and tool-output truncation rules. ## What changed Pass explicit model metadata through conversation recording. Record model responses and tool results using their originating step's settings, and capture the current model for pending input recorded during startup interruption or task completion. ## Testing Add regression coverage for tool results across a model switch, explicit model selection for plain and annotated history, and image input retained during interruption and completion. Extend model-switching tests to verify that prepared media and item identity survive switching back to a multimodal model in live, resumed, and forked threads. GitOrigin-RevId: 5bc750c30c334acb9b802cb80fdea01daffa84a1
4044 lines
149 KiB
Rust
4044 lines
149 KiB
Rust
use super::*;
|
|
use crate::config::Config;
|
|
use crate::config::ConfigOverrides;
|
|
use crate::config::Constrained;
|
|
use crate::config::ManagedFeatures;
|
|
use crate::config::NetworkProxySpec;
|
|
use crate::config::PermissionProfileSnapshot;
|
|
use crate::config::test_config;
|
|
use crate::environment_selection::TurnEnvironmentState;
|
|
use crate::guardian::approval_request::format_guardian_action_compact;
|
|
use crate::guardian::approval_request::guardian_request_target_item_id;
|
|
use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY;
|
|
use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE;
|
|
use crate::guardian::prompt::guardian_policy_prompt_with_config_and_template;
|
|
use crate::guardian::review::guardian_review_session_config;
|
|
use crate::guardian::review::routes_approval_to_guardian_with_reviewer;
|
|
use crate::session::session::Session;
|
|
use crate::session::tests::update_turn_settings_for_test;
|
|
use crate::session::turn_context::TurnContext;
|
|
use crate::test_support;
|
|
use codex_analytics::GuardianApprovalRequestSource;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_config::FeatureRequirementsToml;
|
|
use codex_config::NetworkConstraints;
|
|
use codex_config::NetworkDomainPermissionToml;
|
|
use codex_config::NetworkDomainPermissionsToml;
|
|
use codex_config::RequirementSource;
|
|
use codex_config::Sourced;
|
|
use codex_config::config_toml::ConfigToml;
|
|
use codex_config::types::McpServerConfig;
|
|
use codex_exec_server::LOCAL_FS;
|
|
use codex_features::Feature;
|
|
use codex_guardian_context::ConversationTranscriptEntry;
|
|
use codex_guardian_context::ConversationTranscriptEntryKind;
|
|
use codex_history::RolloutItem;
|
|
use codex_model_provider::create_model_provider;
|
|
use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID;
|
|
use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
|
|
use codex_model_provider_info::ModelProviderInfo;
|
|
use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
|
use codex_models_manager::manager::StaticModelsManager;
|
|
use codex_network_proxy::NetworkProxyConfig;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::approvals::GuardianAssessmentAction;
|
|
use codex_protocol::approvals::NetworkApprovalProtocol;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::FunctionCallOutputContentItem;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::models::SandboxPermissions;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::GranularApprovalConfig;
|
|
use codex_protocol::protocol::GuardianAssessmentStatus;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
use codex_protocol::protocol::TurnCompleteEvent;
|
|
use codex_utils_path_uri::LegacyAppPathString;
|
|
use codex_utils_path_uri::PathUri;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::TempDirExt;
|
|
use core_test_support::context_snapshot;
|
|
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
|
use core_test_support::responses::assert_parent_turn;
|
|
use core_test_support::responses::assert_root_turn;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_response_sequence;
|
|
use core_test_support::responses::mount_sse_once;
|
|
use core_test_support::responses::mount_sse_sequence;
|
|
use core_test_support::responses::sse;
|
|
use core_test_support::responses::sse_failed;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::streaming_sse::StreamingSseChunk;
|
|
use core_test_support::streaming_sse::start_streaming_sse_server;
|
|
use core_test_support::test_path_buf;
|
|
use insta::Settings;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
fn fixed_guardian_parent_session_id() -> ThreadId {
|
|
ThreadId::from_string("11111111-1111-4111-8111-111111111111")
|
|
.expect("fixed parent session id should be a valid UUID")
|
|
}
|
|
|
|
const GUARDIAN_MEMORY_CONTEXT_PROBE: &str = "guardian memory context probe";
|
|
const GUARDIAN_SKILL_NAME: &str = "guardian-context-probe";
|
|
const GUARDIAN_SKILL_BODY_PROBE: &str = "guardian skill body probe";
|
|
|
|
// The memories extension depends on codex-core, so this probe verifies the nested Guardian config
|
|
// at request assembly without introducing a circular test dependency.
|
|
struct GuardianMemoryContextEnabled(bool);
|
|
|
|
struct GuardianMemoryContextProbe;
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<Config> for GuardianMemoryContextProbe {
|
|
fn on_thread_start<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::ThreadStartInput<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
input.thread_store.insert(GuardianMemoryContextEnabled(
|
|
input.config.memories.use_memories,
|
|
));
|
|
})
|
|
}
|
|
}
|
|
|
|
impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe {
|
|
fn contribute_thread_context<'a>(
|
|
&'a self,
|
|
_session_store: &'a codex_extension_api::ExtensionData,
|
|
thread_store: &'a codex_extension_api::ExtensionData,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::PromptFragment>> {
|
|
Box::pin(async move {
|
|
if thread_store
|
|
.get::<GuardianMemoryContextEnabled>()
|
|
.is_some_and(|enabled| enabled.0)
|
|
{
|
|
vec![codex_extension_api::PromptFragment::developer_policy(
|
|
GUARDIAN_MEMORY_CONTEXT_PROBE,
|
|
codex_extension_api::ContentItemKind(
|
|
"guardian.memory_context_probe".to_string(),
|
|
),
|
|
)]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn guardian_test_session_and_turn(
|
|
server: &wiremock::MockServer,
|
|
) -> (Arc<Session>, Arc<TurnContext>) {
|
|
guardian_test_session_and_turn_with_base_url(server.uri().as_str()).await
|
|
}
|
|
|
|
async fn guardian_test_session_turn_and_rx(
|
|
server: &wiremock::MockServer,
|
|
) -> (
|
|
Arc<Session>,
|
|
Arc<TurnContext>,
|
|
async_channel::Receiver<Event>,
|
|
) {
|
|
let (mut session, mut turn, rx) =
|
|
crate::session::tests::make_session_and_context_with_rx().await;
|
|
Arc::get_mut(&mut session)
|
|
.expect("session should be uniquely owned")
|
|
.thread_id = fixed_guardian_parent_session_id();
|
|
let mut config = (*turn.config).clone();
|
|
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
|
let config = Arc::new(config);
|
|
let models_manager = test_support::models_manager_with_provider(
|
|
config.codex_home.to_path_buf(),
|
|
Arc::clone(&session.services.auth_manager),
|
|
config.model_provider.clone(),
|
|
);
|
|
Arc::get_mut(&mut session)
|
|
.expect("session should be uniquely owned")
|
|
.services
|
|
.models_manager = models_manager;
|
|
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
|
|
turn_mut.config = Arc::clone(&config);
|
|
turn_mut.provider =
|
|
create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone());
|
|
|
|
(session, turn, rx)
|
|
}
|
|
|
|
fn native_guardian_cwd(path: &str) -> LegacyAppPathString {
|
|
LegacyAppPathString::from_abs_path(&test_path_buf(path).abs())
|
|
}
|
|
|
|
fn guardian_exec_command_request(id: &str) -> GuardianApprovalRequest {
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: id.to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the reviewed docs fix.".to_string()),
|
|
tty: false,
|
|
}
|
|
}
|
|
|
|
fn guardian_mcp_request(server: &str, tool_name: &str) -> GuardianApprovalRequest {
|
|
GuardianApprovalRequest::McpToolCall {
|
|
id: "mcp-1".to_string(),
|
|
server: server.to_string(),
|
|
tool_name: tool_name.to_string(),
|
|
arguments: Some(serde_json::json!({
|
|
"code": "await browser.open('https://example.com')",
|
|
})),
|
|
connector_id: Some("connector-1".to_string()),
|
|
connector_name: Some("Connected tools".to_string()),
|
|
connector_description: None,
|
|
connected_account_email: None,
|
|
tool_title: Some("Execute JavaScript".to_string()),
|
|
tool_description: None,
|
|
annotations: Some(GuardianMcpAnnotations {
|
|
destructive_hint: None,
|
|
open_world_hint: Some(true),
|
|
read_only_hint: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
async fn guardian_test_session_and_turn_with_base_url(
|
|
base_url: &str,
|
|
) -> (Arc<Session>, Arc<TurnContext>) {
|
|
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
|
|
session.thread_id = fixed_guardian_parent_session_id();
|
|
let mut config = (*turn.config).clone();
|
|
config.model_provider.base_url = Some(format!("{base_url}/v1"));
|
|
let config = Arc::new(config);
|
|
let models_manager = test_support::models_manager_with_provider(
|
|
config.codex_home.to_path_buf(),
|
|
Arc::clone(&session.services.auth_manager),
|
|
config.model_provider.clone(),
|
|
);
|
|
session.services.models_manager = models_manager;
|
|
turn.config = Arc::clone(&config);
|
|
turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone());
|
|
|
|
(Arc::new(session), Arc::new(turn))
|
|
}
|
|
|
|
async fn seed_guardian_parent_history(session: &Arc<Session>, turn: &Arc<TurnContext>) {
|
|
session
|
|
.record_conversation_items(
|
|
turn.as_ref(),
|
|
turn.model_info(),
|
|
&[
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Please check the repo visibility and push the docs fix if needed."
|
|
.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: "gh_repo_view".to_string(),
|
|
namespace: None,
|
|
arguments: "{\"repo\":\"openai/codex\"}".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
encrypted_function_args: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: Some("call-1".to_string()),
|
|
name: None,
|
|
namespace: None,
|
|
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
|
|
"repo visibility: public".to_string(),
|
|
),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "The repo is public; I now need approval to push the docs fix."
|
|
.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
],
|
|
)
|
|
.await;
|
|
}
|
|
|
|
fn rollout_item_contains_message_text(item: &RolloutItem, needle: &str) -> bool {
|
|
let RolloutItem::ResponseItem(response_item) = item else {
|
|
return false;
|
|
};
|
|
response_item_contains_message_text(response_item, needle)
|
|
}
|
|
|
|
fn response_item_contains_message_text(item: &ResponseItem, needle: &str) -> bool {
|
|
let ResponseItem::Message { content, .. } = item else {
|
|
return false;
|
|
};
|
|
content.iter().any(|item| match item {
|
|
ContentItem::InputText { text } | ContentItem::OutputText { text } => text.contains(needle),
|
|
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false,
|
|
})
|
|
}
|
|
|
|
fn guardian_snapshot_options() -> ContextSnapshotOptions {
|
|
ContextSnapshotOptions::default()
|
|
.strip_capability_instructions()
|
|
.strip_agents_md_user_context()
|
|
}
|
|
|
|
fn normalize_guardian_snapshot_paths(text: String) -> String {
|
|
let mut text = text;
|
|
for canonical_path in ["/repo/codex-rs/core", "/repo"] {
|
|
let platform_path = test_path_buf(canonical_path).display().to_string();
|
|
if platform_path == canonical_path {
|
|
continue;
|
|
}
|
|
|
|
let escaped_platform_path = serde_json::to_string(&platform_path)
|
|
.expect("test path should serialize")
|
|
.trim_matches('"')
|
|
.to_string();
|
|
text = text
|
|
.replace(&escaped_platform_path, canonical_path)
|
|
.replace(&platform_path, canonical_path);
|
|
}
|
|
let guardian_policy = guardian_policy_prompt_with_config_and_template(
|
|
BUNDLED_GUARDIAN_POLICY,
|
|
BUNDLED_GUARDIAN_POLICY_TEMPLATE,
|
|
)
|
|
.replace("\r\n", "\n")
|
|
.replace('\r', "\n")
|
|
.replace('\n', "\\n");
|
|
text.replace(&guardian_policy, "<GUARDIAN_POLICY>")
|
|
}
|
|
|
|
fn guardian_prompt_text(items: &[codex_protocol::user_input::UserInput]) -> String {
|
|
items
|
|
.iter()
|
|
.map(|item| match item {
|
|
codex_protocol::user_input::UserInput::Text { text, .. } => text.as_str(),
|
|
_ => "",
|
|
})
|
|
.collect::<String>()
|
|
}
|
|
|
|
fn last_user_message_text_from_body(body: &serde_json::Value) -> String {
|
|
body["input"]
|
|
.as_array()
|
|
.expect("request input array")
|
|
.iter()
|
|
.filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user"))
|
|
.filter_map(|item| item.get("content").and_then(serde_json::Value::as_array))
|
|
.next_back()
|
|
.expect("user message content")
|
|
.iter()
|
|
.filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text"))
|
|
.filter_map(|span| span.get("text").and_then(serde_json::Value::as_str))
|
|
.collect::<String>()
|
|
}
|
|
|
|
#[test]
|
|
fn build_guardian_transcript_keeps_original_numbering() {
|
|
let entries = [
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::User,
|
|
text: "first".to_string(),
|
|
original_bytes: "first".len(),
|
|
},
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::Assistant,
|
|
text: "second".to_string(),
|
|
original_bytes: "second".len(),
|
|
},
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::ProtectedAssistant,
|
|
text: "third".to_string(),
|
|
original_bytes: "third".len(),
|
|
},
|
|
];
|
|
|
|
let (transcript, omission) = render_guardian_transcript_entries(&entries[..2]);
|
|
|
|
assert_eq!(
|
|
transcript,
|
|
vec![
|
|
"[1] user: first".to_string(),
|
|
"[2] assistant: second".to_string()
|
|
]
|
|
);
|
|
assert!(omission.is_none());
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> anyhow::Result<()> {
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let prompt = build_guardian_prompt_items(
|
|
session.as_ref(),
|
|
Some("Sandbox denied outbound git push to github.com.".to_string()),
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the reviewed docs fix.".to_string()),
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Full,
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("whose request action you are assessing"));
|
|
assert!(text.contains(">>> TRANSCRIPT START\n"));
|
|
assert!(text.contains(">>> TRANSCRIPT END\n"));
|
|
assert!(text.contains("The Codex agent has requested the following action:\n"));
|
|
assert!(!text.contains("TRANSCRIPT DELTA"));
|
|
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_prefers_retry_reason_over_approval_reason() -> anyhow::Result<()> {
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
let context = GuardianReviewContext::from(&turn);
|
|
|
|
let prompt = build_guardian_prompt_items_with_parent_turn(
|
|
session.as_ref(),
|
|
session.conversation_history_snapshot().await.as_ref(),
|
|
Some(&context),
|
|
ApprovalRequestReasons {
|
|
approval: Some("A policy rule requires approval.".to_string()),
|
|
retry: Some("The sandbox blocked the initial command.".to_string()),
|
|
},
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: None,
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Full,
|
|
/*reviewed_node_repl_evidence_sequence*/ 0,
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("Retry reason:\nThe sandbox blocked the initial command.\n\n"));
|
|
assert!(!text.contains("A policy rule requires approval."));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_truncates_oversized_approval_reason() -> anyhow::Result<()> {
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
let context = GuardianReviewContext::from(&turn);
|
|
let approval_reason = format!("policy-start {} policy-end", "x".repeat(10_000));
|
|
let expected_reason = codex_utils_output_truncation::truncate_text(
|
|
&approval_reason,
|
|
codex_utils_output_truncation::TruncationPolicy::Tokens(/*tokens*/ 512),
|
|
);
|
|
|
|
let prompt = build_guardian_prompt_items_with_parent_turn(
|
|
session.as_ref(),
|
|
session.conversation_history_snapshot().await.as_ref(),
|
|
Some(&context),
|
|
ApprovalRequestReasons {
|
|
approval: Some(approval_reason),
|
|
retry: None,
|
|
},
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: None,
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Full,
|
|
/*reviewed_node_repl_evidence_sequence*/ 0,
|
|
)
|
|
.await?;
|
|
|
|
let reason_item = prompt
|
|
.items
|
|
.iter()
|
|
.find_map(|item| match item {
|
|
codex_protocol::user_input::UserInput::Text { text, .. }
|
|
if text.contains("tokens truncated") =>
|
|
{
|
|
Some(text)
|
|
}
|
|
_ => None,
|
|
})
|
|
.expect("oversized approval reason should include a truncation marker");
|
|
assert!(reason_item.starts_with("policy-start"));
|
|
assert!(reason_item.ends_with("policy-end\n\n"));
|
|
assert_eq!(reason_item, &format!("{expected_reason}\n\n"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Result<()> {
|
|
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
|
|
session.thread_id = fixed_guardian_parent_session_id();
|
|
let workspace_root = test_path_buf("/repo").abs();
|
|
let second_workspace_root = test_path_buf("/another-repo").abs();
|
|
let denied_root = workspace_root.join("private");
|
|
let second_denied_root = second_workspace_root.join("private");
|
|
let denied_glob = test_path_buf("/repo/private/**").display().to_string();
|
|
let environment_permission_profile = PermissionProfile::from_runtime_permissions(
|
|
&FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(Some(
|
|
"private".to_string(),
|
|
)),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: denied_glob.clone(),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
missing_path_behavior: None,
|
|
},
|
|
]),
|
|
NetworkSandboxPolicy::Restricted,
|
|
);
|
|
let TurnEnvironmentState::Ready(environment) = &mut turn.environments.environments[0] else {
|
|
panic!("parent environment should be ready");
|
|
};
|
|
environment.config_mut().permission_profile =
|
|
PermissionProfileSnapshot::legacy(environment_permission_profile);
|
|
environment.config_mut().workspace_roots = vec![
|
|
PathUri::from_abs_path(&workspace_root),
|
|
PathUri::from_abs_path(&second_workspace_root),
|
|
];
|
|
let session = Arc::new(session);
|
|
let turn = Arc::new(turn);
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
let context = GuardianReviewContext::from(&turn);
|
|
|
|
let prompt = build_guardian_prompt_items_with_parent_turn(
|
|
session.as_ref(),
|
|
session.conversation_history_snapshot().await.as_ref(),
|
|
Some(&context),
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("Sandbox denied reading /repo/private/secret.txt.".to_string()),
|
|
},
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["cat".to_string(), "/repo/private/secret.txt".to_string()],
|
|
cwd: test_path_buf("/repo").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated,
|
|
additional_permissions: None,
|
|
justification: Some("Need to inspect the secret file.".to_string()),
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Full,
|
|
/*reviewed_node_repl_evidence_sequence*/ 0,
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("PARENT TURN PERMISSION CONTEXT START"));
|
|
assert!(text.contains("do not approve escalation whose purpose is to read them"));
|
|
assert!(text.contains(denied_root.to_string_lossy().as_ref()));
|
|
assert!(text.contains(second_denied_root.to_string_lossy().as_ref()));
|
|
assert!(text.contains(&format!("glob `{denied_glob}`")));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyhow::Result<()> {
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
session
|
|
.record_conversation_items(
|
|
turn.as_ref(),
|
|
turn.model_info(),
|
|
&[
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Please also push the second docs fix.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "I need approval for the second push.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let prompt = build_guardian_prompt_items(
|
|
session.as_ref(),
|
|
/*retry_reason*/ None,
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-2".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the second docs fix.".to_string()),
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Delta {
|
|
cursor: GuardianTranscriptCursor {
|
|
parent_history_version: 0,
|
|
transcript_entry_count: 4,
|
|
},
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("added since your last approval assessment"));
|
|
assert!(text.contains(">>> TRANSCRIPT DELTA START\n"));
|
|
assert!(text.contains("[5] user: Please also push the second docs fix."));
|
|
assert!(text.contains("[6] assistant: I need approval for the second push."));
|
|
assert!(text.contains(">>> TRANSCRIPT DELTA END\n"));
|
|
assert!(text.contains("The Codex agent has requested the following next action:\n"));
|
|
assert!(!text.contains("[1] user: Please check the repo visibility"));
|
|
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 6);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_delta_mode_handles_empty_delta() -> anyhow::Result<()> {
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let prompt = build_guardian_prompt_items(
|
|
session.as_ref(),
|
|
/*retry_reason*/ None,
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-2".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the second docs fix.".to_string()),
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Delta {
|
|
cursor: GuardianTranscriptCursor {
|
|
parent_history_version: 0,
|
|
transcript_entry_count: 4,
|
|
},
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains(">>> TRANSCRIPT DELTA START\n"));
|
|
assert!(text.contains("<no retained transcript delta entries>"));
|
|
assert!(text.contains(">>> TRANSCRIPT DELTA END\n"));
|
|
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_stale_delta_cursor_falls_back_to_full_prompt() -> anyhow::Result<()>
|
|
{
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let prompt = build_guardian_prompt_items(
|
|
session.as_ref(),
|
|
/*retry_reason*/ None,
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-3".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the docs fix.".to_string()),
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Delta {
|
|
cursor: GuardianTranscriptCursor {
|
|
parent_history_version: 0,
|
|
transcript_entry_count: 99,
|
|
},
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("whose request action you are assessing"));
|
|
assert!(text.contains(">>> TRANSCRIPT START\n"));
|
|
assert!(!text.contains("TRANSCRIPT DELTA"));
|
|
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -> anyhow::Result<()>
|
|
{
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
session
|
|
.replace_history(
|
|
vec![
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Compacted retained user request.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "Compacted summary of earlier guardian context.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
],
|
|
/*reference_context_item*/ None,
|
|
)
|
|
.await;
|
|
session
|
|
.record_conversation_items(
|
|
turn.as_ref(),
|
|
turn.model_info(),
|
|
&[
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Please push after the compaction.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "I need approval for the post-compaction push.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let prompt = build_guardian_prompt_items(
|
|
session.as_ref(),
|
|
/*retry_reason*/ None,
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-4".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push after the compaction.".to_string()),
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Delta {
|
|
cursor: GuardianTranscriptCursor {
|
|
parent_history_version: 0,
|
|
transcript_entry_count: 4,
|
|
},
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("whose request action you are assessing"));
|
|
assert!(text.contains(">>> TRANSCRIPT START\n"));
|
|
assert!(!text.contains("TRANSCRIPT DELTA"));
|
|
assert!(text.contains("[3] user: Please push after the compaction."));
|
|
assert!(text.contains("[4] assistant: I need approval for the post-compaction push."));
|
|
assert_eq!(prompt.transcript_cursor.parent_history_version, 1);
|
|
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn collect_guardian_transcript_entries(
|
|
history: &dyn codex_guardian_context::SectionHistory,
|
|
node_repl_result_token_limit: usize,
|
|
) -> Vec<ConversationTranscriptEntry> {
|
|
prompt::collect_guardian_context(
|
|
history,
|
|
node_repl_result_token_limit,
|
|
&[],
|
|
&[],
|
|
/*planned_action*/ None,
|
|
/*permissions*/ None,
|
|
/*node_repl*/ None,
|
|
)
|
|
.expect("collect Guardian context")
|
|
.transcript_entries()
|
|
.to_vec()
|
|
}
|
|
|
|
#[test]
|
|
fn collect_guardian_transcript_entries_skips_contextual_user_messages() {
|
|
let items = vec![
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "hello".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
];
|
|
|
|
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
|
|
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(
|
|
entries[0],
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::ProtectedAssistant,
|
|
text: "hello".to_string(),
|
|
original_bytes: "hello".len(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() {
|
|
let approval_text =
|
|
format!("{AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX}\n\nApproved action:\n{{}}");
|
|
let items = vec![
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "developer".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "ordinary developer context".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "developer".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: approval_text.clone(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
];
|
|
|
|
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
|
|
|
|
assert_eq!(
|
|
entries,
|
|
vec![ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::Developer,
|
|
original_bytes: approval_text.len(),
|
|
text: approval_text,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
|
|
let mut items = vec![
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "check the repo".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: "read_file".to_string(),
|
|
namespace: None,
|
|
arguments: "{\"path\":\"README.md\"}".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
encrypted_function_args: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: Some("call-1".to_string()),
|
|
name: None,
|
|
namespace: None,
|
|
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
|
|
"repo is public".to_string(),
|
|
),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "I need to push a fix".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
];
|
|
|
|
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
|
|
|
|
assert_eq!(entries.len(), 4);
|
|
assert_eq!(
|
|
entries[1],
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::ToolCall("tool read_file call".to_string()),
|
|
text: "{\"path\":\"README.md\"}".to_string(),
|
|
original_bytes: "{\"path\":\"README.md\"}".len(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
entries[2],
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::ToolOutput("tool read_file result".to_string()),
|
|
text: "repo is public".to_string(),
|
|
original_bytes: "repo is public".len(),
|
|
}
|
|
);
|
|
if let ResponseItem::FunctionCall { namespace, .. } = &mut items[1] {
|
|
*namespace = Some("mcp__node_repl__".to_string());
|
|
}
|
|
assert!(matches!(
|
|
collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS)[2].kind,
|
|
ConversationTranscriptEntryKind::NodeReplToolOutput(_)
|
|
));
|
|
|
|
let oversized_result = "é🙂".repeat(/*n*/ 10_000);
|
|
if let ResponseItem::FunctionCallOutput { output, .. } = &mut items[2] {
|
|
*output =
|
|
codex_protocol::models::FunctionCallOutputPayload::from_text(oversized_result.clone());
|
|
}
|
|
for token_cap in [
|
|
GUARDIAN_MAX_TOOL_ENTRY_TOKENS,
|
|
GUARDIAN_MAX_NODE_REPL_TOOL_RESULT_TOKENS,
|
|
] {
|
|
let entries = collect_guardian_transcript_entries(&items, token_cap);
|
|
assert_eq!(
|
|
entries[2],
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::NodeReplToolOutput(
|
|
"tool read_file result".to_string()
|
|
),
|
|
text: guardian_truncate_text(&oversized_result, token_cap).0,
|
|
original_bytes: oversized_result.len(),
|
|
}
|
|
);
|
|
assert_eq!(entries.len(), 4);
|
|
assert_eq!(
|
|
render_guardian_transcript_entries(&entries),
|
|
(
|
|
vec![
|
|
"[1] user: check the repo".to_string(),
|
|
"[2] tool read_file call: {\"path\":\"README.md\"}".to_string(),
|
|
format!("[3] tool read_file result: {}", entries[2].text),
|
|
"[4] assistant: I need to push a fix".to_string(),
|
|
],
|
|
None,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn collect_guardian_transcript_entries_preserves_named_unpaired_tool_sources() {
|
|
let mut items = vec![ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: None,
|
|
name: Some("notifications".to_string()),
|
|
namespace: Some("slack".to_string()),
|
|
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
|
|
"new message".to_string(),
|
|
),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}];
|
|
items.extend(
|
|
[
|
|
(None, "anonymous output"),
|
|
(Some("missing-call"), "orphaned function output"),
|
|
]
|
|
.map(|(call_id, text)| ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: call_id.map(str::to_string),
|
|
name: None,
|
|
namespace: None,
|
|
output: codex_protocol::models::FunctionCallOutputPayload::from_text(text.to_string()),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}),
|
|
);
|
|
items.push(ResponseItem::CustomToolCallOutput {
|
|
id: None,
|
|
call_id: "missing-custom-call".to_string(),
|
|
name: None,
|
|
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
|
|
"orphaned custom output".to_string(),
|
|
),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
});
|
|
|
|
let mut expected = vec![ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::ToolOutput(
|
|
"tool slack.notifications result".to_string(),
|
|
),
|
|
text: "new message".to_string(),
|
|
original_bytes: "new message".len(),
|
|
}];
|
|
expected.extend(
|
|
["orphaned function output", "orphaned custom output"].map(|text| {
|
|
ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::ToolOutput("tool result".to_string()),
|
|
text: text.to_string(),
|
|
original_bytes: text.len(),
|
|
}
|
|
}),
|
|
);
|
|
assert_eq!(
|
|
collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS),
|
|
expected,
|
|
);
|
|
|
|
if let ResponseItem::FunctionCallOutput { output, .. } = &mut items[0] {
|
|
*output = codex_protocol::models::FunctionCallOutputPayload::from_content_items(vec![
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,image".to_string(),
|
|
detail: None,
|
|
},
|
|
]);
|
|
}
|
|
expected[0] = ConversationTranscriptEntry {
|
|
kind: ConversationTranscriptEntryKind::ToolOutput(
|
|
"tool slack.notifications result".to_string(),
|
|
),
|
|
text: "[non-text output]".to_string(),
|
|
original_bytes: "[non-text output]".len(),
|
|
};
|
|
assert_eq!(
|
|
collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS),
|
|
expected,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() {
|
|
let content = "prefix ".repeat(200) + &" suffix".repeat(200);
|
|
|
|
let (truncated, was_truncated) = guardian_truncate_text(&content, /*token_cap*/ 20);
|
|
|
|
assert!(truncated.starts_with("prefix"));
|
|
assert!(truncated.contains("<truncated omitted_approx_tokens=\""));
|
|
assert!(truncated.ends_with("suffix"));
|
|
assert!(was_truncated);
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_action_formatters_reject_large_aggregate_payloads() {
|
|
let file: PathUri = test_path_buf("/tmp/file").abs().into();
|
|
let action = GuardianApprovalRequest::ApplyPatch {
|
|
id: "patch-1".to_string(),
|
|
cwd: test_path_buf("/tmp").abs().into(),
|
|
files: vec![file; 20_000],
|
|
patch: String::new(),
|
|
};
|
|
|
|
for error in [
|
|
format_guardian_action_pretty(&action).map(|_| ()),
|
|
format_guardian_action_compact(&action).map(|_| ()),
|
|
] {
|
|
assert_eq!(
|
|
error
|
|
.expect_err("aggregate action should exceed the review limit")
|
|
.to_string(),
|
|
"Guardian action exceeds the 200000-byte review limit"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn format_guardian_action_pretty_reports_no_truncation_for_small_payload() -> serde_json::Result<()>
|
|
{
|
|
let action = GuardianApprovalRequest::ApplyPatch {
|
|
id: "patch-1".to_string(),
|
|
cwd: test_path_buf("/tmp").abs().into(),
|
|
files: Vec::new(),
|
|
patch: "line\n".to_string(),
|
|
};
|
|
|
|
let rendered = format_guardian_action_pretty(&action)?;
|
|
|
|
assert!(rendered.text.contains("\"tool\": \"apply_patch\""));
|
|
assert!(!rendered.truncated);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json::Result<()> {
|
|
let action = GuardianApprovalRequest::McpToolCall {
|
|
id: "call-1".to_string(),
|
|
server: "mcp_server".to_string(),
|
|
tool_name: "browser_navigate".to_string(),
|
|
arguments: Some(serde_json::json!({
|
|
"url": "https://example.com",
|
|
})),
|
|
connector_id: None,
|
|
connector_name: Some("Playwright".to_string()),
|
|
connector_description: None,
|
|
connected_account_email: Some("owner@example.com".to_string()),
|
|
tool_title: Some("Navigate".to_string()),
|
|
tool_description: None,
|
|
annotations: Some(GuardianMcpAnnotations {
|
|
destructive_hint: Some(true),
|
|
open_world_hint: None,
|
|
read_only_hint: Some(false),
|
|
}),
|
|
};
|
|
|
|
assert_eq!(
|
|
guardian_approval_request_to_json(&action)?,
|
|
serde_json::json!({
|
|
"tool": "mcp_tool_call",
|
|
"server": "mcp_server",
|
|
"tool_name": "browser_navigate",
|
|
"arguments": {
|
|
"url": "https://example.com",
|
|
},
|
|
"connector_name": "Playwright",
|
|
"connected_account_email": "owner@example.com",
|
|
"tool_title": "Navigate",
|
|
"annotations": {
|
|
"destructive_hint": true,
|
|
"read_only_hint": false,
|
|
},
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_items_keeps_required_node_repl_reviews_generic() -> anyhow::Result<()>
|
|
{
|
|
let (session, mut turn) =
|
|
guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
update_turn_settings_for_test(
|
|
Arc::get_mut(&mut turn).expect("turn should be uniquely owned"),
|
|
|settings| Arc::make_mut(&mut settings.model_info).node_repl_auto_review_required = true,
|
|
);
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
let context = GuardianReviewContext::from(&turn);
|
|
|
|
let prompt = build_guardian_prompt_items_with_parent_turn(
|
|
session.as_ref(),
|
|
session.conversation_history_snapshot().await.as_ref(),
|
|
Some(&context),
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("Retry the authorized browser inspection.".to_string()),
|
|
},
|
|
guardian_mcp_request("node_repl", "js"),
|
|
GuardianPromptMode::Full,
|
|
/*reviewed_node_repl_evidence_sequence*/ 0,
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("Assess the exact planned action below."));
|
|
assert!(text.contains("Retry reason:\nRetry the authorized browser inspection."));
|
|
assert!(text.contains("Planned action JSON:"));
|
|
assert!(text.contains("\"tool\": \"mcp_tool_call\""));
|
|
assert!(text.contains("\"server\": \"node_repl\""));
|
|
assert!(text.contains("\"tool_name\": \"js\""));
|
|
assert!(text.contains("await browser.open('https://example.com')"));
|
|
assert!(!text.contains("# Computer and Browser Use"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_items_keeps_other_requests_generic() -> anyhow::Result<()> {
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
let context = GuardianReviewContext::from(&turn);
|
|
|
|
for request in [
|
|
guardian_mcp_request("node_repl", "js"),
|
|
guardian_mcp_request("node_repl", "inspect"),
|
|
guardian_mcp_request("another_server", "js"),
|
|
guardian_exec_command_request("shell-1"),
|
|
] {
|
|
let prompt = build_guardian_prompt_items_with_parent_turn(
|
|
session.as_ref(),
|
|
session.conversation_history_snapshot().await.as_ref(),
|
|
Some(&context),
|
|
ApprovalRequestReasons::default(),
|
|
request,
|
|
GuardianPromptMode::Full,
|
|
/*reviewed_node_repl_evidence_sequence*/ 0,
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("Assess the exact planned action below."));
|
|
assert!(text.contains("Planned action JSON:"));
|
|
assert!(!text.contains("Node REPL action JSON:"));
|
|
assert!(!text.contains("Distinguish preparation"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_approval_request_to_json_renders_network_access_trigger() -> serde_json::Result<()> {
|
|
let cwd = PathUri::parse("file:///C:/repo").expect("valid Windows path URI");
|
|
let action = GuardianApprovalRequest::NetworkAccess {
|
|
id: "network-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
target: "https://example.com:443".to_string(),
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
port: 443,
|
|
trigger: Some(GuardianNetworkAccessTrigger {
|
|
call_id: "call-1".to_string(),
|
|
tool_name: "shell".to_string(),
|
|
command: vec!["curl".to_string(), "https://example.com".to_string()],
|
|
cwd,
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Fetch the release metadata.".to_string()),
|
|
tty: None,
|
|
}),
|
|
};
|
|
|
|
assert_eq!(
|
|
guardian_approval_request_to_json(&action)?,
|
|
serde_json::json!({
|
|
"tool": "network_access",
|
|
"target": "https://example.com:443",
|
|
"host": "example.com",
|
|
"protocol": "https",
|
|
"port": 443,
|
|
"trigger": {
|
|
"callId": "call-1",
|
|
"toolName": "shell",
|
|
"command": ["curl", "https://example.com"],
|
|
"cwd": "C:\\repo",
|
|
"sandboxPermissions": "use_default",
|
|
"justification": "Fetch the release metadata.",
|
|
},
|
|
})
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn build_guardian_prompt_items_explains_network_access_review_scope() -> anyhow::Result<()> {
|
|
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
let cwd = PathUri::from_abs_path(&test_path_buf("/repo").abs());
|
|
|
|
let prompt = build_guardian_prompt_items(
|
|
session.as_ref(),
|
|
Some("Network access to \"example.com\" is blocked by policy.".to_string()),
|
|
GuardianApprovalRequest::NetworkAccess {
|
|
id: "network-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
target: "https://example.com:443".to_string(),
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
port: 443,
|
|
trigger: Some(GuardianNetworkAccessTrigger {
|
|
call_id: "call-1".to_string(),
|
|
tool_name: "shell".to_string(),
|
|
command: vec!["curl".to_string(), "https://example.com".to_string()],
|
|
cwd,
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Fetch the release metadata.".to_string()),
|
|
tty: None,
|
|
}),
|
|
},
|
|
GuardianPromptMode::Full,
|
|
)
|
|
.await?;
|
|
|
|
let text = guardian_prompt_text(&prompt.items);
|
|
assert!(text.contains("Below is a proposed network access request under review."));
|
|
assert!(!text.contains("Network approval context:"));
|
|
assert!(
|
|
!text.contains(
|
|
"This approval request is about network access to the target in the network access JSON below"
|
|
)
|
|
);
|
|
assert!(
|
|
text.contains(
|
|
"When assessing this request, focus primarily on whether the triggering command is authorised by the user and whether it is within the rules."
|
|
)
|
|
);
|
|
assert!(
|
|
text.contains(
|
|
"The user does not need to have explicitly authorised this exact network connection, as long as the network access is a reasonable consequence of the triggering command."
|
|
)
|
|
);
|
|
assert!(text.contains("\"trigger\""));
|
|
assert!(text.contains("Network access JSON:"));
|
|
assert!(!text.contains("The Codex agent has requested the following action:"));
|
|
assert!(!text.contains("Planned action JSON:"));
|
|
assert!(!text.contains("Retry reason:"));
|
|
assert!(!text.contains("Network access to \"example.com\" is blocked by policy."));
|
|
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("snapshots");
|
|
settings.set_prepend_module_to_snapshot(false);
|
|
settings.bind(|| {
|
|
assert_snapshot!(
|
|
"codex_core__guardian__tests__network_access_guardian_prompt_layout",
|
|
normalize_guardian_snapshot_paths(text)
|
|
);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case::test_case(SandboxPermissions::UseDefault)]
|
|
#[test_case::test_case(SandboxPermissions::RequireEscalated)]
|
|
#[test_case::test_case(SandboxPermissions::WithAdditionalPermissions)]
|
|
fn guardian_write_stdin_preserves_input_and_foreign_cwd(
|
|
sandbox_permissions: SandboxPermissions,
|
|
) -> serde_json::Result<()> {
|
|
let cwd = PathUri::parse("file:///C:/workspace").expect("valid executor cwd");
|
|
let input = "confirm\n";
|
|
let additional_permissions =
|
|
if sandbox_permissions == SandboxPermissions::WithAdditionalPermissions {
|
|
Some(serde_json::from_value(
|
|
serde_json::json!({"network":{"enabled":true}}),
|
|
)?)
|
|
} else {
|
|
None
|
|
};
|
|
let action = GuardianApprovalRequest::WriteStdin {
|
|
id: "terminal-open".to_string(),
|
|
approval_id: "terminal-write".to_string(),
|
|
environment_id: "windows-executor".to_string(),
|
|
process_id: 1000,
|
|
input: input.to_string(),
|
|
cwd: cwd.clone(),
|
|
tty: true,
|
|
sandbox_permissions,
|
|
additional_permissions: additional_permissions.clone(),
|
|
};
|
|
|
|
let mut expected = serde_json::json!({
|
|
"tool": "write_stdin",
|
|
"environment_id": "windows-executor",
|
|
"session_id": 1000,
|
|
"chars": input,
|
|
"cwd": r"C:\workspace",
|
|
"sandbox_permissions": sandbox_permissions,
|
|
"tty": true,
|
|
});
|
|
if let Some(permissions) = additional_permissions {
|
|
expected["additional_permissions"] = serde_json::to_value(permissions)?;
|
|
}
|
|
assert_eq!(guardian_approval_request_to_json(&action)?, expected);
|
|
assert_eq!(
|
|
guardian_assessment_action(&action),
|
|
GuardianAssessmentAction::WriteStdin {
|
|
approval_id: "terminal-write".to_string(),
|
|
process_id: "1000".to_string(),
|
|
stdin: input.to_string(),
|
|
cwd,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
(
|
|
guardian_request_target_item_id(&action),
|
|
guardian_request_turn_id(&action, "current-turn"),
|
|
),
|
|
(Some("terminal-open"), "current-turn"),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case::test_case(codex_utils_path_uri::PathConvention::Windows, r"C:\workspace"; "windows")]
|
|
#[test_case::test_case(codex_utils_path_uri::PathConvention::Posix, "/C:/workspace"; "posix")]
|
|
fn guardian_exec_command_uses_executor_cwd_convention(
|
|
cwd_convention: codex_utils_path_uri::PathConvention,
|
|
expected_cwd: &str,
|
|
) -> serde_json::Result<()> {
|
|
let cwd = PathUri::parse("file:///C:/workspace").expect("valid executor cwd");
|
|
let action = crate::tools::sandboxing::ApprovalAction::ExecCommand {
|
|
id: "command-1".to_string(),
|
|
environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "status".to_string()],
|
|
hook_command: "git status".to_string(),
|
|
cwd,
|
|
sandbox_permissions: SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: None,
|
|
tty: false,
|
|
proposed_execpolicy_amendment: None,
|
|
}
|
|
.into_guardian_request(Some(cwd_convention))
|
|
.expect("Guardian request should render executor cwd");
|
|
|
|
assert_eq!(
|
|
guardian_approval_request_to_json(&action)?,
|
|
serde_json::json!({
|
|
"tool": "exec_command",
|
|
"command": ["git", "status"],
|
|
"cwd": expected_cwd,
|
|
"sandbox_permissions": "use_default",
|
|
"tty": false,
|
|
}),
|
|
);
|
|
assert_eq!(
|
|
guardian_assessment_action(&action),
|
|
GuardianAssessmentAction::Command {
|
|
source: codex_protocol::approvals::GuardianCommandSource::UnifiedExec,
|
|
command: "git status".to_string(),
|
|
cwd: LegacyAppPathString::from_string(expected_cwd),
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_apply_patch_preserves_foreign_paths_and_redacts_patch_text() -> serde_json::Result<()> {
|
|
let cwd = PathUri::parse("file:///C:/workspace").expect("valid executor cwd");
|
|
let file =
|
|
PathUri::parse("file:///C:/workspace/guardian.txt").expect("valid executor file path");
|
|
let patch = "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+secret\n*** End Patch";
|
|
let action = GuardianApprovalRequest::ApplyPatch {
|
|
id: "patch-1".to_string(),
|
|
cwd,
|
|
files: vec![file],
|
|
patch: patch.to_string(),
|
|
};
|
|
|
|
assert_eq!(
|
|
guardian_approval_request_to_json(&action)?,
|
|
serde_json::json!({
|
|
"tool": "apply_patch",
|
|
"cwd": r"C:\workspace",
|
|
"files": [r"C:\workspace\guardian.txt"],
|
|
"patch": patch,
|
|
}),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(guardian_assessment_action(&action))?,
|
|
serde_json::json!({
|
|
"type": "apply_patch",
|
|
"cwd": r"C:\workspace",
|
|
"files": [r"C:\workspace\guardian.txt"],
|
|
}),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_request_turn_id_prefers_network_access_owner_turn() {
|
|
let network_access = GuardianApprovalRequest::NetworkAccess {
|
|
id: "network-1".to_string(),
|
|
turn_id: "owner-turn".to_string(),
|
|
target: "https://example.com:443".to_string(),
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
port: 443,
|
|
trigger: None,
|
|
};
|
|
let apply_patch = GuardianApprovalRequest::ApplyPatch {
|
|
id: "patch-1".to_string(),
|
|
cwd: test_path_buf("/tmp").abs().into(),
|
|
files: vec![test_path_buf("/tmp/guardian.txt").abs().into()],
|
|
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
|
|
.to_string(),
|
|
};
|
|
|
|
assert_eq!(
|
|
guardian_request_turn_id(&network_access, "fallback-turn"),
|
|
"owner-turn"
|
|
);
|
|
assert_eq!(
|
|
guardian_request_turn_id(&apply_patch, "fallback-turn"),
|
|
"fallback-turn"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_request_target_item_id_omits_network_access_trigger_call_id() {
|
|
let network_access = GuardianApprovalRequest::NetworkAccess {
|
|
id: "network-1".to_string(),
|
|
turn_id: "owner-turn".to_string(),
|
|
target: "https://example.com:443".to_string(),
|
|
host: "example.com".to_string(),
|
|
protocol: NetworkApprovalProtocol::Https,
|
|
port: 443,
|
|
trigger: Some(GuardianNetworkAccessTrigger {
|
|
call_id: "call-1".to_string(),
|
|
tool_name: "shell".to_string(),
|
|
command: vec!["curl".to_string(), "https://example.com".to_string()],
|
|
cwd: PathUri::from_abs_path(&test_path_buf("/repo").abs()),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: None,
|
|
tty: None,
|
|
}),
|
|
};
|
|
|
|
assert_eq!(guardian_request_target_item_id(&network_access), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
|
|
let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
|
|
let cancel_token = CancellationToken::new();
|
|
cancel_token.cancel();
|
|
|
|
let decision = super::decide_approval(
|
|
Arc::clone(&session),
|
|
&turn,
|
|
"review-cancelled-guardian".to_string(),
|
|
GuardianApprovalRequest::ApplyPatch {
|
|
id: "patch-1".to_string(),
|
|
cwd: test_path_buf("/tmp").abs().into(),
|
|
files: vec![test_path_buf("/tmp/guardian.txt").abs().into()],
|
|
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
|
|
.to_string(),
|
|
},
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: None,
|
|
},
|
|
GuardianReviewOptions {
|
|
require_guardian: true,
|
|
plugin_attribution_override: None,
|
|
approval_request_source: GuardianApprovalRequestSource::MainTurn,
|
|
external_cancel: Some(cancel_token),
|
|
require_synchronous_review: false,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(decision, Some(ReviewDecision::Abort));
|
|
|
|
let mut guardian_statuses = Vec::new();
|
|
let mut warnings = Vec::new();
|
|
while let Ok(event) = rx.try_recv() {
|
|
match event.msg {
|
|
EventMsg::GuardianAssessment(event) => guardian_statuses.push(event.status),
|
|
EventMsg::GuardianWarning(event) => warnings.push(event.message),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
guardian_statuses,
|
|
vec![
|
|
GuardianAssessmentStatus::InProgress,
|
|
GuardianAssessmentStatus::Aborted,
|
|
]
|
|
);
|
|
assert!(warnings.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_timeout_message_distinguishes_timeout_from_policy_denial() {
|
|
let mut model = codex_models_manager::model_info::model_info_from_slug("acting-model");
|
|
model.model_messages = None;
|
|
let message = guardian_timeout_message(&model);
|
|
assert!(message.contains("did not finish before its deadline"));
|
|
assert!(message.contains("retry once"));
|
|
assert!(!message.contains("unacceptable risk"));
|
|
|
|
for timeout_instructions in [None, Some("Catalog timeout instructions."), Some("")] {
|
|
model.model_messages = Some(
|
|
serde_json::from_value(serde_json::json!({
|
|
"auto_review": {
|
|
"policy": "review policy",
|
|
"timeout_instructions": timeout_instructions,
|
|
},
|
|
}))
|
|
.expect("model messages should deserialize"),
|
|
);
|
|
assert_eq!(
|
|
guardian_timeout_message(&model),
|
|
timeout_instructions.unwrap_or(&message),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routes_approval_to_guardian_requires_guardian_reviewer() {
|
|
let (_session, mut turn) = crate::session::tests::make_session_and_context().await;
|
|
let mut config = (*turn.config).clone();
|
|
config.approvals_reviewer = ApprovalsReviewer::User;
|
|
turn.config = Arc::new(config.clone());
|
|
|
|
assert!(!routes_approval_to_guardian(&turn));
|
|
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
turn.config = Arc::new(config);
|
|
|
|
assert!(routes_approval_to_guardian(&turn));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routes_approval_to_guardian_can_use_app_reviewer_override() {
|
|
let (_session, turn) = crate::session::tests::make_session_and_context().await;
|
|
|
|
assert!(!routes_approval_to_guardian_with_reviewer(
|
|
&turn,
|
|
ApprovalsReviewer::User
|
|
));
|
|
assert!(routes_approval_to_guardian_with_reviewer(
|
|
&turn,
|
|
ApprovalsReviewer::AutoReview
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routes_approval_to_guardian_allows_granular_review_policy() {
|
|
let (_session, mut turn) = crate::session::tests::make_session_and_context().await;
|
|
let mut config = (*turn.config).clone();
|
|
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
|
turn.config = Arc::new(config);
|
|
Arc::make_mut(&mut turn.config)
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::Granular(GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: true,
|
|
}))
|
|
.expect("test setup should allow updating approval policy");
|
|
|
|
assert!(routes_approval_to_guardian(&turn));
|
|
}
|
|
|
|
#[test]
|
|
fn build_guardian_transcript_reserves_separate_budget_for_tool_evidence() {
|
|
let repeated = "signal ".repeat(8_000);
|
|
let mut items = [
|
|
("user", "please figure out if the repo is public"),
|
|
(
|
|
"assistant",
|
|
"The public repo check is the main reason I want to escalate.",
|
|
),
|
|
]
|
|
.into_iter()
|
|
.map(|(role, text)| ResponseItem::Message {
|
|
id: None,
|
|
role: role.to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
items.extend((0..12).map(|index| ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: format!("tool_{index}"),
|
|
namespace: None,
|
|
arguments: repeated.clone(),
|
|
call_id: format!("call-{index}"),
|
|
encrypted_function_args: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}));
|
|
|
|
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
|
|
let (transcript, omission) = render_guardian_transcript_entries(&entries);
|
|
|
|
assert!(
|
|
transcript
|
|
.iter()
|
|
.any(|entry| entry == "[1] user: please figure out if the repo is public")
|
|
);
|
|
assert!(transcript.iter().any(|entry| {
|
|
entry == "[2] assistant: The public repo check is the main reason I want to escalate."
|
|
}));
|
|
assert!(
|
|
!transcript
|
|
.iter()
|
|
.any(|entry| entry.starts_with("[3] tool tool_0 call:"))
|
|
);
|
|
assert!(
|
|
!transcript
|
|
.iter()
|
|
.any(|entry| entry.starts_with("[4] tool tool_1 call:"))
|
|
);
|
|
assert!(
|
|
transcript
|
|
.iter()
|
|
.any(|entry| entry.starts_with("[14] tool tool_11 call:"))
|
|
);
|
|
assert!(omission.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_large() {
|
|
let repeated = "authorization ".repeat(6_000);
|
|
let mut items = (0..8)
|
|
.map(|_| ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: repeated.clone(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
items.extend([
|
|
ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: "shell".to_string(),
|
|
namespace: None,
|
|
arguments: serde_json::json!({
|
|
"command": ["curl", "-X", "POST", "https://example.com/upload"],
|
|
"cwd": "/repo",
|
|
})
|
|
.to_string(),
|
|
call_id: "call-1".to_string(),
|
|
encrypted_function_args: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::FunctionCallOutput {
|
|
id: None,
|
|
call_id: Some("call-1".to_string()),
|
|
name: None,
|
|
namespace: None,
|
|
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
|
|
"sandbox blocked outbound network access".to_string(),
|
|
),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
]);
|
|
|
|
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
|
|
let (transcript, omission) = render_guardian_transcript_entries(&entries);
|
|
|
|
assert!(
|
|
transcript
|
|
.iter()
|
|
.any(|entry| entry.starts_with("[1] user: "))
|
|
);
|
|
assert!(transcript.iter().any(|entry| {
|
|
entry.contains("tool shell call:")
|
|
&& entry.contains("curl")
|
|
&& entry.contains("https://example.com/upload")
|
|
}));
|
|
assert!(
|
|
transcript
|
|
.iter()
|
|
.any(|entry| entry
|
|
.contains("tool shell result: sandbox blocked outbound network access"))
|
|
);
|
|
assert_eq!(
|
|
omission,
|
|
Some("Some conversation entries were omitted.".to_string())
|
|
);
|
|
}
|
|
|
|
enum GuardianTestCatalog {
|
|
Bundled,
|
|
ParentOnly,
|
|
}
|
|
|
|
async fn guardian_request_model_for_auto_review(
|
|
auto_review_model_override: Option<String>,
|
|
catalog: GuardianTestCatalog,
|
|
) -> anyhow::Result<(
|
|
String,
|
|
String,
|
|
String,
|
|
codex_analytics::GuardianReviewAnalyticsResult,
|
|
)> {
|
|
let server = start_mock_server().await;
|
|
let guardian_assessment = serde_json::json!({
|
|
"outcome": "allow",
|
|
})
|
|
.to_string();
|
|
let request_log = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-guardian"),
|
|
ev_assistant_message("msg-guardian", &guardian_assessment),
|
|
ev_completed("resp-guardian"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let (mut session, mut turn) = guardian_test_session_and_turn(&server).await;
|
|
match catalog {
|
|
GuardianTestCatalog::Bundled => {}
|
|
GuardianTestCatalog::ParentOnly => {
|
|
let parent_model = turn.model_info().as_ref().clone();
|
|
let auth_manager = Arc::clone(&session.services.auth_manager);
|
|
let models_manager = StaticModelsManager::new(
|
|
Some(auth_manager),
|
|
ModelsResponse {
|
|
models: vec![parent_model],
|
|
},
|
|
);
|
|
Arc::get_mut(&mut session)
|
|
.expect("session should be unique")
|
|
.services
|
|
.models_manager = Arc::new(models_manager);
|
|
}
|
|
}
|
|
update_turn_settings_for_test(
|
|
Arc::get_mut(&mut turn).expect("turn should be unique"),
|
|
|settings| {
|
|
Arc::make_mut(&mut settings.model_info).auto_review_model_override =
|
|
auto_review_model_override
|
|
},
|
|
);
|
|
let parent_model = turn.model_info().slug.clone();
|
|
let preferred_model = turn.provider.approval_review_preferred_model().to_string();
|
|
let parent_turn_id = turn.sub_id.clone();
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let (outcome, analytics_result) = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
turn,
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: None,
|
|
tty: false,
|
|
},
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("Sandbox denied outbound git push to github.com.".to_string()),
|
|
},
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
let GuardianReviewOutcome::Completed(_) = outcome else {
|
|
panic!("expected guardian assessment");
|
|
};
|
|
|
|
let request = request_log.single_request();
|
|
let request_body = request.body_json();
|
|
core_test_support::responses::assert_parent_turn(&request_body, Some(parent_turn_id.as_str()))?;
|
|
let request_model = request_body
|
|
.get("model")
|
|
.and_then(|value| value.as_str())
|
|
.expect("guardian request should include a model")
|
|
.to_string();
|
|
|
|
Ok((
|
|
request_model,
|
|
parent_model,
|
|
preferred_model,
|
|
analytics_result,
|
|
))
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_uses_model_catalog_override_when_preferred_review_model_exists()
|
|
-> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let override_model = "guardian-review-model-override".to_string();
|
|
let (request_model, parent_model, preferred_model, analytics_result) =
|
|
guardian_request_model_for_auto_review(
|
|
Some(override_model.clone()),
|
|
GuardianTestCatalog::Bundled,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(request_model, override_model);
|
|
assert_ne!(request_model, parent_model);
|
|
assert_ne!(request_model, preferred_model);
|
|
assert_eq!(
|
|
analytics_result.guardian_catalog_contains_auto_review,
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_default_review_model_id.as_deref(),
|
|
Some(preferred_model.as_str())
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_review_model_overridden,
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_review_model_override.as_deref(),
|
|
Some(override_model.as_str())
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_model_provider_id.as_deref(),
|
|
Some(OPENAI_PROVIDER_ID)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_uses_preferred_review_model_without_model_catalog_override()
|
|
-> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (request_model, parent_model, preferred_model, analytics_result) =
|
|
guardian_request_model_for_auto_review(
|
|
/*auto_review_model_override*/ None,
|
|
GuardianTestCatalog::Bundled,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(request_model, preferred_model);
|
|
assert_ne!(request_model, parent_model);
|
|
assert_eq!(
|
|
analytics_result.guardian_catalog_contains_auto_review,
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_default_review_model_id.as_deref(),
|
|
Some(preferred_model.as_str())
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_review_model_overridden,
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_review_model_override.as_deref(),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_model_provider_id.as_deref(),
|
|
Some(OPENAI_PROVIDER_ID)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_records_missing_auto_review_model_in_analytics_metadata()
|
|
-> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let (request_model, parent_model, preferred_model, analytics_result) =
|
|
guardian_request_model_for_auto_review(
|
|
/*auto_review_model_override*/ None,
|
|
GuardianTestCatalog::ParentOnly,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(request_model, parent_model);
|
|
assert_ne!(request_model, preferred_model);
|
|
assert_eq!(
|
|
analytics_result.guardian_catalog_contains_auto_review,
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_default_review_model_id.as_deref(),
|
|
Some(preferred_model.as_str())
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_review_model_overridden,
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_review_model_override.as_deref(),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
analytics_result.guardian_model_provider_id.as_deref(),
|
|
Some(OPENAI_PROVIDER_ID)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
|
|
-> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let guardian_assessment = serde_json::json!({
|
|
"risk_level": "medium",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "The user explicitly requested pushing the reviewed branch to the known remote.",
|
|
})
|
|
.to_string();
|
|
let request_log = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-guardian"),
|
|
ev_assistant_message("msg-guardian", &guardian_assessment),
|
|
ev_completed("resp-guardian"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
|
|
session.thread_id = fixed_guardian_parent_session_id();
|
|
let temp_cwd = TempDir::new()?;
|
|
let mut config = (*turn.config).clone();
|
|
config.cwd = temp_cwd.abs();
|
|
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
|
config.memories.use_memories = true;
|
|
config
|
|
.features
|
|
.enable(Feature::MemoryTool)
|
|
.expect("memory tool feature is configurable");
|
|
let config = Arc::new(config);
|
|
let models_manager = test_support::models_manager_with_provider(
|
|
config.codex_home.to_path_buf(),
|
|
Arc::clone(&session.services.auth_manager),
|
|
config.model_provider.clone(),
|
|
);
|
|
session.services.models_manager = models_manager;
|
|
let memory_extension = Arc::new(GuardianMemoryContextProbe);
|
|
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new();
|
|
extensions.thread_lifecycle_contributor(memory_extension.clone());
|
|
extensions.prompt_contributor(memory_extension);
|
|
session.services.extensions = Arc::new(extensions.build());
|
|
|
|
let skill_dir = config
|
|
.codex_home
|
|
.to_path_buf()
|
|
.join("skills")
|
|
.join(GUARDIAN_SKILL_NAME);
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
format!(
|
|
"---\nname: {GUARDIAN_SKILL_NAME}\ndescription: Guardian skill injection probe.\n---\n\n{GUARDIAN_SKILL_BODY_PROBE}\n"
|
|
),
|
|
)?;
|
|
session.services.skills_service.clear_cache();
|
|
turn.config = Arc::clone(&config);
|
|
turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone());
|
|
update_turn_settings_for_test(&mut turn, |settings| {
|
|
Arc::make_mut(&mut settings.model_info).auto_review_model_override =
|
|
Some("codex-auto-review".to_string());
|
|
});
|
|
let session = Arc::new(session);
|
|
let turn = Arc::new(turn);
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
session
|
|
.record_conversation_items(
|
|
turn.as_ref(),
|
|
turn.model_info(),
|
|
&[ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: format!(
|
|
"Use ${GUARDIAN_SKILL_NAME} before deciding whether the push is safe."
|
|
),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}],
|
|
)
|
|
.await;
|
|
|
|
let request = GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec![
|
|
"git".to_string(),
|
|
"push".to_string(),
|
|
"origin".to_string(),
|
|
"guardian-approval-mvp".to_string(),
|
|
],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the reviewed docs fix to the repo remote.".to_string()),
|
|
tty: false,
|
|
};
|
|
|
|
let outcome = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
request,
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("Sandbox denied outbound git push to github.com.".to_string()),
|
|
},
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
let (GuardianReviewOutcome::Completed(assessment), metadata) = outcome else {
|
|
panic!("expected guardian assessment");
|
|
};
|
|
let guardian_thread_id = metadata
|
|
.guardian_thread_id
|
|
.as_deref()
|
|
.expect("guardian thread id");
|
|
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert_ne!(guardian_thread_id, session.thread_id.to_string());
|
|
ThreadId::from_string(guardian_thread_id).expect("guardian thread id should be a valid UUID");
|
|
assert!(matches!(
|
|
metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
|
|
));
|
|
let request = request_log.single_request();
|
|
let request_body = request.body_json();
|
|
assert!(
|
|
request_body.get("tools").is_none(),
|
|
"guardian request should use Responses Lite tool input"
|
|
);
|
|
let guardian_tools = request_body["input"]
|
|
.as_array()
|
|
.and_then(|input| input.first())
|
|
.filter(|item| item["type"] == "additional_tools")
|
|
.and_then(|item| item["tools"].as_array())
|
|
.and_then(|tools| {
|
|
tools
|
|
.iter()
|
|
.find(|tool| tool["type"] == "namespace" && tool["name"] == "functions")
|
|
})
|
|
.and_then(|namespace| namespace["tools"].as_array())
|
|
.expect("guardian request functions namespace");
|
|
let mut guardian_tool_names = guardian_tools
|
|
.iter()
|
|
.map(|tool| tool["name"].as_str().expect("guardian code-mode tool name"))
|
|
.collect::<Vec<_>>();
|
|
guardian_tool_names.sort_unstable();
|
|
assert_eq!(guardian_tool_names, vec!["exec", "wait"]);
|
|
|
|
let guardian_exec_description = guardian_tools
|
|
.iter()
|
|
.find(|tool| tool["name"] == "exec")
|
|
.and_then(|tool| tool["description"].as_str())
|
|
.expect("guardian code-mode exec description");
|
|
let mut guardian_nested_tool_names = guardian_exec_description
|
|
.lines()
|
|
.filter_map(|line| line.strip_prefix("### `"))
|
|
.filter_map(|line| line.strip_suffix('`'))
|
|
.collect::<Vec<_>>();
|
|
guardian_nested_tool_names.sort_unstable();
|
|
assert_eq!(
|
|
guardian_nested_tool_names,
|
|
vec!["exec_command", "view_image", "write_stdin"]
|
|
);
|
|
let guardian_user_text = request.message_input_texts("user").join("\n");
|
|
assert!(
|
|
guardian_user_text.contains(&format!("${GUARDIAN_SKILL_NAME}")),
|
|
"guardian request should contain the untrusted skill mention from the parent transcript"
|
|
);
|
|
assert!(
|
|
!request.body_contains_text(GUARDIAN_SKILL_BODY_PROBE),
|
|
"guardian request should not inject a skill body from its generated review prompt"
|
|
);
|
|
assert!(
|
|
!request.body_contains_text(GUARDIAN_MEMORY_CONTEXT_PROBE),
|
|
"guardian request should not include memory context"
|
|
);
|
|
assert_eq!(
|
|
request_body.pointer("/text/format/strict"),
|
|
Some(&serde_json::json!(false))
|
|
);
|
|
assert_eq!(
|
|
request_body.pointer("/text/format/schema"),
|
|
Some(&serde_json::json!({
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"properties": {
|
|
"risk_level": {
|
|
"type": "string",
|
|
"enum": ["low", "medium", "high", "critical"]
|
|
},
|
|
"user_authorization": {
|
|
"type": "string",
|
|
"enum": ["unknown", "low", "medium", "high"]
|
|
},
|
|
"outcome": {
|
|
"type": "string",
|
|
"enum": ["allow", "deny"]
|
|
},
|
|
"rationale": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"required": ["outcome"]
|
|
}))
|
|
);
|
|
let request_model = request_body
|
|
.get("model")
|
|
.and_then(|value| value.as_str())
|
|
.expect("guardian request should include a model");
|
|
let request_reasoning_effort = request_body
|
|
.get("reasoning")
|
|
.and_then(|reasoning| reasoning.get("effort"))
|
|
.and_then(|value| value.as_str());
|
|
assert_eq!(metadata.guardian_model.as_deref(), Some(request_model));
|
|
assert_eq!(
|
|
metadata.guardian_reasoning_effort.as_deref(),
|
|
request_reasoning_effort
|
|
);
|
|
assert_eq!(metadata.had_prior_review_context, Some(false));
|
|
assert!(
|
|
metadata.time_to_first_token_ms.is_some(),
|
|
"guardian review metadata should capture TTFT when the nested turn completes"
|
|
);
|
|
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("snapshots");
|
|
settings.set_prepend_module_to_snapshot(false);
|
|
settings.bind(|| {
|
|
assert_snapshot!(
|
|
"codex_core__guardian__tests__guardian_review_request_layout",
|
|
normalize_guardian_snapshot_paths(context_snapshot::format_labeled_requests_snapshot(
|
|
"Guardian review request layout",
|
|
&[("Guardian Review Request", &request)],
|
|
&guardian_snapshot_options(),
|
|
))
|
|
);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Result<()> {
|
|
let (session, _) = crate::session::tests::make_session_and_context().await;
|
|
let prompt = build_guardian_prompt_items(
|
|
&session,
|
|
/*retry_reason*/ None,
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "status".to_string()],
|
|
cwd: test_path_buf("/repo").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: None,
|
|
tty: false,
|
|
},
|
|
GuardianPromptMode::Full,
|
|
)
|
|
.await?;
|
|
let prompt_text = prompt
|
|
.items
|
|
.into_iter()
|
|
.map(|item| match item {
|
|
codex_protocol::user_input::UserInput::Text { text, .. } => text,
|
|
codex_protocol::user_input::UserInput::Image { .. } => String::new(),
|
|
_ => String::new(),
|
|
})
|
|
.collect::<String>();
|
|
|
|
assert!(
|
|
prompt_text.contains(&format!(
|
|
">>> TRANSCRIPT END\nReviewed Codex session id: {}\n",
|
|
session.thread_id
|
|
)),
|
|
"guardian prompt should expose the parent session id immediately after the transcript end"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let first_rationale = "first guardian rationale from the prior review";
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-1"),
|
|
ev_assistant_message(
|
|
"msg-guardian-1",
|
|
&format!(
|
|
"{{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"{first_rationale}\"}}"
|
|
),
|
|
),
|
|
ev_completed("resp-guardian-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-2"),
|
|
ev_assistant_message(
|
|
"msg-guardian-2",
|
|
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"second guardian rationale\"}",
|
|
),
|
|
ev_completed("resp-guardian-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-3"),
|
|
ev_assistant_message(
|
|
"msg-guardian-3",
|
|
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"third guardian rationale\"}",
|
|
),
|
|
ev_completed("resp-guardian-3"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-4"),
|
|
ev_assistant_message(
|
|
"msg-guardian-4",
|
|
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"fourth guardian rationale\"}",
|
|
),
|
|
ev_completed("resp-guardian-4"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let (session, mut turn) = guardian_test_session_and_turn(&server).await;
|
|
let mut config = (*turn.config).clone();
|
|
config
|
|
.features
|
|
.enable(Feature::GuardianReuseParentCompaction)
|
|
.expect("Guardian parent-compaction reuse should be configurable");
|
|
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be unique");
|
|
update_turn_settings_for_test(turn_mut, |settings| {
|
|
Arc::make_mut(&mut settings.model_info).auto_review_model_override =
|
|
Some("codex-auto-review".to_string());
|
|
});
|
|
turn_mut.config = Arc::new(config);
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let first_request = GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the first docs fix.".to_string()),
|
|
tty: false,
|
|
};
|
|
let first_outcome = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
first_request,
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("First retry reason".to_string()),
|
|
},
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
session
|
|
.record_conversation_items(
|
|
turn.as_ref(),
|
|
turn.model_info(),
|
|
&[
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Please push the second docs fix too.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "I need approval for the second docs fix.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
],
|
|
)
|
|
.await;
|
|
let second_request = GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-2".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec![
|
|
"git".to_string(),
|
|
"push".to_string(),
|
|
"--force-with-lease".to_string(),
|
|
],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the second docs fix.".to_string()),
|
|
tty: false,
|
|
};
|
|
let second_outcome = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
second_request,
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("Second retry reason".to_string()),
|
|
},
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
let committed_rollout_items = session
|
|
.guardian_review_session()
|
|
.committed_fork_rollout_items_for_test()
|
|
.await
|
|
.expect("committed guardian fork snapshot");
|
|
assert_eq!(
|
|
committed_rollout_items
|
|
.iter()
|
|
.filter(|item| rollout_item_contains_message_text(
|
|
item,
|
|
"Use prior reviews as context, not binding precedent."
|
|
))
|
|
.count(),
|
|
1,
|
|
"follow-up reminder should be persisted for guardian forks"
|
|
);
|
|
session
|
|
.replace_history(
|
|
vec![
|
|
ResponseItem::Compaction {
|
|
id: Some(codex_protocol::ResponseItemId::from_server(
|
|
"cmp_guardian_parent_summary".to_string(),
|
|
)),
|
|
encrypted_content: "encrypted guardian parent summary".to_string(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Please push the third docs fix too.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "I need approval for the third docs fix.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
],
|
|
/*reference_context_item*/ None,
|
|
)
|
|
.await;
|
|
let third_request = GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-3".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the third docs fix.".to_string()),
|
|
tty: false,
|
|
};
|
|
let third_outcome = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
third_request,
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("Third retry reason".to_string()),
|
|
},
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
session
|
|
.replace_history(
|
|
vec![ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Please review after a summary-free context reset.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}],
|
|
/*reference_context_item*/ None,
|
|
)
|
|
.await;
|
|
let fourth_outcome = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
guardian_exec_command_request("shell-4"),
|
|
ApprovalRequestReasons::default(),
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
|
|
let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else {
|
|
panic!("expected first guardian assessment");
|
|
};
|
|
let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome
|
|
else {
|
|
panic!("expected second guardian assessment");
|
|
};
|
|
let (GuardianReviewOutcome::Completed(third_assessment), third_metadata) = third_outcome else {
|
|
panic!("expected third guardian assessment");
|
|
};
|
|
let (GuardianReviewOutcome::Completed(fourth_assessment), fourth_metadata) = fourth_outcome
|
|
else {
|
|
panic!("expected fourth guardian assessment");
|
|
};
|
|
assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert_eq!(fourth_assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert!(matches!(
|
|
first_metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
|
|
));
|
|
assert!(matches!(
|
|
second_metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
|
));
|
|
assert!(matches!(
|
|
third_metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
|
|
));
|
|
assert!(matches!(
|
|
fourth_metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
|
));
|
|
ThreadId::from_string(
|
|
first_metadata
|
|
.guardian_thread_id
|
|
.as_deref()
|
|
.expect("first guardian thread id"),
|
|
)
|
|
.expect("first guardian thread id should be a valid UUID");
|
|
ThreadId::from_string(
|
|
second_metadata
|
|
.guardian_thread_id
|
|
.as_deref()
|
|
.expect("second guardian thread id"),
|
|
)
|
|
.expect("second guardian thread id should be a valid UUID");
|
|
ThreadId::from_string(
|
|
third_metadata
|
|
.guardian_thread_id
|
|
.as_deref()
|
|
.expect("third guardian thread id"),
|
|
)
|
|
.expect("third guardian thread id should be a valid UUID");
|
|
assert_eq!(first_metadata.had_prior_review_context, Some(false));
|
|
assert_eq!(second_metadata.had_prior_review_context, Some(true));
|
|
assert_eq!(third_metadata.had_prior_review_context, Some(false));
|
|
assert_eq!(fourth_metadata.had_prior_review_context, Some(true));
|
|
assert_eq!(
|
|
first_metadata.guardian_thread_id,
|
|
second_metadata.guardian_thread_id
|
|
);
|
|
assert_ne!(
|
|
second_metadata.guardian_thread_id,
|
|
third_metadata.guardian_thread_id
|
|
);
|
|
assert_eq!(
|
|
third_metadata.guardian_thread_id,
|
|
fourth_metadata.guardian_thread_id
|
|
);
|
|
|
|
let requests = request_log.requests();
|
|
assert_eq!(requests.len(), 4);
|
|
|
|
let first_body = requests[0].body_json();
|
|
let second_body = requests[1].body_json();
|
|
let third_body = requests[2].body_json();
|
|
let fourth_body = requests[3].body_json();
|
|
let third_input = third_body["input"]
|
|
.as_array()
|
|
.expect("guardian review should include input items");
|
|
assert!(third_input.iter().any(|item| {
|
|
item["type"] == "compaction"
|
|
&& item["id"] == "cmp_guardian_parent_summary"
|
|
&& item["encrypted_content"] == "encrypted guardian parent summary"
|
|
}));
|
|
assert_eq!(
|
|
first_body["prompt_cache_key"],
|
|
second_body["prompt_cache_key"]
|
|
);
|
|
assert_eq!(
|
|
second_body["prompt_cache_key"],
|
|
third_body["prompt_cache_key"]
|
|
);
|
|
assert_eq!(
|
|
third_body["prompt_cache_key"],
|
|
fourth_body["prompt_cache_key"]
|
|
);
|
|
assert!(fourth_body.to_string().contains("third guardian rationale"));
|
|
assert!(
|
|
second_body.to_string().contains(concat!(
|
|
"Use prior reviews as context, not binding precedent. ",
|
|
"Follow the Workspace Policy. ",
|
|
"If the user explicitly approves a previously rejected action after being ",
|
|
"informed of the concrete risks, set outcome to \\\"allow\\\" unless the policy ",
|
|
"explicitly disallows user overwrites in such cases."
|
|
)),
|
|
"follow-up guardian request should include the follow-up reminder"
|
|
);
|
|
assert!(
|
|
second_body.to_string().contains(first_rationale),
|
|
"guardian session should append earlier reviews into the follow-up request"
|
|
);
|
|
assert_eq!(
|
|
third_body
|
|
.to_string()
|
|
.matches("Use prior reviews as context, not binding precedent.")
|
|
.count(),
|
|
0,
|
|
"a fresh guardian session should not inherit the follow-up reminder"
|
|
);
|
|
let third_user_message = requests[2]
|
|
.message_input_text_groups("user")
|
|
.last()
|
|
.expect("fresh guardian user message")
|
|
.join("");
|
|
assert!(third_user_message.contains(">>> TRANSCRIPT START\n"));
|
|
assert!(third_user_message.contains("Please push the third docs fix too."));
|
|
assert!(!third_body.to_string().contains(first_rationale));
|
|
let second_user_message = requests[1]
|
|
.message_input_text_groups("user")
|
|
.last()
|
|
.expect("follow-up guardian user message")
|
|
.join("");
|
|
assert!(second_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
|
|
assert!(second_user_message.contains("[5] user: Please push the second docs fix too."));
|
|
assert!(
|
|
second_user_message.contains("[6] assistant: I need approval for the second docs fix.")
|
|
);
|
|
assert!(!second_user_message.contains("[1] user: Please check the repo visibility"));
|
|
|
|
let mut settings = Settings::clone_current();
|
|
settings.set_snapshot_path("snapshots");
|
|
settings.set_prepend_module_to_snapshot(false);
|
|
settings.bind(|| {
|
|
assert_snapshot!(
|
|
"codex_core__guardian__tests__guardian_followup_review_request_layout",
|
|
format!(
|
|
"{}\n\nshared_prompt_cache_key: {}\nfollowup_contains_first_rationale: {}",
|
|
normalize_guardian_snapshot_paths(
|
|
context_snapshot::format_labeled_requests_snapshot(
|
|
"Guardian follow-up review request layout",
|
|
&[
|
|
("Initial Guardian Review Request", &requests[0]),
|
|
("Follow-up Guardian Review Request", &requests[1]),
|
|
],
|
|
&guardian_snapshot_options(),
|
|
)
|
|
),
|
|
first_body["prompt_cache_key"] == second_body["prompt_cache_key"],
|
|
second_body.to_string().contains(first_rationale),
|
|
)
|
|
);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-1"),
|
|
ev_assistant_message(
|
|
"msg-guardian-1",
|
|
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"first guardian rationale\"}",
|
|
),
|
|
ev_completed("resp-guardian-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-guardian-2"),
|
|
ev_assistant_message(
|
|
"msg-guardian-2",
|
|
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"second guardian rationale\"}",
|
|
),
|
|
ev_completed("resp-guardian-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
|
let first_outcome = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the first docs fix.".to_string()),
|
|
tty: false,
|
|
},
|
|
ApprovalRequestReasons::default(),
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else {
|
|
panic!("expected first guardian assessment");
|
|
};
|
|
assert_eq!(first_assessment.rationale, "first guardian rationale");
|
|
assert!(matches!(
|
|
first_metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
|
|
));
|
|
|
|
session
|
|
.guardian_review_session()
|
|
.send_trunk_event_raw_for_test(Event {
|
|
id: "stale-turn".to_string(),
|
|
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id: "stale-turn".to_string(),
|
|
started_at: None,
|
|
last_agent_message: Some(
|
|
"{\"risk_level\":\"high\",\"user_authorization\":\"low\",\"outcome\":\"deny\",\"rationale\":\"stale guardian rationale\"}"
|
|
.to_string(),
|
|
),
|
|
error: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
time_to_first_token_ms: Some(1),
|
|
}),
|
|
})
|
|
.await;
|
|
|
|
let second_outcome = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-2".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the second docs fix.".to_string()),
|
|
tty: false,
|
|
},
|
|
ApprovalRequestReasons::default(),
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 1,
|
|
)
|
|
.await;
|
|
let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome
|
|
else {
|
|
panic!("expected second guardian assessment");
|
|
};
|
|
assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert_eq!(second_assessment.rationale, "second guardian rationale");
|
|
assert!(matches!(
|
|
second_metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
|
));
|
|
|
|
assert_eq!(
|
|
request_log.requests().len(),
|
|
2,
|
|
"the reused trunk should wait for the real follow-up review"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let error_message =
|
|
"Item 'rs_test' of type 'reasoning' was provided without its required following item.";
|
|
let request_log = mount_response_sequence(
|
|
&server,
|
|
vec![
|
|
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
|
|
"error": {
|
|
"message": error_message,
|
|
"type": "invalid_request_error",
|
|
"param": "input"
|
|
}
|
|
})),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let (mut session, mut turn, rx) =
|
|
crate::session::tests::make_session_and_context_with_rx().await;
|
|
let mut config = (*turn.config).clone();
|
|
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
|
let config = Arc::new(config);
|
|
let models_manager = test_support::models_manager_with_provider(
|
|
config.codex_home.to_path_buf(),
|
|
Arc::clone(&session.services.auth_manager),
|
|
config.model_provider.clone(),
|
|
);
|
|
Arc::get_mut(&mut session)
|
|
.expect("session should be uniquely owned")
|
|
.services
|
|
.models_manager = models_manager;
|
|
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
|
|
turn_mut.config = Arc::clone(&config);
|
|
turn_mut.provider =
|
|
create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone());
|
|
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let decision = review_approval_request(
|
|
&session,
|
|
&turn,
|
|
"review-shell-guardian-error".to_string(),
|
|
GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-guardian-error".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Need to push the reviewed docs fix.".to_string()),
|
|
tty: false,
|
|
},
|
|
ApprovalRequestReasons::default(),
|
|
)
|
|
.await;
|
|
|
|
let ReviewDecision::Denied { rejection } = decision else {
|
|
panic!("guardian error should deny the approval");
|
|
};
|
|
assert_eq!(request_log.requests().len(), 1);
|
|
|
|
let mut warnings = Vec::new();
|
|
let mut denial_rationales = Vec::new();
|
|
while let Ok(event) = rx.try_recv() {
|
|
match event.msg {
|
|
EventMsg::GuardianWarning(event) => warnings.push(event.message),
|
|
EventMsg::GuardianAssessment(event)
|
|
if event.status == GuardianAssessmentStatus::Denied =>
|
|
{
|
|
denial_rationales.push(event.rationale)
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
warnings
|
|
.iter()
|
|
.any(|message| message.contains(error_message)),
|
|
"warning should include the underlying responses api error"
|
|
);
|
|
assert!(
|
|
denial_rationales
|
|
.iter()
|
|
.flatten()
|
|
.any(|message| message.contains(error_message)),
|
|
"denial rationale should include the underlying responses api error"
|
|
);
|
|
assert!(
|
|
denial_rationales.iter().flatten().all(|message| {
|
|
!message.contains("guardian review completed without an assessment payload")
|
|
}),
|
|
"denial rationale should not fall back to the generic missing payload error"
|
|
);
|
|
assert!(
|
|
rejection.contains("Reason: Automatic approval review failed:")
|
|
&& rejection.contains(error_message),
|
|
"rejection message should include guardian rationale: {rejection}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_retries_transient_session_failure_then_approves() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let approval = serde_json::json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "retry succeeded",
|
|
})
|
|
.to_string();
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse_failed(
|
|
"resp-session-failure",
|
|
"server_is_overloaded",
|
|
"temporary reviewer overload",
|
|
),
|
|
sse(vec![
|
|
ev_response_created("resp-approved"),
|
|
ev_assistant_message("msg-approved", &approval),
|
|
ev_completed("resp-approved"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let (outcome, metadata) = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
guardian_exec_command_request("shell-session-retry"),
|
|
ApprovalRequestReasons::default(),
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 3,
|
|
)
|
|
.await;
|
|
|
|
let GuardianReviewOutcome::Completed(assessment) = outcome else {
|
|
panic!("expected guardian assessment");
|
|
};
|
|
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert_eq!(assessment.rationale, "retry succeeded");
|
|
assert_eq!(metadata.attempt_count, 2);
|
|
assert!(matches!(
|
|
metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
|
));
|
|
assert_eq!(request_log.requests().len(), 2);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_does_not_retry_missing_assessment_payload() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![sse(vec![
|
|
ev_response_created("resp-missing-assessment"),
|
|
ev_completed("resp-missing-assessment"),
|
|
])],
|
|
)
|
|
.await;
|
|
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let decision = review_approval_request(
|
|
&session,
|
|
&turn,
|
|
"review-missing-assessment".to_string(),
|
|
guardian_exec_command_request("shell-missing-assessment"),
|
|
ApprovalRequestReasons::default(),
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(decision, ReviewDecision::Denied { .. }));
|
|
assert_eq!(request_log.requests().len(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let approval = serde_json::json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "retry succeeded",
|
|
})
|
|
.to_string();
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-parse-failure-1"),
|
|
ev_assistant_message("msg-parse-failure-1", "not valid guardian json"),
|
|
ev_completed("resp-parse-failure-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parse-failure-2"),
|
|
ev_assistant_message("msg-parse-failure-2", "still not valid guardian json"),
|
|
ev_completed("resp-parse-failure-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-approved"),
|
|
ev_assistant_message("msg-approved", &approval),
|
|
ev_completed("resp-approved"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let (outcome, metadata) = run_guardian_review_session_for_test(
|
|
Arc::clone(&session),
|
|
Arc::clone(&turn),
|
|
guardian_exec_command_request("shell-parse-retry"),
|
|
ApprovalRequestReasons::default(),
|
|
guardian_output_schema(),
|
|
/*external_cancel*/ None,
|
|
/*max_attempts*/ 3,
|
|
)
|
|
.await;
|
|
|
|
let GuardianReviewOutcome::Completed(assessment) = outcome else {
|
|
panic!("expected guardian assessment");
|
|
};
|
|
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
|
|
assert_eq!(assessment.rationale, "retry succeeded");
|
|
assert_eq!(metadata.attempt_count, 3);
|
|
assert!(matches!(
|
|
metadata.guardian_session_kind,
|
|
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
|
));
|
|
assert_eq!(request_log.requests().len(), 3);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_exhausts_three_failures_with_one_terminal_event() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-parse-failure-1"),
|
|
ev_assistant_message("msg-parse-failure-1", "invalid one"),
|
|
ev_completed("resp-parse-failure-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parse-failure-2"),
|
|
ev_assistant_message("msg-parse-failure-2", "invalid two"),
|
|
ev_completed("resp-parse-failure-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-parse-failure-3"),
|
|
ev_assistant_message("msg-parse-failure-3", "invalid three"),
|
|
ev_completed("resp-parse-failure-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let (session, turn, rx) = guardian_test_session_turn_and_rx(&server).await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let decision = review_approval_request(
|
|
&session,
|
|
&turn,
|
|
"review-exhausted-retry".to_string(),
|
|
guardian_exec_command_request("shell-exhausted-retry"),
|
|
ApprovalRequestReasons::default(),
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(decision, ReviewDecision::Denied { .. }));
|
|
assert_eq!(request_log.requests().len(), 3);
|
|
let mut statuses = Vec::new();
|
|
while let Ok(event) = rx.try_recv() {
|
|
if let EventMsg::GuardianAssessment(event) = event.msg {
|
|
statuses.push(event.status);
|
|
}
|
|
}
|
|
assert_eq!(
|
|
statuses,
|
|
vec![
|
|
GuardianAssessmentStatus::InProgress,
|
|
GuardianAssessmentStatus::Denied,
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let denial = serde_json::json!({
|
|
"risk_level": "high",
|
|
"user_authorization": "unknown",
|
|
"outcome": "deny",
|
|
"rationale": "unsafe",
|
|
})
|
|
.to_string();
|
|
let request_log = mount_sse_sequence(
|
|
&server,
|
|
vec![sse(vec![
|
|
ev_response_created("resp-denied"),
|
|
ev_assistant_message("msg-denied", &denial),
|
|
ev_completed("resp-denied"),
|
|
])],
|
|
)
|
|
.await;
|
|
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let decision = review_approval_request(
|
|
&session,
|
|
&turn,
|
|
"review-valid-denial".to_string(),
|
|
guardian_exec_command_request("shell-valid-denial"),
|
|
ApprovalRequestReasons::default(),
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(decision, ReviewDecision::Denied { .. }));
|
|
assert_eq!(request_log.requests().len(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn escalated_retry_bypasses_extension_approval_and_runs_guardian() -> anyhow::Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
struct AutoApprovingReviewContributor;
|
|
|
|
impl codex_extension_api::ApprovalReviewContributor for AutoApprovingReviewContributor {
|
|
fn decide<'a>(
|
|
&'a self,
|
|
_input: &'a codex_extension_api::ApprovalDecisionInput<'_>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Option<codex_extension_api::ApprovalDecision>>
|
|
{
|
|
Box::pin(async { Some(codex_extension_api::ApprovalDecision::Allow) })
|
|
}
|
|
}
|
|
|
|
let server = start_mock_server().await;
|
|
let denial = serde_json::json!({
|
|
"risk_level": "high",
|
|
"user_authorization": "unknown",
|
|
"outcome": "deny",
|
|
"rationale": "The original attempt was blocked by the sandbox.",
|
|
})
|
|
.to_string();
|
|
let request_log = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-escalated-retry"),
|
|
ev_assistant_message("msg-escalated-retry", &denial),
|
|
ev_completed("resp-escalated-retry"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let (mut session, turn) = guardian_test_session_and_turn(&server).await;
|
|
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new();
|
|
extensions.approval_review_contributor(Arc::new(AutoApprovingReviewContributor));
|
|
Arc::get_mut(&mut session)
|
|
.expect("session should be uniquely owned")
|
|
.services
|
|
.extensions = Arc::new(extensions.build());
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let retry_reason = "The sandbox blocked the original command.";
|
|
let decision = review_approval_request(
|
|
&session,
|
|
&turn,
|
|
"review-escalated-retry".to_string(),
|
|
guardian_exec_command_request("shell-escalated-retry"),
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some(retry_reason.to_string()),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(decision, ReviewDecision::Denied { .. }));
|
|
assert!(
|
|
request_log
|
|
.single_request()
|
|
.body_contains_text(retry_reason)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> anyhow::Result<()>
|
|
{
|
|
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
|
|
|
|
let handle = std::thread::Builder::new()
|
|
.name("guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history".to_string())
|
|
.stack_size(TEST_STACK_SIZE_BYTES)
|
|
.spawn(|| -> anyhow::Result<()> {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
runtime.block_on(Box::pin(async {
|
|
let first_assessment = serde_json::json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "first guardian rationale",
|
|
})
|
|
.to_string();
|
|
let second_assessment = serde_json::json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "second guardian rationale",
|
|
})
|
|
.to_string();
|
|
let third_assessment = serde_json::json!({
|
|
"risk_level": "low",
|
|
"user_authorization": "high",
|
|
"outcome": "allow",
|
|
"rationale": "third guardian rationale",
|
|
})
|
|
.to_string();
|
|
let (gate_tx, gate_rx) = tokio::sync::oneshot::channel();
|
|
let (server, _) = start_streaming_sse_server(vec![
|
|
vec![StreamingSseChunk {
|
|
gate: None,
|
|
body: sse(vec![
|
|
ev_response_created("resp-guardian-1"),
|
|
ev_assistant_message("msg-guardian-1", &first_assessment),
|
|
ev_completed("resp-guardian-1"),
|
|
]),
|
|
}],
|
|
vec![
|
|
StreamingSseChunk {
|
|
gate: None,
|
|
body: sse(vec![ev_response_created("resp-guardian-2")]),
|
|
},
|
|
StreamingSseChunk {
|
|
gate: Some(gate_rx),
|
|
body: sse(vec![
|
|
ev_assistant_message("msg-guardian-2", &second_assessment),
|
|
ev_completed("resp-guardian-2"),
|
|
]),
|
|
},
|
|
],
|
|
vec![StreamingSseChunk {
|
|
gate: None,
|
|
body: sse(vec![
|
|
ev_response_created("resp-guardian-3"),
|
|
ev_assistant_message("msg-guardian-3", "not valid guardian json"),
|
|
ev_completed("resp-guardian-3"),
|
|
]),
|
|
}],
|
|
vec![StreamingSseChunk {
|
|
gate: None,
|
|
body: sse(vec![
|
|
ev_response_created("resp-guardian-4"),
|
|
ev_assistant_message("msg-guardian-4", &third_assessment),
|
|
ev_completed("resp-guardian-4"),
|
|
]),
|
|
}],
|
|
])
|
|
.await;
|
|
|
|
let (mut session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await;
|
|
// Isolate feedback from other tests using the fixed parent session ID.
|
|
Arc::get_mut(&mut session)
|
|
.expect("session should be uniquely owned")
|
|
.thread_id = ThreadId::new();
|
|
turn.turn_metadata_state
|
|
.set_parent_turn_id("upstream-parent-turn".to_string());
|
|
turn.turn_metadata_state
|
|
.set_root_turn_id("causal-root-turn".to_string());
|
|
seed_guardian_parent_history(&session, &turn).await;
|
|
|
|
let initial_request = GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-guardian-1".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "status".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Inspect repo state before proceeding.".to_string()),
|
|
tty: false,
|
|
};
|
|
assert_eq!(
|
|
review_approval_request(
|
|
&session,
|
|
&turn,
|
|
"review-shell-guardian-1".to_string(),
|
|
initial_request,
|
|
ApprovalRequestReasons::default()
|
|
)
|
|
.await,
|
|
ReviewDecision::Approved
|
|
);
|
|
session
|
|
.record_conversation_items(
|
|
turn.as_ref(), turn.model_info(),
|
|
&[
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Please inspect pending changes before pushing.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "I need approval to run git diff.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,},
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let second_request = GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-guardian-2".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "diff".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Inspect pending changes before proceeding.".to_string()),
|
|
tty: false,
|
|
};
|
|
let third_request = GuardianApprovalRequest::ExecCommand {
|
|
id: "shell-guardian-3".to_string(),
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
command: vec!["git".to_string(), "push".to_string()],
|
|
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
|
|
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
|
|
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
|
|
additional_permissions: None,
|
|
justification: Some("Inspect whether pushing is safe before proceeding.".to_string()),
|
|
tty: false,
|
|
};
|
|
|
|
let session_for_second = Arc::clone(&session);
|
|
let turn_for_second = Arc::clone(&turn);
|
|
let mut second_review = tokio::spawn(async move {
|
|
review_approval_request(
|
|
&session_for_second,
|
|
&turn_for_second,
|
|
"review-shell-guardian-2".to_string(),
|
|
second_request,
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("trunk follow-up".to_string()),
|
|
},
|
|
)
|
|
.await
|
|
});
|
|
|
|
let second_request_observed = tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
if server.requests().await.len() >= 2 {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await;
|
|
assert!(
|
|
second_request_observed.is_ok(),
|
|
"second guardian request was not observed"
|
|
);
|
|
session
|
|
.record_conversation_items(
|
|
turn.as_ref(), turn.model_info(),
|
|
&[
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "Now inspect whether pushing is safe.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,},
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: "I need approval to push after the diff check.".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,},
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let third_decision = review_approval_request(
|
|
&session,
|
|
&turn,
|
|
"review-shell-guardian-3".to_string(),
|
|
third_request,
|
|
ApprovalRequestReasons {
|
|
approval: None,
|
|
retry: Some("parallel follow-up".to_string()),
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(third_decision, ReviewDecision::Approved);
|
|
let requests = server.requests().await;
|
|
assert_eq!(requests.len(), 4);
|
|
let first_request_body = serde_json::from_slice::<serde_json::Value>(&requests[0])?;
|
|
let second_request_body = serde_json::from_slice::<serde_json::Value>(&requests[1])?;
|
|
let failed_ephemeral_request_body =
|
|
serde_json::from_slice::<serde_json::Value>(&requests[2])?;
|
|
let retried_ephemeral_request_body =
|
|
serde_json::from_slice::<serde_json::Value>(&requests[3])?;
|
|
let mut reviewer_turn_ids = std::collections::BTreeSet::new();
|
|
for (body, expected_root) in [
|
|
(&first_request_body, Some("causal-root-turn")),
|
|
(&second_request_body, Some("causal-root-turn")),
|
|
(&failed_ephemeral_request_body, Some("causal-root-turn")),
|
|
(&retried_ephemeral_request_body, Some("causal-root-turn")),
|
|
] {
|
|
assert_parent_turn(body, Some(turn.sub_id.as_str()))?;
|
|
assert_root_turn(body, expected_root)?;
|
|
assert_ne!(body["client_metadata"]["turn_id"], turn.sub_id);
|
|
reviewer_turn_ids.insert(
|
|
body["client_metadata"]["turn_id"].as_str().expect("reviewer turn id")
|
|
);
|
|
}
|
|
assert_eq!(reviewer_turn_ids.len(), 4);
|
|
assert_eq!(
|
|
second_request_body["prompt_cache_key"],
|
|
failed_ephemeral_request_body["prompt_cache_key"],
|
|
"forked guardian review should reuse the trunk guardian prompt cache key"
|
|
);
|
|
assert_eq!(
|
|
failed_ephemeral_request_body["prompt_cache_key"],
|
|
retried_ephemeral_request_body["prompt_cache_key"],
|
|
"retried ephemeral review should preserve the guardian prompt cache key"
|
|
);
|
|
let third_request_body_text = retried_ephemeral_request_body.to_string();
|
|
assert!(
|
|
third_request_body_text.contains("first guardian rationale"),
|
|
"forked guardian review should include the last committed trunk assessment"
|
|
);
|
|
let third_user_message = last_user_message_text_from_body(&retried_ephemeral_request_body);
|
|
assert!(third_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
|
|
assert!(
|
|
third_user_message.contains("[5] user: Please inspect pending changes before pushing.")
|
|
);
|
|
assert!(third_user_message.contains("[7] user: Now inspect whether pushing is safe."));
|
|
assert!(!third_user_message.contains("[1] user: Please check the repo visibility"));
|
|
assert!(
|
|
!third_request_body_text.contains("second guardian rationale"),
|
|
"forked guardian review should not include the still in-flight trunk assessment"
|
|
);
|
|
assert!(
|
|
tokio::time::timeout(Duration::from_millis(100), &mut second_review)
|
|
.await
|
|
.is_err(),
|
|
"the trunk guardian review should still be blocked on its gated response"
|
|
);
|
|
|
|
gate_tx
|
|
.send(())
|
|
.expect("second guardian review gate should still be open");
|
|
assert_eq!(second_review.await?, ReviewDecision::Approved);
|
|
let feedback = codex_feedback::guardian_review_failures(&[session.thread_id()])
|
|
.attachment
|
|
.expect("failed ephemeral review survives cleanup and subsequent allowed reviews");
|
|
let record: serde_json::Value = serde_json::from_slice(&feedback.buffer)?;
|
|
assert_eq!(
|
|
serde_json::json!({
|
|
"reviewed_thread_id": record["reviewed_thread_id"],
|
|
"reviewed_turn_id": record["reviewed_turn_id"],
|
|
"target_item_id": record["target_item_id"],
|
|
"reviewer_thread_id": record["reviewer_thread_id"],
|
|
"status": record["status"],
|
|
"decision": record["decision"],
|
|
"command": serde_json::from_str::<serde_json::Value>(
|
|
record["action"].as_str().expect("reviewed action")
|
|
)?["command"],
|
|
}),
|
|
serde_json::json!({
|
|
"reviewed_thread_id": session.thread_id(),
|
|
"reviewed_turn_id": turn.sub_id,
|
|
"target_item_id": "shell-guardian-3",
|
|
"reviewer_thread_id": failed_ephemeral_request_body["client_metadata"]["thread_id"],
|
|
"status": "invalid_decision",
|
|
"decision": "not valid guardian json",
|
|
"command": ["git", "push"],
|
|
})
|
|
);
|
|
server.shutdown().await;
|
|
|
|
Ok(())
|
|
}))
|
|
})?;
|
|
|
|
match handle.join() {
|
|
Ok(result) => result,
|
|
Err(_) => Err(anyhow::anyhow!(
|
|
"guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history thread panicked"
|
|
)),
|
|
}
|
|
}
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_preserves_parent_network_proxy() {
|
|
let mut parent_config = test_config().await;
|
|
let network = NetworkProxySpec::from_config_and_constraints(
|
|
NetworkProxyConfig::default(),
|
|
Some(NetworkConstraints {
|
|
enabled: Some(true),
|
|
domains: Some(NetworkDomainPermissionsToml {
|
|
entries: std::collections::BTreeMap::from([(
|
|
"github.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
)]),
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
parent_config.permissions.permission_profile(),
|
|
)
|
|
.expect("network proxy spec");
|
|
parent_config.permissions.network = Some(network.clone());
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"parent-active-model",
|
|
Some(codex_protocol::openai_models::ReasoningEffort::Low),
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert_eq!(guardian_config.permissions.network, Some(network));
|
|
assert_eq!(
|
|
guardian_config.model,
|
|
Some("parent-active-model".to_string())
|
|
);
|
|
assert_eq!(
|
|
guardian_config.model_reasoning_effort,
|
|
Some(codex_protocol::openai_models::ReasoningEffort::Low)
|
|
);
|
|
assert_eq!(
|
|
guardian_config.permissions.approval_policy,
|
|
Constrained::allow_only(AskForApproval::Never)
|
|
);
|
|
assert_eq!(
|
|
guardian_config.permissions.permission_profile(),
|
|
&PermissionProfile::read_only()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_clears_context_overrides_for_distinct_effective_model() {
|
|
let server = start_mock_server().await;
|
|
let (session, mut turn) = guardian_test_session_and_turn(&server).await;
|
|
let mut config = (*turn.config).clone();
|
|
config.model = Some("codex-auto-review".to_string());
|
|
config.model_context_window = Some(900_000);
|
|
config.model_auto_compact_token_limit = Some(600_000);
|
|
Arc::get_mut(&mut turn)
|
|
.expect("turn should be unique")
|
|
.config = Arc::new(config);
|
|
|
|
let guardian_config = guardian_review_session_config(session.as_ref(), turn.as_ref())
|
|
.await
|
|
.expect("guardian config")
|
|
.spawn_config;
|
|
|
|
assert_eq!(
|
|
(
|
|
guardian_config.model_context_window,
|
|
guardian_config.model_auto_compact_token_limit,
|
|
),
|
|
(None, None)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_preserves_context_overrides_for_same_effective_model() {
|
|
let server = start_mock_server().await;
|
|
let (mut session, mut turn) = guardian_test_session_and_turn(&server).await;
|
|
let parent_model = turn.model_info().as_ref().clone();
|
|
let auth_manager = Arc::clone(&session.services.auth_manager);
|
|
Arc::get_mut(&mut session)
|
|
.expect("session should be unique")
|
|
.services
|
|
.models_manager = Arc::new(StaticModelsManager::new(
|
|
Some(auth_manager),
|
|
ModelsResponse {
|
|
models: vec![parent_model],
|
|
},
|
|
));
|
|
let mut config = (*turn.config).clone();
|
|
config.model = Some("stale-parent-model".to_string());
|
|
config.model_context_window = Some(128_000);
|
|
config.model_auto_compact_token_limit = Some(100_000);
|
|
Arc::get_mut(&mut turn)
|
|
.expect("turn should be unique")
|
|
.config = Arc::new(config);
|
|
|
|
let guardian_config = guardian_review_session_config(session.as_ref(), turn.as_ref())
|
|
.await
|
|
.expect("guardian config")
|
|
.spawn_config;
|
|
|
|
assert_eq!(
|
|
(
|
|
guardian_config.model_context_window,
|
|
guardian_config.model_auto_compact_token_limit,
|
|
),
|
|
(Some(128_000), Some(100_000))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_clears_parent_developer_instructions() {
|
|
let mut parent_config = test_config().await;
|
|
parent_config.developer_instructions =
|
|
Some("parent or managed config should not replace guardian policy".to_string());
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert_eq!(guardian_config.developer_instructions, None);
|
|
assert_eq!(
|
|
guardian_config.base_instructions,
|
|
Some(guardian_policy_prompt_with_config_and_template(
|
|
BUNDLED_GUARDIAN_POLICY,
|
|
BUNDLED_GUARDIAN_POLICY_TEMPLATE,
|
|
))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_clears_legacy_notify() {
|
|
let mut parent_config = test_config().await;
|
|
parent_config.notify = Some(vec![
|
|
"/path/to/notify".to_string(),
|
|
"turn-ended".to_string(),
|
|
]);
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert_eq!(guardian_config.notify, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_uses_live_network_proxy_state() {
|
|
let mut parent_config = test_config().await;
|
|
let mut parent_network = NetworkProxyConfig {
|
|
enabled: true,
|
|
..Default::default()
|
|
};
|
|
parent_network.set_allowed_domains(vec!["parent.example".to_string()]);
|
|
parent_config.permissions.network = Some(
|
|
NetworkProxySpec::from_config_and_constraints(
|
|
parent_network,
|
|
/*requirements*/ None,
|
|
parent_config.permissions.permission_profile(),
|
|
)
|
|
.expect("parent network proxy spec"),
|
|
);
|
|
|
|
let mut live_network = NetworkProxyConfig {
|
|
enabled: true,
|
|
..Default::default()
|
|
};
|
|
live_network.set_allowed_domains(vec!["github.com".to_string()]);
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
Some(live_network.clone()),
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert_eq!(
|
|
guardian_config.permissions.network,
|
|
Some(
|
|
NetworkProxySpec::from_config_and_constraints(
|
|
live_network,
|
|
/*requirements*/ None,
|
|
&PermissionProfile::read_only(),
|
|
)
|
|
.expect("live network proxy spec")
|
|
)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_disables_mcp_apps_plugins_memories_and_guardian_v2() {
|
|
let mut parent_config = test_config().await;
|
|
let server: McpServerConfig =
|
|
toml::from_str("command = \"docs-server\"").expect("deserialize MCP server");
|
|
parent_config
|
|
.mcp_servers
|
|
.set(HashMap::from([("docs".to_string(), server)]))
|
|
.expect("parent MCP servers are configurable");
|
|
parent_config
|
|
.features
|
|
.enable(Feature::Apps)
|
|
.expect("apps feature is configurable");
|
|
parent_config
|
|
.features
|
|
.enable(Feature::Plugins)
|
|
.expect("plugins feature is configurable");
|
|
parent_config
|
|
.features
|
|
.enable(Feature::GuardianV2)
|
|
.expect("guardian v2 feature is configurable");
|
|
parent_config.include_apps_instructions = true;
|
|
parent_config.memories.use_memories = true;
|
|
parent_config.memories.dedicated_tools = true;
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert!(guardian_config.mcp_servers.get().is_empty());
|
|
assert!(!guardian_config.features.enabled(Feature::Apps));
|
|
assert!(!guardian_config.features.enabled(Feature::Plugins));
|
|
assert!(!guardian_config.features.enabled(Feature::GuardianV2));
|
|
assert!(!guardian_config.include_apps_instructions);
|
|
assert!(!guardian_config.memories.use_memories);
|
|
assert!(!guardian_config.memories.dedicated_tools);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_allows_pinned_disabled_feature() {
|
|
let mut parent_config = test_config().await;
|
|
parent_config.features = ManagedFeatures::from_configured(
|
|
parent_config.features.get().clone(),
|
|
Some(Sourced {
|
|
value: FeatureRequirementsToml {
|
|
entries: BTreeMap::from([("multi_agent".to_string(), true)]),
|
|
},
|
|
source: RequirementSource::Unknown,
|
|
}),
|
|
)
|
|
.expect("managed features");
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config should continue when a disabled feature is pinned on");
|
|
|
|
assert!(guardian_config.features.enabled(Feature::Collab));
|
|
assert!(guardian_config.mcp_servers.get().is_empty());
|
|
assert!(!guardian_config.include_apps_instructions);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_slug() {
|
|
let mut parent_config = test_config().await;
|
|
parent_config.model = Some("configured-model".to_string());
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert_eq!(guardian_config.model, Some("active-model".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5_4() {
|
|
let mut parent_config = test_config().await;
|
|
parent_config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string();
|
|
parent_config.model_provider =
|
|
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
AMAZON_BEDROCK_GPT_5_4_MODEL_ID,
|
|
Some(ReasoningEffort::Low),
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
let mut expected_model_provider =
|
|
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
|
|
expected_model_provider.request_max_retries = Some(1);
|
|
expected_model_provider.stream_max_retries = Some(1);
|
|
assert_eq!(
|
|
(
|
|
guardian_config.model,
|
|
guardian_config.model_provider_id,
|
|
guardian_config.model_provider,
|
|
),
|
|
(
|
|
Some(AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string()),
|
|
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
|
|
expected_model_provider,
|
|
)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
|
|
let codex_home = tempfile::tempdir().expect("create temp dir");
|
|
let workspace = tempfile::tempdir().expect("create temp dir");
|
|
let config_layer_stack = ConfigLayerStack::new(
|
|
Vec::new(),
|
|
Default::default(),
|
|
codex_config::ConfigRequirementsToml {
|
|
guardian_policy_config: Some(
|
|
" Use the workspace-managed guardian policy. ".to_string(),
|
|
),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.expect("config layer stack");
|
|
let parent_config = Config::load_config_with_layer_stack(
|
|
LOCAL_FS.as_ref(),
|
|
ConfigToml::default(),
|
|
ConfigOverrides {
|
|
cwd: Some(workspace.path().to_path_buf()),
|
|
..Default::default()
|
|
},
|
|
codex_home.abs(),
|
|
config_layer_stack,
|
|
)
|
|
.await
|
|
.expect("load config");
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert_eq!(guardian_config.developer_instructions, None);
|
|
assert_eq!(
|
|
guardian_config.base_instructions,
|
|
Some(guardian_policy_prompt_with_config_and_template(
|
|
"Use the workspace-managed guardian policy.",
|
|
BUNDLED_GUARDIAN_POLICY_TEMPLATE,
|
|
))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override()
|
|
{
|
|
let codex_home = tempfile::tempdir().expect("create temp dir");
|
|
let workspace = tempfile::tempdir().expect("create temp dir");
|
|
let config_layer_stack =
|
|
ConfigLayerStack::new(Vec::new(), Default::default(), Default::default())
|
|
.expect("config layer stack");
|
|
let parent_config = Config::load_config_with_layer_stack(
|
|
LOCAL_FS.as_ref(),
|
|
ConfigToml::default(),
|
|
ConfigOverrides {
|
|
cwd: Some(workspace.path().to_path_buf()),
|
|
..Default::default()
|
|
},
|
|
codex_home.abs(),
|
|
config_layer_stack,
|
|
)
|
|
.await
|
|
.expect("load config");
|
|
|
|
let guardian_config = build_guardian_review_session_config_for_test(
|
|
&parent_config,
|
|
/*live_network_config*/ None,
|
|
"active-model",
|
|
/*reasoning_effort*/ None,
|
|
/*model_messages*/ None,
|
|
)
|
|
.expect("guardian config");
|
|
|
|
assert_eq!(guardian_config.developer_instructions, None);
|
|
assert_eq!(
|
|
guardian_config.base_instructions,
|
|
Some(guardian_policy_prompt_with_config_and_template(
|
|
BUNDLED_GUARDIAN_POLICY,
|
|
BUNDLED_GUARDIAN_POLICY_TEMPLATE,
|
|
))
|
|
);
|
|
}
|
|
|
|
// Keep the existing reviewer tests on the production decision path.
|
|
async fn review_approval_request(
|
|
session: &Arc<Session>,
|
|
context: impl Into<GuardianReviewContext>,
|
|
review_id: String,
|
|
request: GuardianApprovalRequest,
|
|
reasons: ApprovalRequestReasons,
|
|
) -> ReviewDecision {
|
|
super::decide_approval(
|
|
Arc::clone(session),
|
|
context,
|
|
review_id,
|
|
request,
|
|
reasons,
|
|
GuardianReviewOptions {
|
|
require_guardian: true,
|
|
plugin_attribution_override: None,
|
|
approval_request_source: GuardianApprovalRequestSource::MainTurn,
|
|
external_cancel: None,
|
|
require_synchronous_review: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("Guardian should handle the request")
|
|
}
|