blob: fcec1a52e7c91f3e6ae7468f3032d94e8d502d2f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use futures_util::future::BoxFuture;
use maka_agent_ports::model::Connect;
use maka_agent_ports::model::Credentials;
use maka_agent_ports::model::Error as ModelError;
use maka_agent_ports::model::Socket;
use maka_agent_ports::model::Transport;
use maka_agent_ports::provider::Connection;
use maka_agent_ports::provider::Context;
use maka_agent_ports::provider::Error;
use maka_agent_ports::provider::Provider;
use maka_agent_ports::provider::Resolve;
use maka_agent_ports::provider::authentication::Credential;
use maka_capabilities::http;
use maka_providers::codex::Codex;
use serde_json::json;
use std::sync::{Arc, Mutex};
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
#[path = "codex/inventory.rs"]
mod inventory;
struct Exchange {
started: Notify,
release: Notify,
requests: Mutex<Vec<http::Request>>,
fail: bool,
response: serde_json::Value,
status: u16,
}
impl Transport for Exchange {
fn identity(&self) -> u64 {
1
}
fn request(&self, request: http::Request) -> BoxFuture<'_, Result<http::Response, ModelError>> {
Box::pin(async move {
self.requests.lock().unwrap().push(request);
self.started.notify_one();
self.release.notified().await;
if self.fail {
return Err(ModelError::Adapter("reply lost".into()));
}
Ok(http::Response {
head: http::Head {
status: self.status,
url: "https://auth.openai.com/oauth/token".into(),
headers: vec![],
},
body: Arc::new(Body(Mutex::new(Some(
serde_json::to_vec(&self.response).unwrap(),
)))),
})
})
}
fn connect(&self, _: Connect) -> BoxFuture<'_, Result<Arc<dyn Socket>, ModelError>> {
Box::pin(async { Err(ModelError::Adapter("unexpected WebSocket".into())) })
}
}
struct Body(Mutex<Option<Vec<u8>>>);
impl http::Body for Body {
fn next(&self) -> BoxFuture<'_, Result<Option<Vec<u8>>, http::Error>> {
Box::pin(async { Ok(self.0.lock().unwrap().take()) })
}
fn cancel(&self) {}
fn close(&self) -> BoxFuture<'_, Result<(), http::Error>> {
Box::pin(async { Ok(()) })
}
}
fn connection() -> Connection {
Connection {
id: "connection".into(),
revision: 1,
configuration: json!({"baseUrl":"https://chatgpt.com/backend-api/codex"}),
}
}
fn credential() -> Credential {
Credential {
secret: json!({
"access_token":"old-access","refresh_token":"old+refresh/= &value","expires_at":1,"id_token":null,
}).to_string(),
refresh_at: Some(0),
}
}
struct UnexpectedPresentation;
impl maka_agent_ports::provider::authentication::Interaction for UnexpectedPresentation {
fn open_external(&self, _: String, _: Option<String>) -> BoxFuture<'_, Result<(), Error>> {
panic!("a rejected device-code request must not open authorization")
}
}
#[tokio::test]
async fn device_login_preserves_region_and_http_failures_without_echoing_response_secrets() {
for (status, code) in [
(403, "unsupported_country_region_territory"),
(403, "access_denied"),
(429, "rate_limit_exceeded"),
(503, "unavailable"),
] {
let transport = Arc::new(Exchange {
started: Notify::new(),
release: Notify::new(),
requests: Mutex::new(vec![]),
fail: false,
status,
response: json!({"error":{
"code":code,
"message":"do-not-echo-secret\u{1b}[2J",
"refresh_token":"do-not-echo-secret"
}}),
});
transport.release.notify_one();
let error = Codex::default()
.authenticate(
maka_agent_ports::provider::authentication::Authenticate {
connection: connection(),
method: "chatgpt".into(),
input: json!({}),
},
Context {
transport: transport.clone(),
cancellation: CancellationToken::new(),
interaction: Some(Arc::new(UnexpectedPresentation)),
},
)
.await
.err()
.expect("the login service rejected the request");
assert!(matches!(&error, Error::Rejected(message) if message.contains("ChatGPT")));
if code == "unsupported_country_region_territory" {
assert!(error.to_string().contains(code));
}
assert!(error.to_string().contains(&status.to_string()));
assert!(
!serde_json::to_string(&error)
.unwrap()
.contains("do-not-echo")
);
assert_eq!(transport.requests.lock().unwrap().len(), 1);
}
}
#[tokio::test]
async fn sent_refresh_settles_after_cancellation_and_unknown_reply_is_never_retried() {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
for fail in [false, true] {
let binding = Codex::default().definition().unwrap();
let transport = Arc::new(Exchange {
started: Notify::new(),
release: Notify::new(),
requests: Mutex::new(vec![]),
fail,
status: 200,
response: json!({
"access_token": "replacement-access",
"refresh_token": "replacement-refresh",
"expires_in": 3600,
}),
});
let cancellation = CancellationToken::new();
let context = Context {
transport: transport.clone(),
cancellation: cancellation.clone(),
interaction: None,
};
let running = binding.prepare_refresh(connection(), credential()).unwrap();
let refresh = tokio::spawn(async move { running.run(context).await });
transport.started.notified().await;
cancellation.cancel();
transport.release.notify_one();
let result = refresh.await.unwrap();
if fail {
assert!(matches!(result, Err(Error::OutcomeUnknown)));
} else {
let replacement = result.unwrap();
assert!(replacement.refresh_at.is_some_and(|at| at > 0));
let secret: serde_json::Value = serde_json::from_str(&replacement.secret).unwrap();
assert_eq!(secret["refresh_token"], "replacement-refresh");
let Credentials::RequestHeaders(headers) = binding
.authorize(connection(), Some(replacement), "session".into())
.await
.unwrap()
else {
panic!("provider must prepare protocol-neutral credentials")
};
assert_eq!(headers["authorization"], "Bearer replacement-access");
assert_eq!(headers["session_id"], "session");
}
let requests = transport.requests.lock().unwrap();
assert_eq!(
requests.len(),
1,
"uncertain rotating grants cannot be retried"
);
assert_eq!(requests[0].url, "https://auth.openai.com/oauth/token");
let form: std::collections::HashMap<_, _> =
url::form_urlencoded::parse(&requests[0].body)
.into_owned()
.collect();
assert_eq!(form["grant_type"], "refresh_token");
assert_eq!(form["refresh_token"], "old+refresh/= &value");
}
})
.await
.unwrap();
}