mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## Why When silent OAuth refresh cannot recover a rejected MCP tool call, the caller needs the server's authentication challenge to initiate interactive login. The rejected call must not be replayed automatically after refresh has failed. ## What changed - Convert `401 Unauthorized` responses with `WWW-Authenticate` headers into an MCP tool error containing `mcp/www_authenticate` metadata. - Preserve multiple authentication challenges by combining all `WWW-Authenticate` header values. - Keep successful silent refresh behavior while avoiding replay when refresh fails. ## Testing Added coverage for successful and failed refresh, multiple authentication headers, propagation through agent tool-call events, and no-replay behavior. GitOrigin-RevId: c560cff6286d7ab09b1f5c6cf3618d4650cde47d
454 lines
15 KiB
Rust
454 lines
15 KiB
Rust
mod streamable_http_test_support;
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use codex_exec_server::Environment;
|
|
use codex_exec_server::ExecServerError;
|
|
use codex_exec_server::HttpClient;
|
|
use codex_exec_server::HttpRequestParams;
|
|
use codex_exec_server::HttpRequestResponse;
|
|
use codex_exec_server::HttpResponseBodyStream;
|
|
use futures::FutureExt as _;
|
|
use futures::future::BoxFuture;
|
|
use pretty_assertions::assert_eq;
|
|
use rmcp::model::CallToolResult;
|
|
use rmcp::model::ContentBlock;
|
|
use rmcp::model::MetaObject;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
|
|
use streamable_http_test_support::arm_initialize_post_failure;
|
|
use streamable_http_test_support::arm_initialize_post_json_rpc_failure;
|
|
use streamable_http_test_support::arm_initialized_notification_post_json_rpc_failure;
|
|
use streamable_http_test_support::arm_session_post_failure;
|
|
use streamable_http_test_support::arm_session_post_json_rpc_failure;
|
|
use streamable_http_test_support::call_echo_tool;
|
|
use streamable_http_test_support::create_client;
|
|
use streamable_http_test_support::create_client_with_http_client;
|
|
use streamable_http_test_support::expected_echo_result;
|
|
use streamable_http_test_support::spawn_streamable_http_server;
|
|
|
|
const JSON_RPC_INTERNAL_ERROR_CODE: i64 = -32603;
|
|
const SIMULATED_NO_RESPONSE_MESSAGE: &str =
|
|
"http/request failed: error sending request for url (simulated no response)";
|
|
|
|
#[derive(Clone)]
|
|
struct FailFirstInitializeHttpClient {
|
|
inner: Arc<dyn HttpClient>,
|
|
failures_remaining: Arc<AtomicUsize>,
|
|
initialize_attempts: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl FailFirstInitializeHttpClient {
|
|
fn new(inner: Arc<dyn HttpClient>, failures_remaining: usize) -> Self {
|
|
Self {
|
|
inner,
|
|
failures_remaining: Arc::new(AtomicUsize::new(failures_remaining)),
|
|
initialize_attempts: Arc::new(AtomicUsize::new(0)),
|
|
}
|
|
}
|
|
|
|
fn initialize_attempts(&self) -> usize {
|
|
self.initialize_attempts.load(Ordering::SeqCst)
|
|
}
|
|
|
|
fn fail_next_initialize(&self) {
|
|
self.failures_remaining.store(1, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
impl HttpClient for FailFirstInitializeHttpClient {
|
|
fn http_request(
|
|
&self,
|
|
params: HttpRequestParams,
|
|
) -> BoxFuture<'_, Result<HttpRequestResponse, ExecServerError>> {
|
|
self.inner.http_request(params)
|
|
}
|
|
|
|
fn http_request_stream(
|
|
&self,
|
|
params: HttpRequestParams,
|
|
) -> BoxFuture<'_, Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError>> {
|
|
let inner = Arc::clone(&self.inner);
|
|
let failures_remaining = Arc::clone(&self.failures_remaining);
|
|
let initialize_attempts = Arc::clone(&self.initialize_attempts);
|
|
|
|
async move {
|
|
if is_initialize_post(¶ms) {
|
|
initialize_attempts.fetch_add(1, Ordering::SeqCst);
|
|
if failures_remaining.swap(0, Ordering::SeqCst) > 0 {
|
|
return Err(ExecServerError::Server {
|
|
code: JSON_RPC_INTERNAL_ERROR_CODE,
|
|
message: SIMULATED_NO_RESPONSE_MESSAGE.to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
inner.http_request_stream(params).await
|
|
}
|
|
.boxed()
|
|
}
|
|
}
|
|
|
|
fn is_initialize_post(params: &HttpRequestParams) -> bool {
|
|
params.method.eq_ignore_ascii_case("POST")
|
|
&& params
|
|
.body
|
|
.as_ref()
|
|
.and_then(|body| serde_json::from_slice::<Value>(&body.0).ok())
|
|
.and_then(|body| {
|
|
body.get("method")
|
|
.and_then(Value::as_str)
|
|
.map(|method| method == "initialize")
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_initialize_retries_remote_no_response_error() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let http_client = FailFirstInitializeHttpClient::new(
|
|
Environment::default_for_tests().get_http_client(),
|
|
/*failures_remaining*/ 1,
|
|
);
|
|
|
|
let client = create_client_with_http_client(&base_url, Arc::new(http_client.clone())).await?;
|
|
let result = call_echo_tool(&client, "after-init-retry").await?;
|
|
|
|
assert_eq!(http_client.initialize_attempts(), 2);
|
|
assert_eq!(result, expected_echo_result("after-init-retry"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_initialize_retries_transient_http_status() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
|
|
arm_initialize_post_failure(&base_url, /*status*/ 502, /*remaining*/ 1).await?;
|
|
|
|
let client = create_client(&base_url).await?;
|
|
let result = call_echo_tool(&client, "after-status-retry").await?;
|
|
|
|
assert_eq!(result, expected_echo_result("after-status-retry"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_initialize_retries_json_rpc_transient_status() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
|
|
arm_initialize_post_json_rpc_failure(&base_url, /*status*/ 502, /*remaining*/ 1).await?;
|
|
|
|
let client = create_client(&base_url).await?;
|
|
let result = call_echo_tool(&client, "after-json-status-retry").await?;
|
|
|
|
assert_eq!(result, expected_echo_result("after-json-status-retry"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_retries_initialized_notification_status() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
|
|
arm_initialized_notification_post_json_rpc_failure(
|
|
&base_url, /*status*/ 502, /*remaining*/ 1,
|
|
)
|
|
.await?;
|
|
|
|
let client = create_client(&base_url).await?;
|
|
let result = call_echo_tool(&client, "after-notification-status-retry").await?;
|
|
|
|
assert_eq!(
|
|
result,
|
|
expected_echo_result("after-notification-status-retry")
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_tools_list_retries_transient_http_status() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let expected = client
|
|
.list_tools(
|
|
/*params*/ None,
|
|
/*timeout*/ Some(Duration::from_secs(5)),
|
|
)
|
|
.await?;
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 502,
|
|
/*remaining*/ 1,
|
|
/*www_authenticate_headers*/ &[],
|
|
)
|
|
.await?;
|
|
|
|
let result = client
|
|
.list_tools(
|
|
/*params*/ None,
|
|
/*timeout*/ Some(Duration::from_secs(5)),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(result, expected);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_tools_list_retries_json_rpc_transient_status() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let expected = client
|
|
.list_tools(
|
|
/*params*/ None,
|
|
/*timeout*/ Some(Duration::from_secs(5)),
|
|
)
|
|
.await?;
|
|
arm_session_post_json_rpc_failure(&base_url, /*status*/ 502, /*remaining*/ 1).await?;
|
|
|
|
let result = client
|
|
.list_tools(
|
|
/*params*/ None,
|
|
/*timeout*/ Some(Duration::from_secs(5)),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(result, expected);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_404_session_expiry_recovers_and_retries_once() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let warmup = call_echo_tool(&client, "warmup").await?;
|
|
assert_eq!(warmup, expected_echo_result("warmup"));
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 404,
|
|
/*remaining*/ 1,
|
|
/*www_authenticate_headers*/ &[],
|
|
)
|
|
.await?;
|
|
|
|
let recovered = call_echo_tool(&client, "recovered").await?;
|
|
assert_eq!(recovered, expected_echo_result("recovered"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_session_recovery_retries_initialize_failure() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let http_client = FailFirstInitializeHttpClient::new(
|
|
Environment::default_for_tests().get_http_client(),
|
|
/*failures_remaining*/ 0,
|
|
);
|
|
let client = create_client_with_http_client(&base_url, Arc::new(http_client.clone())).await?;
|
|
|
|
let warmup = call_echo_tool(&client, "warmup").await?;
|
|
assert_eq!(warmup, expected_echo_result("warmup"));
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 404,
|
|
/*remaining*/ 1,
|
|
/*www_authenticate_headers*/ &[],
|
|
)
|
|
.await?;
|
|
http_client.fail_next_initialize();
|
|
|
|
let recovered = call_echo_tool(&client, "recovered-after-retry").await?;
|
|
assert_eq!(http_client.initialize_attempts(), 3);
|
|
assert_eq!(recovered, expected_echo_result("recovered-after-retry"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_401_does_not_trigger_recovery() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let warmup = call_echo_tool(&client, "warmup").await?;
|
|
assert_eq!(warmup, expected_echo_result("warmup"));
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 401,
|
|
/*remaining*/ 2,
|
|
/*www_authenticate_headers*/ &[],
|
|
)
|
|
.await?;
|
|
|
|
let first_error = call_echo_tool(&client, "unauthorized").await.unwrap_err();
|
|
assert!(first_error.to_string().contains("401"));
|
|
|
|
let second_error = call_echo_tool(&client, "still-unauthorized")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(second_error.to_string().contains("401"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_401_challenge_is_a_tool_error_without_replay() -> anyhow::Result<()> {
|
|
let challenge = r#"Bearer error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource""#;
|
|
for challenges in [
|
|
vec![challenge],
|
|
vec![r#"Basic realm="proxy, login""#, challenge],
|
|
] {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 401,
|
|
/*remaining*/ 1,
|
|
&challenges,
|
|
)
|
|
.await?;
|
|
|
|
let result = call_echo_tool(&client, "rejected").await?;
|
|
let mut expected =
|
|
CallToolResult::error(vec![ContentBlock::text("Authentication required")]);
|
|
expected.meta = Some(MetaObject::from(serde_json::Map::from_iter([(
|
|
"mcp/www_authenticate".to_string(),
|
|
json!([challenges.join(", ")]),
|
|
)])));
|
|
assert_eq!(result, expected);
|
|
|
|
// A retry inside the failed call would consume the single rejection and return success.
|
|
assert_eq!(
|
|
call_echo_tool(&client, "next-user-call").await?,
|
|
expected_echo_result("next-user-call"),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_403_scope_challenge_returns_insufficient_scope() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let warmup = call_echo_tool(&client, "warmup").await?;
|
|
assert_eq!(warmup, expected_echo_result("warmup"));
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 403,
|
|
/*remaining*/ 1,
|
|
/*www_authenticate_headers*/
|
|
&[r#"Bearer error="insufficient_scope", scope="files:read files:write""#],
|
|
)
|
|
.await?;
|
|
|
|
let error = call_echo_tool(&client, "forbidden").await.unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("Insufficient scope"),
|
|
"expected insufficient-scope transport error, got: {error:#}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_403_finds_bearer_challenge_in_later_header_value() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let warmup = call_echo_tool(&client, "warmup").await?;
|
|
assert_eq!(warmup, expected_echo_result("warmup"));
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 403,
|
|
/*remaining*/ 1,
|
|
/*www_authenticate_headers*/
|
|
&[
|
|
r#"Basic realm="example""#,
|
|
r#"Bearer error="insufficient_scope", scope="files:read""#,
|
|
],
|
|
)
|
|
.await?;
|
|
|
|
let error = call_echo_tool(&client, "forbidden").await.unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("Insufficient scope"),
|
|
"expected insufficient-scope transport error, got: {error:#}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_404_recovery_only_retries_once() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let warmup = call_echo_tool(&client, "warmup").await?;
|
|
assert_eq!(warmup, expected_echo_result("warmup"));
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 404,
|
|
/*remaining*/ 2,
|
|
/*www_authenticate_headers*/ &[],
|
|
)
|
|
.await?;
|
|
|
|
let error = call_echo_tool(&client, "double-404").await.unwrap_err();
|
|
let error_message = error.to_string();
|
|
assert!(
|
|
error_message.contains("404") || error_message.contains("session expired"),
|
|
"expected session-expiry error, got: {error:#}"
|
|
);
|
|
|
|
let recovered = call_echo_tool(&client, "after-double-404").await?;
|
|
assert_eq!(recovered, expected_echo_result("after-double-404"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
|
async fn streamable_http_non_session_failure_does_not_trigger_recovery() -> anyhow::Result<()> {
|
|
let (_server, base_url) = spawn_streamable_http_server().await?;
|
|
let client = create_client(&base_url).await?;
|
|
|
|
let warmup = call_echo_tool(&client, "warmup").await?;
|
|
assert_eq!(warmup, expected_echo_result("warmup"));
|
|
|
|
arm_session_post_failure(
|
|
&base_url,
|
|
/*status*/ 500,
|
|
/*remaining*/ 2,
|
|
/*www_authenticate_headers*/ &[],
|
|
)
|
|
.await?;
|
|
|
|
let first_error = call_echo_tool(&client, "server-error").await.unwrap_err();
|
|
assert!(first_error.to_string().contains("500"));
|
|
|
|
let second_error = call_echo_tool(&client, "still-server-error")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(second_error.to_string().contains("500"));
|
|
|
|
Ok(())
|
|
}
|