Files
codex/codex-rs/app-server/tests/suite/v2/guardian_sync_session_tests.rs
T
Ahmed Ibrahim f046cf35df Scope model catalog caches to the current provider and auth identity (#43906)
## Why

Switching providers or authentication could reuse a previous identity's model catalog, including its default service tier. An in-flight refresh could also overwrite a newer account's catalog after cache storage completed.

## What changed

- Require a matching provider and auth identity for disk and in-memory catalog reuse. Treat legacy entries without an identity as cache misses and fall back to bundled metadata when the in-memory identity no longer matches.
- Recheck identity after fetching and storing a catalog before publishing it.
- Renew cache freshness only when the client version, identity, and ETag match the stored entry.
- Update cache fixtures to include the configured provider and auth identity.

## Testing

Add regression coverage for provider and auth switches, credential rotation, legacy and mismatched cache entries, conditional TTL renewal, and an account switch during cache storage. Verify that API-key requests do not inherit a ChatGPT catalog's default `service_tier`, while explicit tier selection still works.

GitOrigin-RevId: e15d7ffff238d951d7a1c6a68381cbb479994146
2026-09-08 20:37:30 +00:00

324 lines
13 KiB
Rust

//! Exercises reviewer ownership through app-server, including concurrent and inline reviews.
use super::*;
use app_test_support::create_final_assistant_message_sse_response as assistant_response;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_protocol::protocol::SubAgentSource;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use pretty_assertions::assert_eq;
use tokio::sync::oneshot;
const ALLOW: &str = r#"{"outcome":"allow","rationale":"completed seed assessment"}"#;
fn sync_config(responses_url: &str) -> MockResponsesConfig {
MockResponsesConfig::new(responses_url)
.with_provider_config("supports_websockets = false")
.with_approval_policy("on-request")
.with_root_config("approvals_reviewer = \"auto_review\"\nthread_unload_delay_secs = 0")
.with_extra_config("[features.guardianv2]\nenabled = false")
.enable_feature(Feature::GuardianApproval)
.disable_feature(Feature::EnableRequestCompression)
}
fn tool_response(server: &str, tool: &str, calls: &[&str]) -> String {
let mut events = vec![responses::ev_response_created("tools")];
events.extend(calls.iter().map(|call| {
responses::ev_function_call_with_namespace(
call,
&format!("mcp__{server}"),
tool,
&json!({"message": call}).to_string(),
)
}));
events.push(responses::ev_completed("tools"));
responses::sse(events)
}
#[tokio::test]
async fn managed_reviewers_reuse_fork_and_resume_after_parent_shutdown() -> Result<()> {
skip_if_no_network!(Ok(()));
let (continue_parent, parent_gate) = oneshot::channel();
let (finish_first, first_gate) = oneshot::channel();
let (finish_second, second_gate) = oneshot::channel();
let (server, _completions) = start_streaming_sse_server(
vec![
(
tool_response(TEST_SERVER_NAME, TEST_TOOL_NAME, &["seed"]),
None,
),
(assistant_response(ALLOW)?, None),
(
tool_response(TEST_SERVER_NAME, TEST_TOOL_NAME, &["first", "second"]),
Some(parent_gate),
),
(assistant_response(ALLOW)?, Some(first_gate)),
(assistant_response(ALLOW)?, Some(second_gate)),
(assistant_response("Done.")?, None),
(assistant_response("The user authorized the checks.")?, None),
]
.into_iter()
.map(|(body, gate)| vec![StreamingSseChunk { gate, body }])
.collect(),
)
.await;
let (mcp_url, mcp_server) = start_mcp_server(/*sensitive_action*/ None).await?;
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("AGENTS.md"),
"Keep this project private.",
)?;
sync_config(server.uri())
.with_extra_config(&format!(
"[mcp_servers.{TEST_SERVER_NAME}]\nurl = \"{mcp_url}/mcp\"\ndefault_tools_approval_mode = \"prompt\""
))
.write(codex_home.path())?;
let mut app = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(TIMEOUT)
.await?;
let parent = app.start_thread(ThreadStartParams::default()).await?.thread;
let _: TurnStartResponse = app
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: parent.id.clone(),
input: vec![UserInput::Text {
text: "Run the three checks.".to_owned(),
text_elements: Vec::new(),
}],
..Default::default()
},
})
.await?;
// The seed review finished; pause the parent before it requests concurrent approvals.
timeout(TIMEOUT, server.wait_for_request_count(/*count*/ 3)).await?;
let seed: Value = serde_json::from_slice(&server.requests().await[1])?;
let reviewer_id = seed["client_metadata"]["thread_id"]
.as_str()
.expect("reviewer ID")
.to_owned();
let id = app
.send_thread_resume_request(ThreadResumeParams {
thread_id: reviewer_id.clone(),
..Default::default()
})
.await?;
let error = timeout(
TIMEOUT,
app.read_stream_until_error_message(RequestId::Integer(id)),
)
.await??;
assert_eq!(error.error, JSONRPCErrorError {
code: -32600,
message: "cannot resume a live Guardian reviewer; use thread/read to inspect it, or resume after its parent is unloaded".to_owned(),
data: None,
});
// Client removal must leave the cached reviewer available for the next approvals.
for method in ["thread/archive", "thread/delete"] {
let id = app
.send_request(method, Some(json!({"threadId": reviewer_id})))
.await?;
let error = timeout(
TIMEOUT,
app.read_stream_until_error_message(RequestId::Integer(id)),
)
.await??;
assert_eq!(
error.error,
JSONRPCErrorError {
code: -32600,
message: "live internal threads can only be removed by their owner".to_owned(),
data: None,
}
);
}
continue_parent.send(()).expect("continue parent");
// Neither review can finish until both requests arrive: prove reuse and an independent fork.
timeout(TIMEOUT, server.wait_for_request_count(/*count*/ 5)).await?;
let requests = server.requests().await;
let first: Value = serde_json::from_slice(&requests[3])?;
let second: Value = serde_json::from_slice(&requests[4])?;
let concurrent_ids = [
&first["client_metadata"]["thread_id"],
&second["client_metadata"]["thread_id"],
];
assert_ne!(concurrent_ids[0], concurrent_ids[1]);
assert!(concurrent_ids.contains(&&seed["client_metadata"]["thread_id"]));
for review in [&seed, &first, &second] {
assert_eq!(
review["prompt_cache_key"],
format!("guardian:{}", parent.id)
);
let metadata: Value = serde_json::from_str(
review["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("turn metadata"),
)?;
assert_eq!(metadata["subagent_kind"], "guardian");
assert!(
review["input"]
.to_string()
.contains("Keep this project private.")
);
}
for review in [&first, &second] {
assert!(
review["input"]
.to_string()
.contains("completed seed assessment")
);
}
finish_first.send(()).expect("finish first review");
finish_second.send(()).expect("finish second review");
let completed: TurnCompletedNotification =
timeout(TIMEOUT, app.read_notification("turn/completed")).await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
let read: ThreadReadResponse = app
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: reviewer_id.clone(),
include_turns: true,
},
})
.await?;
assert_eq!(
read.thread.source,
SessionSource::SubAgent(SubAgentSource::Other("guardian".to_owned()))
);
let _: ThreadUnsubscribeResponse = app
.request(|request_id| ClientRequest::ThreadUnsubscribe {
request_id,
params: ThreadUnsubscribeParams {
thread_id: parent.id.clone(),
},
})
.await?;
let closed: ThreadClosedNotification =
timeout(TIMEOUT, app.read_notification("thread/closed")).await??;
assert_eq!(closed.thread_id, parent.id);
// Saved reviewers remain discoverable through the existing subagent filters.
for kind in [ThreadSourceKind::SubAgent, ThreadSourceKind::SubAgentOther] {
let params = serde_json::from_value(json!({"sourceKinds": [kind]}))?;
let listed: ThreadListResponse = app
.request(|request_id| ClientRequest::ThreadList { request_id, params })
.await?;
assert!(listed.data.iter().any(|thread| thread.id == reviewer_id));
}
let resumed: ThreadResumeResponse = app
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: reviewer_id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(resumed.thread.id, reviewer_id);
assert_eq!(resumed.thread.source, read.thread.source);
let completed = timeout(
TIMEOUT,
app.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: reviewer_id.clone(),
input: vec![UserInput::Text {
text: "Explain your assessment.".to_owned(),
text_elements: Vec::new(),
}],
..Default::default()
}),
)
.await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
assert_eq!(server.requests().await.len(), 7);
// Once the owner releases it, the resumed reviewer follows normal client removal.
for method in ["thread/archive", "thread/delete"] {
let id = app
.send_request(method, Some(json!({"threadId": reviewer_id})))
.await?;
let response: Value = timeout(TIMEOUT, app.read_response(id)).await??;
assert_eq!(response, json!({}));
}
app.shutdown_gracefully().await?;
mcp_server.abort();
server.shutdown().await;
Ok(())
}
#[tokio::test]
async fn inline_review_delegate_runs_strict_guardian_assessment() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let requests = responses::mount_sse_sequence(
&server,
vec![
tool_response("node_repl", "js", &["inline-tool"]),
assistant_response(ALLOW)?,
assistant_response(r#"{"findings":[]}"#)?,
],
)
.await;
let (mcp_url, mcp_server) =
start_mcp_server_with_tools(&["js"], /*sensitive_action*/ None).await?;
let codex_home = TempDir::new()?;
sync_config(&server.uri())
.with_extra_config(&format!("[mcp_servers.node_repl]\nurl = \"{mcp_url}/mcp\"\ndefault_tools_approval_mode = \"auto\""))
.write(codex_home.path())?;
let config = load_default_config_for_test(&codex_home).await;
let mut model = codex_core::test_support::construct_model_info_offline(MODEL, &config);
// Strict MCP approvals must work under the inline delegate's `never` policy.
model.node_repl_auto_review_required = true;
write_models_cache_with_models(codex_home.path(), vec![model]).await?;
let mut app = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(TIMEOUT)
.await?;
let parent = app.start_thread(ThreadStartParams::default()).await?.thread;
let _: ReviewStartResponse = app
.request(|request_id| ClientRequest::ReviewStart {
request_id,
params: ReviewStartParams {
thread_id: parent.id,
delivery: Some(ReviewDelivery::Inline),
target: ReviewTarget::Custom {
instructions: "Run the configured tool to check this change.".to_owned(),
},
},
})
.await?;
let completed: TurnCompletedNotification =
timeout(TIMEOUT, app.read_notification("turn/completed")).await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
let output = requests
.function_call_output_text("inline-tool")
.expect("MCP tool output");
assert!(output.contains(r#""echoed":"inline-tool""#), "{output}");
assert_eq!(
requests
.requests()
.iter()
.map(|request| request.body_json()["client_metadata"]["x-openai-subagent"].clone())
.collect::<Vec<_>>(),
vec![json!("review"), json!("guardian"), json!("review")]
);
app.shutdown_gracefully().await?;
mcp_server.abort();
Ok(())
}