mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Merge a2d5b79e0c into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+1
@@ -2405,6 +2405,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"system-configuration",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
||||
@@ -409,6 +409,7 @@ strum_macros = "0.28.0"
|
||||
supports-color = "3.0.2"
|
||||
syntect = "5"
|
||||
sys-locale = "0.3.2"
|
||||
system-configuration = "0.7"
|
||||
tar = { version = "=0.4.45", default-features = false }
|
||||
tempfile = "3.23.0"
|
||||
test-log = "0.2.19"
|
||||
|
||||
@@ -714,9 +714,11 @@ If the thread does not already have an active turn, the server starts a standalo
|
||||
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:
|
||||
|
||||
- `{"type":"text","text":"Explain this diff"}`
|
||||
- `{"type":"image","url":"https://…png"}`
|
||||
- `{"type":"image","url":"data:image/png;base64,…"}`
|
||||
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
|
||||
|
||||
The `image` variant accepts inline data URLs. Remote HTTP(S) image URLs are rejected; use a data URL or `localImage` instead.
|
||||
|
||||
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only.
|
||||
|
||||
`approvalsReviewer` accepts:
|
||||
@@ -828,7 +830,7 @@ Invoke a plugin by including a UI mention token such as `@sample` in the text in
|
||||
|
||||
### Example: Inject raw history items
|
||||
|
||||
Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests.
|
||||
Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. Any `input_image` items must use inline data URLs; remote HTTP(S) image URLs are rejected.
|
||||
|
||||
```json
|
||||
{ "method": "thread/inject_items", "id": 36, "params": {
|
||||
@@ -1585,7 +1587,7 @@ The server also emits item lifecycle notifications around the request:
|
||||
3. Client response.
|
||||
4. `item/completed` with `item.type = "dynamicToolCall"`, final `status`, and the returned `contentItems`/`success`.
|
||||
|
||||
The client must respond with content items. Use `inputText` for text and `inputImage` for image URLs/data URLs:
|
||||
The client must respond with content items. Use `inputText` for text and `inputImage` for inline data URLs. Remote HTTP(S) image URLs make the dynamic tool response invalid.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@ use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::error;
|
||||
|
||||
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
|
||||
use crate::image_url::is_remote_image_url;
|
||||
use crate::outgoing_message::ClientRequestResult;
|
||||
use crate::server_request_error::is_turn_transition_server_request_error;
|
||||
|
||||
@@ -54,6 +56,21 @@ pub(crate) async fn on_call_response(
|
||||
|
||||
fn decode_response(value: serde_json::Value) -> (DynamicToolCallResponse, Option<String>) {
|
||||
match serde_json::from_value::<DynamicToolCallResponse>(value) {
|
||||
Ok(response)
|
||||
if response.content_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
DynamicToolCallOutputContentItem::InputImage { image_url }
|
||||
if is_remote_image_url(image_url)
|
||||
)
|
||||
}) =>
|
||||
{
|
||||
error!(
|
||||
message = REMOTE_IMAGE_URL_ERROR,
|
||||
"dynamic tool response was invalid"
|
||||
);
|
||||
fallback_response(REMOTE_IMAGE_URL_ERROR)
|
||||
}
|
||||
Ok(response) => (response, None),
|
||||
Err(err) => {
|
||||
error!("failed to deserialize DynamicToolCallResponse: {err}");
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
pub(crate) const REMOTE_IMAGE_URL_ERROR: &str =
|
||||
"remote image URLs are not supported; use an inline data URL instead";
|
||||
|
||||
pub(crate) fn is_remote_image_url(image_url: &str) -> bool {
|
||||
image_url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
}
|
||||
@@ -96,6 +96,7 @@ mod extensions;
|
||||
mod filters;
|
||||
mod fs_watch;
|
||||
mod fuzzy_file_search;
|
||||
mod image_url;
|
||||
pub mod in_process;
|
||||
mod mcp_refresh;
|
||||
mod message_processor;
|
||||
|
||||
@@ -1,14 +1,70 @@
|
||||
use super::*;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
|
||||
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
|
||||
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
|
||||
use crate::image_url::is_remote_image_url;
|
||||
|
||||
const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str =
|
||||
"direct app-server input is not allowed for multi-agent v2 sub-agents";
|
||||
|
||||
fn validate_user_input_image_urls(input: &[V2UserInput]) -> Result<(), JSONRPCErrorError> {
|
||||
if input.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
V2UserInput::Image { url, .. } if is_remote_image_url(url)
|
||||
)
|
||||
}) {
|
||||
return Err(invalid_request(REMOTE_IMAGE_URL_ERROR));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_response_item_image_urls(items: &[ResponseItem]) -> Result<(), JSONRPCErrorError> {
|
||||
if items.iter().any(|item| match item {
|
||||
ResponseItem::Message { content, .. } => content.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ContentItem::InputImage { image_url, .. } if is_remote_image_url(image_url)
|
||||
)
|
||||
}),
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
output.content_items().is_some_and(|content| {
|
||||
content.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
FunctionCallOutputContentItem::InputImage { image_url, .. }
|
||||
if is_remote_image_url(image_url)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::AgentMessage { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger { .. }
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => false,
|
||||
}) {
|
||||
return Err(invalid_request(REMOTE_IMAGE_URL_ERROR));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TurnRequestProcessor {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
@@ -105,6 +161,7 @@ impl TurnRequestProcessor {
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
validate_user_input_image_urls(¶ms.input)?;
|
||||
self.turn_start_inner(
|
||||
request_id,
|
||||
params,
|
||||
@@ -140,6 +197,7 @@ impl TurnRequestProcessor {
|
||||
request_id: &ConnectionRequestId,
|
||||
params: TurnSteerParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
validate_user_input_image_urls(¶ms.input)?;
|
||||
self.turn_steer_inner(request_id, params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
@@ -764,6 +822,7 @@ impl TurnRequestProcessor {
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(invalid_request)?;
|
||||
validate_response_item_image_urls(&items)?;
|
||||
|
||||
thread
|
||||
.inject_response_items(items)
|
||||
|
||||
@@ -39,6 +39,8 @@ use tokio::time::timeout;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
const REMOTE_IMAGE_URL_ERROR: &str =
|
||||
"remote image URLs are not supported; use an inline data URL instead";
|
||||
|
||||
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
|
||||
// subprocesses or processing test RPCs under load.
|
||||
@@ -552,15 +554,19 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures dynamic tool call responses can include structured content items.
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> {
|
||||
let call_id = "dyn-call-items-1";
|
||||
struct PendingDynamicToolCall {
|
||||
mcp: TestAppServer,
|
||||
server: MockServer,
|
||||
request_id: RequestId,
|
||||
params: DynamicToolCallParams,
|
||||
}
|
||||
|
||||
async fn start_function_dynamic_tool_call(call_id: &str) -> Result<PendingDynamicToolCall> {
|
||||
let tool_name = "demo_tool";
|
||||
let tool_args = json!({ "city": "Paris" });
|
||||
let tool_call_arguments = serde_json::to_string(&tool_args)?;
|
||||
|
||||
let responses = vec![
|
||||
let response_sequence = vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
|
||||
@@ -568,7 +574,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
]),
|
||||
create_final_assistant_message_sse_response("Done")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
let server = create_mock_responses_server_sequence_unchecked(response_sequence).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
@@ -632,20 +638,39 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let (request_id, params) = match request {
|
||||
let (request_id, actual_params) = match request {
|
||||
ServerRequest::DynamicToolCall { request_id, params } => (request_id, params),
|
||||
other => panic!("expected DynamicToolCall request, got {other:?}"),
|
||||
};
|
||||
|
||||
let expected = DynamicToolCallParams {
|
||||
let params = DynamicToolCallParams {
|
||||
thread_id,
|
||||
turn_id: turn_id.clone(),
|
||||
turn_id,
|
||||
call_id: call_id.to_string(),
|
||||
namespace: None,
|
||||
tool: tool_name.to_string(),
|
||||
arguments: tool_args,
|
||||
};
|
||||
assert_eq!(params, expected);
|
||||
assert_eq!(actual_params, params);
|
||||
|
||||
Ok(PendingDynamicToolCall {
|
||||
mcp,
|
||||
server,
|
||||
request_id,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensures dynamic tool call responses can include structured content items.
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> {
|
||||
let call_id = "dyn-call-items-1";
|
||||
let PendingDynamicToolCall {
|
||||
mut mcp,
|
||||
server,
|
||||
request_id,
|
||||
params,
|
||||
} = start_function_dynamic_tool_call(call_id).await?;
|
||||
|
||||
let response_content_items = vec![
|
||||
DynamicToolCallOutputContentItem::InputText {
|
||||
@@ -678,8 +703,8 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
.await?;
|
||||
|
||||
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
|
||||
assert_eq!(completed.thread_id, expected.thread_id.clone());
|
||||
assert_eq!(completed.turn_id, turn_id);
|
||||
assert_eq!(completed.thread_id, params.thread_id);
|
||||
assert_eq!(completed.turn_id, params.turn_id);
|
||||
let ThreadItem::DynamicToolCall {
|
||||
status,
|
||||
content_items: completed_content_items,
|
||||
@@ -746,6 +771,62 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_remote_image_response_becomes_model_visible_error() -> Result<()> {
|
||||
let call_id = "dyn-call-remote-image";
|
||||
let PendingDynamicToolCall {
|
||||
mut mcp,
|
||||
server,
|
||||
request_id,
|
||||
params,
|
||||
} = start_function_dynamic_tool_call(call_id).await?;
|
||||
|
||||
let response = DynamicToolCallResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/tool.png".to_string(),
|
||||
}],
|
||||
success: true,
|
||||
};
|
||||
mcp.send_response(request_id, serde_json::to_value(response)?)
|
||||
.await?;
|
||||
|
||||
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
|
||||
assert_eq!(completed.thread_id, params.thread_id);
|
||||
assert_eq!(completed.turn_id, params.turn_id);
|
||||
let ThreadItem::DynamicToolCall {
|
||||
status,
|
||||
content_items,
|
||||
success,
|
||||
..
|
||||
} = completed.item
|
||||
else {
|
||||
panic!("expected dynamic tool call item");
|
||||
};
|
||||
assert_eq!(status, DynamicToolCallStatus::Failed);
|
||||
assert_eq!(
|
||||
content_items,
|
||||
Some(vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: REMOTE_IMAGE_URL_ERROR.to_string(),
|
||||
}])
|
||||
);
|
||||
assert_eq!(success, Some(false));
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let output = responses_bodies(&server)
|
||||
.await?
|
||||
.iter()
|
||||
.find_map(|body| function_call_output_raw_output(body, call_id))
|
||||
.context("expected function_call_output output in follow-up request")?;
|
||||
assert_eq!(output, json!(REMOTE_IMAGE_URL_ERROR));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn responses_bodies(server: &MockServer) -> Result<Vec<Value>> {
|
||||
let requests = server
|
||||
.received_requests()
|
||||
|
||||
@@ -52,6 +52,7 @@ mod remote_control;
|
||||
mod remote_thread_store;
|
||||
mod request_permissions;
|
||||
mod request_user_input;
|
||||
mod request_validation;
|
||||
mod review;
|
||||
mod safety_check_downgrade;
|
||||
mod skills_list;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REMOTE_IMAGE_URL_ERROR: &str =
|
||||
"remote image URLs are not supported; use an inline data URL instead";
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_handlers_reject_remote_image_urls() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml_with_chatgpt_base_url(
|
||||
codex_home.path(),
|
||||
"http://localhost/unused",
|
||||
"http://localhost/unused",
|
||||
)?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
let thread_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_response)?;
|
||||
let thread_id = thread.id;
|
||||
|
||||
let remote_tool_output = serde_json::to_value(ResponseItem::FunctionCallOutput {
|
||||
id: None,
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/tool.png".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
})?;
|
||||
let requests = [
|
||||
(
|
||||
"turn/start",
|
||||
json!({
|
||||
"threadId": thread_id,
|
||||
"input": [{
|
||||
"type": "image",
|
||||
"url": "HTTP://example.com/start.png",
|
||||
"detail": "high"
|
||||
}]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"turn/steer",
|
||||
json!({
|
||||
"threadId": thread_id,
|
||||
"expectedTurnId": "turn-id",
|
||||
"input": [{
|
||||
"type": "image",
|
||||
"url": "https://example.com/steer.png",
|
||||
"detail": "high"
|
||||
}]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"thread/inject_items",
|
||||
json!({
|
||||
"threadId": thread_id,
|
||||
"items": [remote_tool_output]
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (method, params) in requests {
|
||||
let request_id = mcp.send_raw_request(method, Some(params)).await?;
|
||||
let actual: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let expected = JSONRPCError {
|
||||
id: RequestId::Integer(request_id),
|
||||
error: JSONRPCErrorError {
|
||||
code: -32600,
|
||||
data: None,
|
||||
message: REMOTE_IMAGE_URL_ERROR.to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(actual, expected, "unexpected response for {method}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -106,6 +106,7 @@ const TINY_PNG_BYTES: &[u8] = &[
|
||||
0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1,
|
||||
122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
];
|
||||
const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
|
||||
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
String::from_utf8(req.body.clone())
|
||||
@@ -888,7 +889,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Image {
|
||||
url: "https://example.com/a.png".to_string(),
|
||||
url: TINY_PNG_DATA_URL.to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
responsesapi_client_metadata: Some(HashMap::from([(
|
||||
|
||||
@@ -500,7 +500,7 @@ mod tests {
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -517,7 +517,7 @@ mod tests {
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(
|
||||
&args,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -752,7 +752,7 @@ PATCH"#,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
@@ -792,7 +792,7 @@ PATCH"#,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
@@ -832,7 +832,7 @@ PATCH"#,
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -844,7 +844,7 @@ PATCH"#,
|
||||
result,
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes: HashMap::from([(
|
||||
PathUri::from_path(session_dir.path().join(relative_path))
|
||||
PathUri::from_host_native_path(session_dir.path().join(relative_path))
|
||||
.expect("absolute test path"),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: r#"@@ -1 +1 @@
|
||||
@@ -857,7 +857,8 @@ PATCH"#,
|
||||
},
|
||||
)]),
|
||||
patch: argv[1].clone(),
|
||||
cwd: PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
cwd: PathUri::from_host_native_path(session_dir.path())
|
||||
.expect("absolute test path"),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -887,7 +888,7 @@ PATCH"#,
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -902,8 +903,8 @@ PATCH"#,
|
||||
worktree_dir.as_path()
|
||||
);
|
||||
|
||||
let source_path =
|
||||
PathUri::from_path(worktree_dir.join(source_name)).expect("absolute test path");
|
||||
let source_path = PathUri::from_host_native_path(worktree_dir.join(source_name))
|
||||
.expect("absolute test path");
|
||||
let change = action
|
||||
.changes()
|
||||
.get(&source_path)
|
||||
@@ -912,7 +913,8 @@ PATCH"#,
|
||||
match change {
|
||||
ApplyPatchFileChange::Update { move_path, .. } => {
|
||||
let expected_move_path =
|
||||
PathUri::from_path(worktree_dir.join(dest_name)).expect("absolute test path");
|
||||
PathUri::from_host_native_path(worktree_dir.join(dest_name))
|
||||
.expect("absolute test path");
|
||||
assert_eq!(move_path.as_ref(), Some(&expected_move_path));
|
||||
}
|
||||
other => panic!("expected update change, got {other:?}"),
|
||||
@@ -923,7 +925,7 @@ PATCH"#,
|
||||
async fn test_unreadable_destinations_still_verify() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap();
|
||||
let cwd = PathUri::from_path(session_dir.path()).expect("absolute test path");
|
||||
let cwd = PathUri::from_host_native_path(session_dir.path()).expect("absolute test path");
|
||||
let add_argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
"*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(),
|
||||
@@ -966,7 +968,7 @@ PATCH"#,
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
&PathUri::from_path(session_dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"),
|
||||
LOCAL_FS.as_ref(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
|
||||
@@ -913,7 +913,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -937,7 +937,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() {
|
||||
let dir = tempdir().unwrap();
|
||||
let cwd = PathUri::from_path(dir.path()).expect("absolute test path");
|
||||
let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
|
||||
let relative_add = dir.path().join("relative-add.txt");
|
||||
let absolute_add = dir.path().join("absolute-add.txt");
|
||||
let relative_delete = dir.path().join("relative-delete.txt");
|
||||
@@ -1016,7 +1016,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1052,7 +1052,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1092,7 +1092,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1136,7 +1136,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
let failure = apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1196,7 +1196,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1254,7 +1254,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1298,7 +1298,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1341,7 +1341,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1391,7 +1391,7 @@ mod tests {
|
||||
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let diff = unified_diff_from_chunks(
|
||||
&path_uri,
|
||||
update_file_chunks,
|
||||
@@ -1439,7 +1439,7 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let resolved_path = PathUri::from_path(&path).expect("absolute test path");
|
||||
let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let diff = unified_diff_from_chunks(
|
||||
&resolved_path,
|
||||
chunks,
|
||||
@@ -1485,7 +1485,7 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let resolved_path = PathUri::from_path(&path).expect("absolute test path");
|
||||
let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let diff = unified_diff_from_chunks(
|
||||
&resolved_path,
|
||||
chunks,
|
||||
@@ -1529,7 +1529,7 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
@@ -1580,7 +1580,7 @@ mod tests {
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let diff =
|
||||
unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
|
||||
.await
|
||||
@@ -1610,7 +1610,7 @@ mod tests {
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1648,7 +1648,7 @@ g
|
||||
let mut stderr = Vec::new();
|
||||
let result = apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
@@ -1667,7 +1667,7 @@ g
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("binary.dat");
|
||||
fs::write(dir.path().join("source.txt"), "before\n").unwrap();
|
||||
let cwd = PathUri::from_path(dir.path()).expect("absolute test path");
|
||||
let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path");
|
||||
|
||||
for patch in [
|
||||
wrap_patch("*** Add File: binary.dat\n+text"),
|
||||
@@ -1705,7 +1705,7 @@ g
|
||||
let mut stderr = Vec::new();
|
||||
let delta = apply_patch(
|
||||
&patch,
|
||||
&PathUri::from_path(dir.path()).expect("absolute test path"),
|
||||
&PathUri::from_host_native_path(dir.path()).expect("absolute test path"),
|
||||
&mut stdout,
|
||||
&mut stderr,
|
||||
LOCAL_FS.as_ref(),
|
||||
|
||||
@@ -480,7 +480,7 @@ fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() {
|
||||
#[test]
|
||||
fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() {
|
||||
let cwd_dir = tempfile::tempdir().unwrap();
|
||||
let cwd = PathUri::from_path(cwd_dir.path()).unwrap();
|
||||
let cwd = PathUri::from_host_native_path(cwd_dir.path()).unwrap();
|
||||
let absolute_dir = tempfile::tempdir().unwrap();
|
||||
let absolute_add = absolute_dir.path().join("absolute-add.py").abs();
|
||||
let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs();
|
||||
|
||||
@@ -23,15 +23,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
ApiError::Retryable { message, delay } => CodexErr::Stream(message, delay),
|
||||
ApiError::Stream(msg) => CodexErr::Stream(msg, None),
|
||||
ApiError::ServerOverloaded => CodexErr::ServerOverloaded,
|
||||
ApiError::Api { status, message } => CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: message,
|
||||
url: None,
|
||||
cf_ray: None,
|
||||
request_id: None,
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
}),
|
||||
ApiError::Api { status, message } => {
|
||||
let user_message = api_error_user_message(status, &message);
|
||||
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: message,
|
||||
user_message,
|
||||
url: None,
|
||||
cf_ray: None,
|
||||
request_id: None,
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
})
|
||||
}
|
||||
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
|
||||
ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message },
|
||||
ApiError::Transport(transport) => match transport {
|
||||
@@ -111,6 +115,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
} else {
|
||||
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
user_message: api_error_user_message(status, &body_text),
|
||||
body: body_text,
|
||||
url,
|
||||
cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER),
|
||||
@@ -145,6 +150,8 @@ const X_ERROR_JSON_HEADER: &str = "x-error-json";
|
||||
const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy";
|
||||
const CYBER_POLICY_FALLBACK_MESSAGE: &str =
|
||||
"This request has been flagged for possible cybersecurity risk.";
|
||||
const CLOUDFLARE_BLOCKED_MESSAGE: &str =
|
||||
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region";
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "api_bridge_tests.rs"]
|
||||
@@ -154,6 +161,17 @@ fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option<String> {
|
||||
extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER))
|
||||
}
|
||||
|
||||
fn api_error_user_message(status: http::StatusCode, body: &str) -> Option<String> {
|
||||
if status == http::StatusCode::FORBIDDEN
|
||||
&& body.contains("Cloudflare")
|
||||
&& body.contains("blocked")
|
||||
{
|
||||
Some(format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_request_id(headers: Option<&HeaderMap>) -> Option<String> {
|
||||
extract_header(headers, REQUEST_ID_HEADER)
|
||||
.or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER))
|
||||
|
||||
@@ -26,6 +26,34 @@ fn map_api_error_maps_server_overloaded_from_503_body() {
|
||||
assert!(matches!(err, CodexErr::ServerOverloaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_cloudflare_blocked_response_to_user_message() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-id"));
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::FORBIDDEN,
|
||||
url: Some("http://example.com/blocked".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(
|
||||
"<html><body>Cloudflare error: Sorry, you have been blocked</body></html>".to_string(),
|
||||
),
|
||||
}));
|
||||
|
||||
let CodexErr::UnexpectedStatus(err) = err else {
|
||||
panic!("expected CodexErr::UnexpectedStatus, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
err.user_message.as_deref(),
|
||||
Some(
|
||||
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden)"
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden), url: http://example.com/blocked, cf-ray: ray-id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_cyber_policy_from_400_body() {
|
||||
let body = serde_json::json!({
|
||||
|
||||
@@ -24,8 +24,13 @@ tracing-opentelemetry = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
system-configuration = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Networking_WinHttp",
|
||||
|
||||
@@ -14,9 +14,9 @@ use std::time::Instant;
|
||||
|
||||
use crate::custom_ca::BuildCustomCaTransportError;
|
||||
use crate::custom_ca::build_reqwest_client_with_custom_ca;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use sha2::Digest;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use sha2::Sha256;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -24,6 +24,8 @@ const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
@@ -115,15 +117,23 @@ impl From<BuildRouteAwareHttpClientError> for io::Error {
|
||||
/// Builds a reqwest client with conservative route selection and shared CA handling.
|
||||
///
|
||||
/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after
|
||||
/// a route is selected are returned without trying another route.
|
||||
/// a route is selected are returned without trying another route. Ordered PAC candidates are
|
||||
/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates
|
||||
/// are not retried after a connection failure.
|
||||
pub fn build_reqwest_client_for_route(
|
||||
builder: reqwest::ClientBuilder,
|
||||
request_url: &str,
|
||||
route_class: ClientRouteClass,
|
||||
config: Option<&OutboundProxyConfig>,
|
||||
) -> Result<reqwest::Client, BuildRouteAwareHttpClientError> {
|
||||
let builder =
|
||||
configure_proxy_for_route(&ProcessEnv, builder, request_url, route_class, config)?;
|
||||
let builder = configure_proxy_for_route(
|
||||
&ProcessEnv,
|
||||
builder,
|
||||
request_url,
|
||||
route_class,
|
||||
config,
|
||||
resolve_system_proxy,
|
||||
)?;
|
||||
build_reqwest_client_with_custom_ca(builder).map_err(Into::into)
|
||||
}
|
||||
|
||||
@@ -133,6 +143,7 @@ fn configure_proxy_for_route(
|
||||
request_url: &str,
|
||||
route_class: ClientRouteClass,
|
||||
config: Option<&OutboundProxyConfig>,
|
||||
resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision,
|
||||
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
|
||||
if config.is_none() {
|
||||
return Ok(builder);
|
||||
@@ -217,7 +228,7 @@ impl RequestOrigin {
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
not(target_os = "windows"),
|
||||
not(any(target_os = "windows", target_os = "macos")),
|
||||
allow(
|
||||
dead_code,
|
||||
reason = "Direct and Proxy are constructed only by platform-specific resolvers"
|
||||
@@ -240,12 +251,17 @@ fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProx
|
||||
decision
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
|
||||
macos::resolve(request_url, origin)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
|
||||
windows::resolve(request_url, origin)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn resolve_platform_system_proxy(
|
||||
_request_url: &str,
|
||||
_origin: &RequestOrigin,
|
||||
@@ -317,7 +333,7 @@ fn insert_system_proxy_cache_entry(
|
||||
}
|
||||
|
||||
fn system_proxy_cache_key(request_url: &str) -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
{
|
||||
// Keep URL-specific PAC decisions without retaining the raw routed URL.
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -326,7 +342,7 @@ fn system_proxy_cache_key(request_url: &str) -> String {
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
request_url.to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
use std::ffi::c_void;
|
||||
use std::ptr;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::RequestOrigin;
|
||||
use super::RouteFailureClass;
|
||||
use super::SystemProxyDecision;
|
||||
use system_configuration::core_foundation::array::CFArray;
|
||||
use system_configuration::core_foundation::array::CFArrayRef;
|
||||
use system_configuration::core_foundation::base::CFEqual;
|
||||
use system_configuration::core_foundation::base::CFGetTypeID;
|
||||
use system_configuration::core_foundation::base::CFIndex;
|
||||
use system_configuration::core_foundation::base::CFType;
|
||||
use system_configuration::core_foundation::base::CFTypeRef;
|
||||
use system_configuration::core_foundation::base::TCFType;
|
||||
use system_configuration::core_foundation::base::kCFAllocatorDefault;
|
||||
use system_configuration::core_foundation::dictionary::CFDictionary;
|
||||
use system_configuration::core_foundation::dictionary::CFDictionaryRef;
|
||||
use system_configuration::core_foundation::error::CFErrorRef;
|
||||
use system_configuration::core_foundation::number::CFNumber;
|
||||
use system_configuration::core_foundation::runloop::CFRunLoop;
|
||||
use system_configuration::core_foundation::runloop::CFRunLoopSource;
|
||||
use system_configuration::core_foundation::runloop::CFRunLoopSourceInvalidate;
|
||||
use system_configuration::core_foundation::runloop::CFRunLoopSourceRef;
|
||||
use system_configuration::core_foundation::runloop::kCFRunLoopDefaultMode;
|
||||
use system_configuration::core_foundation::string::CFString;
|
||||
use system_configuration::core_foundation::string::CFStringRef;
|
||||
use system_configuration::core_foundation::url::CFURL;
|
||||
use system_configuration::core_foundation::url::CFURLCreateWithString;
|
||||
use system_configuration::core_foundation::url::CFURLGetTypeID;
|
||||
use system_configuration::core_foundation::url::CFURLRef;
|
||||
use system_configuration::dynamic_store::SCDynamicStoreBuilder;
|
||||
|
||||
const PAC_EXECUTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
type ProxyDictionary = CFDictionary<CFString, CFType>;
|
||||
type ProxyArray = CFArray<ProxyDictionary>;
|
||||
|
||||
#[repr(C)]
|
||||
struct CFStreamClientContext {
|
||||
version: CFIndex,
|
||||
info: *mut c_void,
|
||||
retain: Option<unsafe extern "C" fn(*mut c_void) -> *mut c_void>,
|
||||
release: Option<unsafe extern "C" fn(*mut c_void)>,
|
||||
copy_description: Option<unsafe extern "C" fn(*mut c_void) -> CFStringRef>,
|
||||
}
|
||||
|
||||
type CFProxyAutoConfigurationResultCallback =
|
||||
unsafe extern "C" fn(*mut c_void, CFArrayRef, CFErrorRef);
|
||||
|
||||
#[link(name = "CFNetwork", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
static kCFProxyTypeKey: CFStringRef;
|
||||
static kCFProxyHostNameKey: CFStringRef;
|
||||
static kCFProxyPortNumberKey: CFStringRef;
|
||||
static kCFProxyAutoConfigurationURLKey: CFStringRef;
|
||||
static kCFProxyAutoConfigurationJavaScriptKey: CFStringRef;
|
||||
static kCFProxyTypeNone: CFStringRef;
|
||||
static kCFProxyTypeHTTP: CFStringRef;
|
||||
static kCFProxyTypeHTTPS: CFStringRef;
|
||||
static kCFProxyTypeSOCKS: CFStringRef;
|
||||
static kCFProxyTypeAutoConfigurationURL: CFStringRef;
|
||||
static kCFProxyTypeAutoConfigurationJavaScript: CFStringRef;
|
||||
|
||||
fn CFNetworkCopyProxiesForURL(url: CFURLRef, proxy_settings: CFDictionaryRef) -> CFArrayRef;
|
||||
fn CFNetworkExecuteProxyAutoConfigurationURL(
|
||||
proxy_auto_config_url: CFURLRef,
|
||||
target_url: CFURLRef,
|
||||
callback: CFProxyAutoConfigurationResultCallback,
|
||||
client_context: *mut CFStreamClientContext,
|
||||
) -> CFRunLoopSourceRef;
|
||||
fn CFNetworkExecuteProxyAutoConfigurationScript(
|
||||
proxy_auto_config_script: CFStringRef,
|
||||
target_url: CFURLRef,
|
||||
callback: CFProxyAutoConfigurationResultCallback,
|
||||
client_context: *mut CFStreamClientContext,
|
||||
) -> CFRunLoopSourceRef;
|
||||
}
|
||||
|
||||
pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
|
||||
let Some(target_url) = cf_url(request_url) else {
|
||||
return SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::InvalidProxyConfig,
|
||||
};
|
||||
};
|
||||
|
||||
let Some(settings) = system_proxy_settings() else {
|
||||
return SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
};
|
||||
};
|
||||
|
||||
let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else {
|
||||
return SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
};
|
||||
};
|
||||
|
||||
proxy_array_decision(&proxies, &target_url, origin)
|
||||
}
|
||||
|
||||
fn system_proxy_settings() -> Option<CFDictionary<CFString, CFType>> {
|
||||
let store = SCDynamicStoreBuilder::new("Codex").build()?;
|
||||
store.get_proxies()
|
||||
}
|
||||
|
||||
fn copy_proxies_for_url(
|
||||
target_url: &CFURL,
|
||||
settings: &CFDictionary<CFString, CFType>,
|
||||
) -> Option<ProxyArray> {
|
||||
let proxies = unsafe {
|
||||
CFNetworkCopyProxiesForURL(
|
||||
target_url.as_concrete_TypeRef(),
|
||||
settings.as_concrete_TypeRef(),
|
||||
)
|
||||
};
|
||||
if proxies.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { ProxyArray::wrap_under_create_rule(proxies) })
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_array_decision(
|
||||
proxies: &ProxyArray,
|
||||
target_url: &CFURL,
|
||||
origin: &RequestOrigin,
|
||||
) -> SystemProxyDecision {
|
||||
let mut saw_unsupported = false;
|
||||
let mut saw_unavailable = false;
|
||||
|
||||
// CFNetwork returns candidates in failover order, but the shared resolver currently carries
|
||||
// only one route. This matches the Windows limitation; cross-platform retry requires request
|
||||
// replay semantics and is intentionally deferred.
|
||||
for proxy in proxies {
|
||||
match proxy_entry_decision(&proxy, target_url, origin) {
|
||||
ProxyEntryDecision::Direct => return SystemProxyDecision::Direct,
|
||||
ProxyEntryDecision::Proxy { url } => return SystemProxyDecision::Proxy { url },
|
||||
ProxyEntryDecision::UnsupportedScheme => saw_unsupported = true,
|
||||
ProxyEntryDecision::Unavailable => saw_unavailable = true,
|
||||
}
|
||||
}
|
||||
|
||||
if saw_unsupported {
|
||||
SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::UnsupportedProxyScheme,
|
||||
}
|
||||
} else if saw_unavailable {
|
||||
SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
}
|
||||
} else {
|
||||
SystemProxyDecision::Direct
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_entry_decision(
|
||||
proxy: &ProxyDictionary,
|
||||
target_url: &CFURL,
|
||||
origin: &RequestOrigin,
|
||||
) -> ProxyEntryDecision {
|
||||
let Some(proxy_type) = cf_string_value(proxy, unsafe { kCFProxyTypeKey }) else {
|
||||
return ProxyEntryDecision::Unavailable;
|
||||
};
|
||||
|
||||
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone }) {
|
||||
return ProxyEntryDecision::Direct;
|
||||
}
|
||||
|
||||
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP }) {
|
||||
return concrete_proxy_entry(proxy, "http");
|
||||
}
|
||||
|
||||
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS }) {
|
||||
// CFNetwork's HTTPS proxy type is a tunneling proxy for HTTPS destinations; it does not
|
||||
// preserve an explicit TLS-to-proxy transport. See https://developer.apple.com/documentation/cfnetwork/kcfproxytypehttps.
|
||||
return concrete_proxy_entry(proxy, "http");
|
||||
}
|
||||
|
||||
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeSOCKS }) {
|
||||
return ProxyEntryDecision::UnsupportedScheme;
|
||||
}
|
||||
|
||||
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL }) {
|
||||
let Some(pac_url) = cf_url_value(proxy, unsafe { kCFProxyAutoConfigurationURLKey }) else {
|
||||
return ProxyEntryDecision::Unavailable;
|
||||
};
|
||||
return pac_decision(execute_pac_url(&pac_url, target_url), target_url, origin);
|
||||
}
|
||||
|
||||
if cf_string_equals(&proxy_type, unsafe {
|
||||
kCFProxyTypeAutoConfigurationJavaScript
|
||||
}) {
|
||||
let Some(script) =
|
||||
cf_string_value(proxy, unsafe { kCFProxyAutoConfigurationJavaScriptKey })
|
||||
else {
|
||||
return ProxyEntryDecision::Unavailable;
|
||||
};
|
||||
return pac_decision(
|
||||
execute_pac(|callback, context| unsafe {
|
||||
CFNetworkExecuteProxyAutoConfigurationScript(
|
||||
script.as_concrete_TypeRef(),
|
||||
target_url.as_concrete_TypeRef(),
|
||||
callback,
|
||||
context,
|
||||
)
|
||||
}),
|
||||
target_url,
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
ProxyEntryDecision::Unavailable
|
||||
}
|
||||
|
||||
fn pac_decision(
|
||||
result: Result<ProxyArray, RouteFailureClass>,
|
||||
target_url: &CFURL,
|
||||
origin: &RequestOrigin,
|
||||
) -> ProxyEntryDecision {
|
||||
let proxies = match result {
|
||||
Ok(proxies) => proxies,
|
||||
Err(RouteFailureClass::UnsupportedProxyScheme) => {
|
||||
return ProxyEntryDecision::UnsupportedScheme;
|
||||
}
|
||||
Err(_) => return ProxyEntryDecision::Unavailable,
|
||||
};
|
||||
|
||||
match proxy_array_decision(&proxies, target_url, origin) {
|
||||
SystemProxyDecision::Direct => ProxyEntryDecision::Direct,
|
||||
SystemProxyDecision::Proxy { url } => ProxyEntryDecision::Proxy { url },
|
||||
SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::UnsupportedProxyScheme,
|
||||
} => ProxyEntryDecision::UnsupportedScheme,
|
||||
SystemProxyDecision::Unavailable { failure: _ } => ProxyEntryDecision::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_pac_url(pac_url: &CFURL, target_url: &CFURL) -> Result<ProxyArray, RouteFailureClass> {
|
||||
execute_pac(|callback, context| unsafe {
|
||||
CFNetworkExecuteProxyAutoConfigurationURL(
|
||||
pac_url.as_concrete_TypeRef(),
|
||||
target_url.as_concrete_TypeRef(),
|
||||
callback,
|
||||
context,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_pac(
|
||||
create_source: impl FnOnce(
|
||||
CFProxyAutoConfigurationResultCallback,
|
||||
*mut CFStreamClientContext,
|
||||
) -> CFRunLoopSourceRef,
|
||||
) -> Result<ProxyArray, RouteFailureClass> {
|
||||
let mut state = PacRunLoopState { result: None };
|
||||
let mut context = CFStreamClientContext {
|
||||
version: 0,
|
||||
info: (&mut state as *mut PacRunLoopState).cast::<c_void>(),
|
||||
retain: None,
|
||||
release: None,
|
||||
copy_description: None,
|
||||
};
|
||||
|
||||
let source = create_source(pac_result_callback, &mut context);
|
||||
if source.is_null() {
|
||||
return Err(RouteFailureClass::ProxyResolutionUnavailable);
|
||||
}
|
||||
|
||||
let source = unsafe { CFRunLoopSource::wrap_under_create_rule(source) };
|
||||
let run_loop = CFRunLoop::get_current();
|
||||
let mode = unsafe { kCFRunLoopDefaultMode };
|
||||
run_loop.add_source(&source, mode);
|
||||
|
||||
let started_at = Instant::now();
|
||||
while state.result.is_none() && started_at.elapsed() < PAC_EXECUTION_TIMEOUT {
|
||||
CFRunLoop::run_in_mode(mode, Duration::from_millis(50), true);
|
||||
}
|
||||
|
||||
if state.result.is_none() {
|
||||
unsafe { CFRunLoopSourceInvalidate(source.as_concrete_TypeRef()) };
|
||||
}
|
||||
run_loop.remove_source(&source, mode);
|
||||
state
|
||||
.result
|
||||
.unwrap_or(Err(RouteFailureClass::ConnectTimeout))
|
||||
}
|
||||
|
||||
unsafe extern "C" fn pac_result_callback(
|
||||
client: *mut c_void,
|
||||
proxies: CFArrayRef,
|
||||
error: CFErrorRef,
|
||||
) {
|
||||
let state = unsafe { &mut *client.cast::<PacRunLoopState>() };
|
||||
state.result = if !error.is_null() || proxies.is_null() {
|
||||
Some(Err(RouteFailureClass::ProxyResolutionUnavailable))
|
||||
} else {
|
||||
Some(Ok(unsafe { ProxyArray::wrap_under_get_rule(proxies) }))
|
||||
};
|
||||
CFRunLoop::get_current().stop();
|
||||
}
|
||||
|
||||
struct PacRunLoopState {
|
||||
result: Option<Result<ProxyArray, RouteFailureClass>>,
|
||||
}
|
||||
|
||||
fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEntryDecision {
|
||||
let Some(host) = cf_string_value(proxy, unsafe { kCFProxyHostNameKey })
|
||||
.map(|host| host.to_string())
|
||||
.filter(|host| !host.is_empty())
|
||||
else {
|
||||
return ProxyEntryDecision::Unavailable;
|
||||
};
|
||||
|
||||
let host = bracket_ipv6_host(&host);
|
||||
let url = match cf_i32_value(proxy, unsafe { kCFProxyPortNumberKey }) {
|
||||
Some(port) if port > 0 => format!("{proxy_scheme}://{host}:{port}"),
|
||||
_ => format!("{proxy_scheme}://{host}"),
|
||||
};
|
||||
ProxyEntryDecision::Proxy { url }
|
||||
}
|
||||
|
||||
fn bracket_ipv6_host(host: &str) -> String {
|
||||
if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn cf_string_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<CFString> {
|
||||
proxy
|
||||
.find(key)
|
||||
.and_then(|value| value.downcast::<CFString>())
|
||||
}
|
||||
|
||||
fn cf_i32_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<i32> {
|
||||
proxy
|
||||
.find(key)
|
||||
.and_then(|value| value.downcast::<CFNumber>())
|
||||
.and_then(|value| value.to_i32())
|
||||
}
|
||||
|
||||
fn cf_url_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<CFURL> {
|
||||
proxy.find(key).and_then(|value| {
|
||||
if unsafe { CFGetTypeID(value.as_CFTypeRef()) == CFURLGetTypeID() } {
|
||||
Some(unsafe { CFURL::wrap_under_get_rule(value.as_CFTypeRef() as CFURLRef) })
|
||||
} else {
|
||||
value
|
||||
.downcast::<CFString>()
|
||||
.and_then(|value| cf_url(value.to_string().as_str()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cf_string_equals(value: &CFString, expected: CFStringRef) -> bool {
|
||||
unsafe { CFEqual(value.as_CFTypeRef(), expected as CFTypeRef) != 0 }
|
||||
}
|
||||
|
||||
fn cf_url(value: &str) -> Option<CFURL> {
|
||||
let value = CFString::new(value);
|
||||
let url = unsafe {
|
||||
CFURLCreateWithString(
|
||||
kCFAllocatorDefault,
|
||||
value.as_concrete_TypeRef(),
|
||||
ptr::null(),
|
||||
)
|
||||
};
|
||||
if url.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { CFURL::wrap_under_create_rule(url) })
|
||||
}
|
||||
}
|
||||
|
||||
enum ProxyEntryDecision {
|
||||
Direct,
|
||||
Proxy { url: String },
|
||||
UnsupportedScheme,
|
||||
Unavailable,
|
||||
}
|
||||
@@ -84,6 +84,9 @@ async fn enabled_environment_proxy_routes_request_through_proxy() {
|
||||
request_url,
|
||||
ClientRouteClass::Auth,
|
||||
Some(&config),
|
||||
|_, _| SystemProxyDecision::Unavailable {
|
||||
failure: RouteFailureClass::ProxyResolutionUnavailable,
|
||||
},
|
||||
)
|
||||
.expect("enabled proxy route should configure");
|
||||
|
||||
@@ -204,6 +207,6 @@ fn system_proxy_cache_key_preserves_url_specific_pac_decisions() {
|
||||
cache_key,
|
||||
system_proxy_cache_key("https://auth.openai.com/oauth/revoke")
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
assert!(!cache_key.contains(request_url));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use tempfile::tempdir;
|
||||
fn convert_apply_patch_maps_add_variant() {
|
||||
let tmp = tempdir().expect("tmp");
|
||||
let path = tmp.path().join("a.txt");
|
||||
let path_uri = PathUri::from_path(&path).expect("absolute test path");
|
||||
let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path");
|
||||
let action = ApplyPatchAction::new_add_for_test(&path_uri, "hello".to_string());
|
||||
|
||||
let got = convert_apply_patch_to_protocol(&action);
|
||||
|
||||
+17
-10
@@ -112,7 +112,6 @@ use crate::feedback_tags;
|
||||
use crate::responses_metadata::CodexResponsesMetadata;
|
||||
use crate::responses_metadata::subagent_header_value;
|
||||
use crate::util::emit_feedback_auth_recovery_tags;
|
||||
use codex_api::map_api_error;
|
||||
use codex_feedback::FeedbackRequestTags;
|
||||
use codex_feedback::emit_feedback_request_tags_with_auth_env;
|
||||
use codex_login::auth_env_telemetry::AuthEnvTelemetry;
|
||||
@@ -577,7 +576,7 @@ impl ModelClient {
|
||||
turn_state.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_api_error);
|
||||
.map_err(|error| self.state.provider.map_api_error(error));
|
||||
trace_attempt.record_result(result.as_deref());
|
||||
result
|
||||
}
|
||||
@@ -604,7 +603,7 @@ impl ModelClient {
|
||||
let response = ApiRealtimeCallClient::new(transport, api_provider, client_setup.api_auth)
|
||||
.create_with_session_and_headers(sdp, session_config, extra_headers)
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
.map_err(|error| self.state.provider.map_api_error(error))?;
|
||||
Ok(RealtimeWebrtcCallStart {
|
||||
sdp: response.sdp,
|
||||
call_id: response.call_id,
|
||||
@@ -658,7 +657,7 @@ impl ModelClient {
|
||||
client
|
||||
.summarize_input(&payload, self.build_subagent_headers())
|
||||
.await
|
||||
.map_err(map_api_error)
|
||||
.map_err(|error| self.state.provider.map_api_error(error))
|
||||
}
|
||||
|
||||
fn build_subagent_headers(&self) -> ApiHeaderMap {
|
||||
@@ -1337,6 +1336,7 @@ impl ModelClientSession {
|
||||
stream,
|
||||
session_telemetry.clone(),
|
||||
inference_trace_attempt,
|
||||
Arc::clone(&self.client.state.provider),
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
@@ -1355,6 +1355,7 @@ impl ModelClientSession {
|
||||
unauthorized_transport,
|
||||
&mut auth_recovery,
|
||||
session_telemetry,
|
||||
&self.client.state.provider,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
@@ -1363,7 +1364,7 @@ impl ModelClientSession {
|
||||
Err(err) => {
|
||||
let response_debug_context =
|
||||
extract_response_debug_context_from_api_error(&err);
|
||||
let err = map_api_error(err);
|
||||
let err = self.client.state.provider.map_api_error(err);
|
||||
inference_trace_attempt.record_failed(
|
||||
&err,
|
||||
response_debug_context.request_id.as_deref(),
|
||||
@@ -1469,12 +1470,13 @@ impl ModelClientSession {
|
||||
unauthorized_transport,
|
||||
&mut auth_recovery,
|
||||
session_telemetry,
|
||||
&self.client.state.provider,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(map_api_error(err)),
|
||||
Err(err) => return Err(self.client.state.provider.map_api_error(err)),
|
||||
}
|
||||
|
||||
let (mut ws_request, previous_response_id_from_untraced_warmup) =
|
||||
@@ -1503,7 +1505,7 @@ impl ModelClientSession {
|
||||
self.websocket_session.last_response_from_untraced_warmup = warmup;
|
||||
let websocket_connection =
|
||||
self.websocket_session.connection.as_ref().ok_or_else(|| {
|
||||
map_api_error(ApiError::Stream(
|
||||
self.client.state.provider.map_api_error(ApiError::Stream(
|
||||
"websocket connection is unavailable".to_string(),
|
||||
))
|
||||
})?;
|
||||
@@ -1517,7 +1519,7 @@ impl ModelClientSession {
|
||||
.map_err(|err| {
|
||||
let response_debug_context =
|
||||
extract_response_debug_context_from_api_error(&err);
|
||||
let err = map_api_error(err);
|
||||
let err = self.client.state.provider.map_api_error(err);
|
||||
inference_trace_attempt.record_failed(
|
||||
&err,
|
||||
response_debug_context.request_id.as_deref(),
|
||||
@@ -1529,6 +1531,7 @@ impl ModelClientSession {
|
||||
stream_result,
|
||||
session_telemetry.clone(),
|
||||
inference_trace_attempt,
|
||||
Arc::clone(&self.client.state.provider),
|
||||
);
|
||||
self.websocket_session.last_response_rx = Some(last_request_rx);
|
||||
return Ok(WebsocketStreamOutcome::Stream(stream));
|
||||
@@ -1761,6 +1764,7 @@ fn map_response_stream(
|
||||
api_stream: codex_api::ResponseStream,
|
||||
session_telemetry: SessionTelemetry,
|
||||
inference_trace_attempt: InferenceTraceAttempt,
|
||||
provider: SharedModelProvider,
|
||||
) -> (ResponseStream, oneshot::Receiver<LastResponse>) {
|
||||
let codex_api::ResponseStream {
|
||||
rx_event,
|
||||
@@ -1775,6 +1779,7 @@ fn map_response_stream(
|
||||
api_stream,
|
||||
session_telemetry,
|
||||
inference_trace_attempt,
|
||||
provider,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1783,6 +1788,7 @@ fn map_response_events<S>(
|
||||
api_stream: S,
|
||||
session_telemetry: SessionTelemetry,
|
||||
inference_trace_attempt: InferenceTraceAttempt,
|
||||
provider: SharedModelProvider,
|
||||
) -> (ResponseStream, oneshot::Receiver<LastResponse>)
|
||||
where
|
||||
S: futures::Stream<Item = std::result::Result<ResponseEvent, ApiError>>
|
||||
@@ -1893,7 +1899,7 @@ where
|
||||
if let Some(upstream_request_id) = upstream_request_id {
|
||||
feedback_tags!(last_model_request_id = upstream_request_id);
|
||||
}
|
||||
let mapped = map_api_error(err);
|
||||
let mapped = provider.map_api_error(err);
|
||||
inference_trace_attempt.record_failed(
|
||||
&mapped,
|
||||
upstream_request_id,
|
||||
@@ -1999,6 +2005,7 @@ async fn handle_unauthorized(
|
||||
transport: TransportError,
|
||||
auth_recovery: &mut Option<UnauthorizedRecovery>,
|
||||
session_telemetry: &SessionTelemetry,
|
||||
provider: &SharedModelProvider,
|
||||
) -> Result<UnauthorizedRecoveryExecution> {
|
||||
let debug = extract_response_debug_context(&transport);
|
||||
if let Some(recovery) = auth_recovery
|
||||
@@ -2108,7 +2115,7 @@ async fn handle_unauthorized(
|
||||
debug.auth_error_code.as_deref(),
|
||||
);
|
||||
|
||||
Err(map_api_error(ApiError::Transport(transport)))
|
||||
Err(provider.map_api_error(ApiError::Transport(transport)))
|
||||
}
|
||||
|
||||
fn api_error_http_status(error: &ApiError) -> Option<u16> {
|
||||
|
||||
@@ -15,10 +15,13 @@ use crate::test_support::TestCodexResponsesRequestKind;
|
||||
use crate::test_support::responses_metadata as test_responses_metadata;
|
||||
use codex_api::ApiError;
|
||||
use codex_api::ResponseEvent;
|
||||
use codex_api::TransportError;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider::BearerAuthProvider;
|
||||
use codex_model_provider::SharedModelProvider;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::WireApi;
|
||||
@@ -81,6 +84,10 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
|
||||
)
|
||||
}
|
||||
|
||||
fn test_model_provider() -> SharedModelProvider {
|
||||
test_model_client(SessionSource::Cli).state.provider.clone()
|
||||
}
|
||||
|
||||
fn test_responses_metadata_for_client(
|
||||
client: &ModelClient,
|
||||
turn_id: Option<&str>,
|
||||
@@ -391,6 +398,7 @@ async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Re
|
||||
api_stream,
|
||||
test_session_telemetry(),
|
||||
attempt,
|
||||
test_model_provider(),
|
||||
);
|
||||
|
||||
let observed = stream
|
||||
@@ -440,6 +448,7 @@ async fn response_stream_records_last_model_feedback_ids() {
|
||||
api_stream,
|
||||
test_session_telemetry(),
|
||||
InferenceTraceAttempt::disabled(),
|
||||
test_model_provider(),
|
||||
);
|
||||
|
||||
while stream.next().await.is_some() {}
|
||||
@@ -455,6 +464,39 @@ async fn response_stream_records_last_model_feedback_ids() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bedrock_unauthorized_error_uses_provider_mapping() {
|
||||
let provider = create_model_provider(
|
||||
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None),
|
||||
/*auth_manager*/ None,
|
||||
);
|
||||
let mut auth_recovery = None;
|
||||
let url = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses";
|
||||
let error = super::handle_unauthorized(
|
||||
TransportError::Http {
|
||||
status: http::StatusCode::UNAUTHORIZED,
|
||||
url: Some(url.to_string()),
|
||||
headers: None,
|
||||
body: Some(
|
||||
"Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
&mut auth_recovery,
|
||||
&test_session_telemetry(),
|
||||
&provider,
|
||||
)
|
||||
.await
|
||||
.expect_err("expired Bedrock signature should fail");
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"Amazon Bedrock rejected the request because its AWS signature has expired. Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or unset it, then restart Codex, url: {url}"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropped_backpressured_response_stream_traces_cancelled_partial_output()
|
||||
-> anyhow::Result<()> {
|
||||
@@ -481,6 +523,7 @@ async fn dropped_backpressured_response_stream_traces_cancelled_partial_output()
|
||||
api_stream,
|
||||
test_session_telemetry(),
|
||||
attempt,
|
||||
test_model_provider(),
|
||||
);
|
||||
|
||||
// Fill the mapper channel with non-terminal events, then yield one output
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::session::Session;
|
||||
use super::turn_context::TurnContext;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ContextWindowTokenStatus {
|
||||
// Full active context usage, independent of the configured auto-compact scope.
|
||||
pub(crate) active_context_tokens: i64,
|
||||
// Usage counted against `model_auto_compact_token_limit` for the current scope.
|
||||
pub(crate) auto_compact_scope_tokens: i64,
|
||||
pub(crate) auto_compact_scope_limit: i64,
|
||||
pub(crate) full_context_window_limit: Option<i64>,
|
||||
pub(crate) auto_compact_window_prefill_tokens: Option<i64>,
|
||||
pub(crate) full_context_window_limit_reached: bool,
|
||||
pub(crate) token_limit_reached: bool,
|
||||
pub(crate) context_remaining_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
impl ContextWindowTokenStatus {
|
||||
pub(crate) fn tokens_until_compaction(&self) -> i64 {
|
||||
let full_context_remaining = self.full_context_window_limit.map_or(i64::MAX, |limit| {
|
||||
limit.saturating_sub(self.active_context_tokens)
|
||||
});
|
||||
self.auto_compact_scope_limit
|
||||
.saturating_sub(self.auto_compact_scope_tokens)
|
||||
.min(full_context_remaining)
|
||||
.max(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn context_window_token_status(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
) -> ContextWindowTokenStatus {
|
||||
let active_context_tokens = self.get_total_token_usage().await;
|
||||
let mut auto_compact_window_prefill_tokens = None;
|
||||
let mut has_context_remaining_limit = false;
|
||||
let (auto_compact_scope_tokens, auto_compact_scope_limit, full_context_window_limit) =
|
||||
match turn_context.config.model_auto_compact_token_limit_scope {
|
||||
AutoCompactTokenLimitScope::Total => (
|
||||
active_context_tokens,
|
||||
turn_context
|
||||
.model_info
|
||||
.auto_compact_token_limit()
|
||||
.unwrap_or(i64::MAX),
|
||||
None,
|
||||
),
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix => {
|
||||
let window = self.auto_compact_window_snapshot().await;
|
||||
auto_compact_window_prefill_tokens = window.prefill_input_tokens;
|
||||
let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens);
|
||||
let scope_limit = turn_context
|
||||
.config
|
||||
.model_auto_compact_token_limit
|
||||
.or_else(|| turn_context.model_info.auto_compact_token_limit());
|
||||
let full_context_window_limit = turn_context.model_context_window();
|
||||
has_context_remaining_limit =
|
||||
scope_limit.is_some() || full_context_window_limit.is_some();
|
||||
(
|
||||
active_context_tokens.saturating_sub(baseline),
|
||||
scope_limit.unwrap_or(i64::MAX),
|
||||
full_context_window_limit,
|
||||
)
|
||||
}
|
||||
};
|
||||
let full_context_window_limit_reached =
|
||||
full_context_window_limit.is_some_and(|full_context_window_limit| {
|
||||
active_context_tokens >= full_context_window_limit
|
||||
});
|
||||
let token_limit_reached = auto_compact_scope_tokens >= auto_compact_scope_limit
|
||||
|| full_context_window_limit_reached;
|
||||
let mut status = ContextWindowTokenStatus {
|
||||
active_context_tokens,
|
||||
auto_compact_scope_tokens,
|
||||
auto_compact_scope_limit,
|
||||
full_context_window_limit,
|
||||
auto_compact_window_prefill_tokens,
|
||||
full_context_window_limit_reached,
|
||||
token_limit_reached,
|
||||
context_remaining_tokens: None,
|
||||
};
|
||||
status.context_remaining_tokens =
|
||||
match turn_context.config.model_auto_compact_token_limit_scope {
|
||||
AutoCompactTokenLimitScope::Total => turn_context
|
||||
.model_context_window()
|
||||
.map(|limit| limit.saturating_sub(active_context_tokens).max(0)),
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix if has_context_remaining_limit => {
|
||||
Some(status.tokens_until_compaction())
|
||||
}
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix => None,
|
||||
};
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,7 @@ use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
|
||||
mod config_lock;
|
||||
pub(crate) mod context_window;
|
||||
mod handlers;
|
||||
mod inject;
|
||||
mod input_queue;
|
||||
|
||||
@@ -294,8 +294,9 @@ pub(crate) async fn run_turn(
|
||||
let (has_pending_input, token_status, estimated_token_count) = async {
|
||||
let has_pending_input =
|
||||
sess.input_queue.has_pending_input(&sess.active_turn).await;
|
||||
let token_status =
|
||||
auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
|
||||
let token_status = sess
|
||||
.context_window_token_status(turn_context.as_ref())
|
||||
.await;
|
||||
let estimated_token_count =
|
||||
sess.get_estimated_token_count(turn_context.as_ref()).await;
|
||||
(has_pending_input, token_status, estimated_token_count)
|
||||
@@ -322,17 +323,7 @@ pub(crate) async fn run_turn(
|
||||
"post sampling token usage"
|
||||
);
|
||||
|
||||
let tokens_after_sampling = token_status.active_context_tokens;
|
||||
let full_context_remaining = token_status
|
||||
.full_context_window_limit
|
||||
.map_or(i64::MAX, |limit| {
|
||||
limit.saturating_sub(tokens_after_sampling)
|
||||
});
|
||||
let tokens_until_compaction = token_status
|
||||
.auto_compact_scope_limit
|
||||
.saturating_sub(token_status.auto_compact_scope_tokens)
|
||||
.min(full_context_remaining)
|
||||
.max(0);
|
||||
let tokens_until_compaction = token_status.tokens_until_compaction();
|
||||
super::token_budget::maybe_record(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -802,68 +793,6 @@ async fn track_turn_resolved_config_analytics(
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AutoCompactTokenStatus {
|
||||
// Full active context usage, independent of the configured auto-compact scope.
|
||||
active_context_tokens: i64,
|
||||
// Usage counted against `model_auto_compact_token_limit` for the current scope.
|
||||
auto_compact_scope_tokens: i64,
|
||||
auto_compact_scope_limit: i64,
|
||||
full_context_window_limit: Option<i64>,
|
||||
auto_compact_window_prefill_tokens: Option<i64>,
|
||||
full_context_window_limit_reached: bool,
|
||||
token_limit_reached: bool,
|
||||
}
|
||||
|
||||
async fn auto_compact_token_status(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
) -> AutoCompactTokenStatus {
|
||||
let active_context_tokens = sess.get_total_token_usage().await;
|
||||
let mut auto_compact_window_prefill_tokens = None;
|
||||
let (auto_compact_scope_tokens, auto_compact_scope_limit, full_context_window_limit) =
|
||||
match turn_context.config.model_auto_compact_token_limit_scope {
|
||||
AutoCompactTokenLimitScope::Total => (
|
||||
active_context_tokens,
|
||||
turn_context
|
||||
.model_info
|
||||
.auto_compact_token_limit()
|
||||
.unwrap_or(i64::MAX),
|
||||
None,
|
||||
),
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix => {
|
||||
let window = sess.auto_compact_window_snapshot().await;
|
||||
auto_compact_window_prefill_tokens = window.prefill_input_tokens;
|
||||
let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens);
|
||||
(
|
||||
active_context_tokens.saturating_sub(baseline),
|
||||
turn_context
|
||||
.config
|
||||
.model_auto_compact_token_limit
|
||||
.or_else(|| turn_context.model_info.auto_compact_token_limit())
|
||||
.unwrap_or(i64::MAX),
|
||||
turn_context.model_context_window(),
|
||||
)
|
||||
}
|
||||
};
|
||||
let full_context_window_limit_reached =
|
||||
full_context_window_limit.is_some_and(|full_context_window_limit| {
|
||||
active_context_tokens >= full_context_window_limit
|
||||
});
|
||||
let token_limit_reached =
|
||||
auto_compact_scope_tokens >= auto_compact_scope_limit || full_context_window_limit_reached;
|
||||
|
||||
AutoCompactTokenStatus {
|
||||
active_context_tokens,
|
||||
auto_compact_scope_tokens,
|
||||
auto_compact_scope_limit,
|
||||
full_context_window_limit,
|
||||
auto_compact_window_prefill_tokens,
|
||||
full_context_window_limit_reached,
|
||||
token_limit_reached,
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
async fn run_pre_sampling_compact(
|
||||
sess: &Arc<Session>,
|
||||
@@ -879,7 +808,9 @@ async fn run_pre_sampling_compact(
|
||||
}
|
||||
|
||||
maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?;
|
||||
let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
|
||||
let token_status = sess
|
||||
.context_window_token_status(turn_context.as_ref())
|
||||
.await;
|
||||
// Compact if the configured auto-compaction budget or usable context window is exhausted.
|
||||
if token_status.token_limit_reached {
|
||||
run_auto_compact(
|
||||
|
||||
@@ -645,7 +645,7 @@ mod tests {
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
|
||||
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let cwd = PathUri::from_path(dir.path()).expect("absolute cwd");
|
||||
let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd");
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let delta = codex_apply_patch::apply_patch(
|
||||
@@ -729,7 +729,7 @@ mod tests {
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
|
||||
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let cwd = PathUri::from_path(dir.path()).expect("absolute cwd");
|
||||
let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd");
|
||||
|
||||
for patch in [
|
||||
"*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch",
|
||||
@@ -782,7 +782,7 @@ mod tests {
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await;
|
||||
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let cwd = PathUri::from_path(dir.path()).expect("absolute cwd");
|
||||
let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd");
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let delta = codex_apply_patch::apply_patch(
|
||||
|
||||
@@ -75,19 +75,14 @@ impl ToolExecutor<ToolInvocation> for GetContextRemainingHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let Some(model_context_window) = invocation.turn.model_context_window() else {
|
||||
return Ok(boxed_tool_output(GetContextRemainingOutput::new(
|
||||
/*tokens_left*/ None,
|
||||
)));
|
||||
};
|
||||
let active_context_tokens = invocation.session.get_total_token_usage().await.max(0);
|
||||
let tokens_left = model_context_window
|
||||
.saturating_sub(active_context_tokens)
|
||||
.max(0);
|
||||
let token_status = invocation
|
||||
.session
|
||||
.context_window_token_status(invocation.turn.as_ref())
|
||||
.await;
|
||||
|
||||
Ok(boxed_tool_output(GetContextRemainingOutput::new(Some(
|
||||
tokens_left,
|
||||
))))
|
||||
Ok(boxed_tool_output(GetContextRemainingOutput::new(
|
||||
token_status.context_remaining_tokens,
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ fn git_blob_sha1_hex(data: &str) -> String {
|
||||
}
|
||||
|
||||
async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta {
|
||||
let cwd = PathUri::from_path(root).expect("absolute tempdir path");
|
||||
let cwd = PathUri::from_host_native_path(root).expect("absolute tempdir path");
|
||||
let argv = vec!["apply_patch".to_string(), patch.to_string()];
|
||||
match codex_apply_patch::maybe_parse_apply_patch_verified(
|
||||
&argv,
|
||||
|
||||
@@ -998,7 +998,7 @@ impl TestCodexHarness {
|
||||
) -> Result<()> {
|
||||
let abs_path = self.path_abs(rel);
|
||||
if let Some(parent) = abs_path.parent() {
|
||||
let parent_uri = PathUri::from_path(&parent)?;
|
||||
let parent_uri = PathUri::from_host_native_path(&parent)?;
|
||||
self.test
|
||||
.fs()
|
||||
.create_directory(
|
||||
@@ -1008,7 +1008,7 @@ impl TestCodexHarness {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let abs_path_uri = PathUri::from_path(&abs_path)?;
|
||||
let abs_path_uri = PathUri::from_host_native_path(&abs_path)?;
|
||||
self.test
|
||||
.fs()
|
||||
.write_file(
|
||||
@@ -1022,7 +1022,7 @@ impl TestCodexHarness {
|
||||
|
||||
pub async fn read_file_text(&self, rel: impl AsRef<Path>) -> Result<String> {
|
||||
let path = self.path_abs(rel);
|
||||
let path_uri = PathUri::from_path(&path)?;
|
||||
let path_uri = PathUri::from_host_native_path(&path)?;
|
||||
Ok(self
|
||||
.test
|
||||
.fs()
|
||||
@@ -1032,7 +1032,7 @@ impl TestCodexHarness {
|
||||
|
||||
pub async fn create_dir_all(&self, rel: impl AsRef<Path>) -> Result<()> {
|
||||
let path = self.path_abs(rel);
|
||||
let path_uri = PathUri::from_path(&path)?;
|
||||
let path_uri = PathUri::from_host_native_path(&path)?;
|
||||
self.test
|
||||
.fs()
|
||||
.create_directory(
|
||||
|
||||
@@ -141,8 +141,8 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> {
|
||||
agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move {
|
||||
let agents_md = cwd.join("AGENTS.md");
|
||||
let override_md = cwd.join("AGENTS.override.md");
|
||||
let agents_md_uri = PathUri::from_path(&agents_md)?;
|
||||
let override_md_uri = PathUri::from_path(&override_md)?;
|
||||
let agents_md_uri = PathUri::from_host_native_path(&agents_md)?;
|
||||
let override_md_uri = PathUri::from_host_native_path(&override_md)?;
|
||||
fs.write_file(&agents_md_uri, b"base doc".to_vec(), /*sandbox*/ None)
|
||||
.await?;
|
||||
fs.write_file(
|
||||
@@ -177,8 +177,8 @@ async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Res
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let agents_dir = cwd.join("AGENTS.md");
|
||||
let fallback = cwd.join("WORKFLOW.md");
|
||||
let agents_dir_uri = PathUri::from_path(&agents_dir)?;
|
||||
let fallback_uri = PathUri::from_path(&fallback)?;
|
||||
let agents_dir_uri = PathUri::from_host_native_path(&agents_dir)?;
|
||||
let fallback_uri = PathUri::from_host_native_path(&fallback)?;
|
||||
fs.create_directory(
|
||||
&agents_dir_uri,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
@@ -220,10 +220,10 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> {
|
||||
let root_agents = root.join("AGENTS.md");
|
||||
let git_marker = root.join(".git");
|
||||
let nested_agents = nested.join("AGENTS.md");
|
||||
let nested_uri = PathUri::from_path(&nested)?;
|
||||
let root_agents_uri = PathUri::from_path(&root_agents)?;
|
||||
let git_marker_uri = PathUri::from_path(&git_marker)?;
|
||||
let nested_agents_uri = PathUri::from_path(&nested_agents)?;
|
||||
let nested_uri = PathUri::from_host_native_path(&nested)?;
|
||||
let root_agents_uri = PathUri::from_host_native_path(&root_agents)?;
|
||||
let git_marker_uri = PathUri::from_host_native_path(&git_marker)?;
|
||||
let nested_agents_uri = PathUri::from_host_native_path(&nested_agents)?;
|
||||
|
||||
fs.create_directory(
|
||||
&nested_uri,
|
||||
@@ -363,7 +363,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu
|
||||
let mut builder = test_codex()
|
||||
.with_home(home)
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let agents_md_uri = PathUri::from_path(cwd.join("AGENTS.md"))?;
|
||||
let agents_md_uri = PathUri::from_host_native_path(cwd.join("AGENTS.md"))?;
|
||||
fs.write_file(
|
||||
&agents_md_uri,
|
||||
b"project doc".to_vec(),
|
||||
@@ -420,7 +420,8 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> {
|
||||
.with_home(Arc::clone(&home))
|
||||
.with_user_instructions_provider(provider.clone())
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let project_agents_uri = PathUri::from_path(cwd.join(GLOBAL_AGENTS_FILENAME))?;
|
||||
let project_agents_uri =
|
||||
PathUri::from_host_native_path(cwd.join(GLOBAL_AGENTS_FILENAME))?;
|
||||
fs.write_file(
|
||||
&project_agents_uri,
|
||||
PROJECT_INSTRUCTIONS.as_bytes().to_vec(),
|
||||
@@ -505,7 +506,7 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re
|
||||
let mut builder = test_codex()
|
||||
.with_home(Arc::clone(&home))
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let agents_md_uri = PathUri::from_path(cwd.join("AGENTS.md"))?;
|
||||
let agents_md_uri = PathUri::from_host_native_path(cwd.join("AGENTS.md"))?;
|
||||
fs.write_file(
|
||||
&agents_md_uri,
|
||||
PROJECT_INSTRUCTIONS.as_bytes().to_vec(),
|
||||
@@ -534,7 +535,7 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re
|
||||
)?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&PathUri::from_path(&project_source)?,
|
||||
&PathUri::from_host_native_path(&project_source)?,
|
||||
NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -631,7 +632,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
|
||||
.with_user_instructions_provider(provider.clone())
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
fs.write_file(
|
||||
&PathUri::from_path(cwd.join(GLOBAL_AGENTS_FILENAME))?,
|
||||
&PathUri::from_host_native_path(cwd.join(GLOBAL_AGENTS_FILENAME))?,
|
||||
b"remote project instructions".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -658,7 +659,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: PathUri::from_path(local_root.path())?,
|
||||
cwd: PathUri::from_host_native_path(local_root.path())?,
|
||||
},
|
||||
],
|
||||
thread_extension_init: Default::default(),
|
||||
@@ -671,7 +672,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
|
||||
vec![
|
||||
PathUri::from_abs_path(&global_source),
|
||||
PathUri::from_abs_path(&remote_source),
|
||||
PathUri::from_path(&local_source)?,
|
||||
PathUri::from_host_native_path(&local_source)?,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -684,7 +685,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
|
||||
)?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&PathUri::from_path(test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME))?,
|
||||
&PathUri::from_host_native_path(test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME))?,
|
||||
b"new remote project instructions".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -712,7 +713,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
|
||||
vec![
|
||||
PathUri::from_abs_path(&global_source),
|
||||
PathUri::from_abs_path(&remote_source),
|
||||
PathUri::from_path(&local_source)?,
|
||||
PathUri::from_host_native_path(&local_source)?,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -1329,7 +1329,7 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest
|
||||
config.cwd = config.cwd.join("subdir");
|
||||
})
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let cwd_uri = PathUri::from_path(&cwd)?;
|
||||
let cwd_uri = PathUri::from_host_native_path(&cwd)?;
|
||||
fs.create_directory(
|
||||
&cwd_uri,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
@@ -1337,8 +1337,8 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest
|
||||
)
|
||||
.await?;
|
||||
let repo_root = cwd.parent().expect("nested cwd should have parent");
|
||||
let git_uri = PathUri::from_path(repo_root.join(".git"))?;
|
||||
let repo_file_uri = PathUri::from_path(repo_root.join("repo.txt"))?;
|
||||
let git_uri = PathUri::from_host_native_path(repo_root.join(".git"))?;
|
||||
let repo_file_uri = PathUri::from_host_native_path(repo_root.join("repo.txt"))?;
|
||||
fs.write_file(
|
||||
&git_uri,
|
||||
b"gitdir: /tmp/fake-worktree\n".to_vec(),
|
||||
@@ -1586,7 +1586,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let shared_cwd_uri = PathUri::from_path(&shared_cwd)?;
|
||||
let shared_cwd_uri = PathUri::from_host_native_path(&shared_cwd)?;
|
||||
let _ = fs::remove_dir_all(shared_cwd.as_path());
|
||||
test.fs()
|
||||
.remove(
|
||||
@@ -1689,7 +1689,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re
|
||||
assert_eq!(
|
||||
test.fs()
|
||||
.read_file_text(
|
||||
&PathUri::from_path(shared_cwd.join(file_name))?,
|
||||
&PathUri::from_host_native_path(shared_cwd.join(file_name))?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?,
|
||||
@@ -1859,7 +1859,7 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()>
|
||||
|
||||
let harness = apply_patch_harness_with(|builder| {
|
||||
builder.with_workspace_setup(|cwd, fs| async move {
|
||||
let binary_path_uri = PathUri::from_path(cwd.join("binary.dat"))?;
|
||||
let binary_path_uri = PathUri::from_host_native_path(cwd.join("binary.dat"))?;
|
||||
fs.write_file(
|
||||
&binary_path_uri,
|
||||
vec![0xff, 0xfe, 0xfd],
|
||||
|
||||
@@ -74,7 +74,7 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() ->
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
||||
let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd_uri,
|
||||
|
||||
@@ -173,7 +173,7 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
|
||||
let file_system = test_env.environment().get_filesystem();
|
||||
|
||||
let file_path_abs = test_env.cwd().join("remote-test-env-ok");
|
||||
let file_path_uri = PathUri::from_path(&file_path_abs)?;
|
||||
let file_path_uri = PathUri::from_host_native_path(&file_path_abs)?;
|
||||
let payload = b"remote-test-env-ok".to_vec();
|
||||
|
||||
file_system
|
||||
@@ -329,8 +329,8 @@ async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> {
|
||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
|
||||
let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs();
|
||||
let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs();
|
||||
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
||||
let target_uri = PathUri::from_path(&target_path)?;
|
||||
let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?;
|
||||
let target_uri = PathUri::from_host_native_path(&target_path)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd_uri,
|
||||
@@ -801,8 +801,8 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> {
|
||||
))
|
||||
.abs();
|
||||
let remote_marker_name = "marker.txt";
|
||||
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
||||
let remote_marker_uri = PathUri::from_path(remote_cwd.join(remote_marker_name))?;
|
||||
let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?;
|
||||
let remote_marker_uri = PathUri::from_host_native_path(remote_cwd.join(remote_marker_name))?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd_uri,
|
||||
@@ -900,7 +900,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result
|
||||
let local_write_root = local_cwd.path().join(relative_write_root);
|
||||
let local_target_path = local_cwd.path().join(relative_target_path);
|
||||
fs::create_dir(&local_write_root)?;
|
||||
let remote_write_root_uri = PathUri::from_path(&remote_write_root)?;
|
||||
let remote_write_root_uri = PathUri::from_host_native_path(&remote_write_root)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_write_root_uri,
|
||||
@@ -1040,7 +1040,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result
|
||||
assert_eq!(
|
||||
test.fs()
|
||||
.read_file_text(
|
||||
&PathUri::from_path(&remote_target_path)?,
|
||||
&PathUri::from_host_native_path(&remote_target_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?,
|
||||
@@ -1084,7 +1084,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
||||
let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd_uri,
|
||||
@@ -1129,7 +1129,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<
|
||||
let remote_contents = test
|
||||
.fs()
|
||||
.read_file_text(
|
||||
&PathUri::from_path(remote_cwd.join(file_name))?,
|
||||
&PathUri::from_host_native_path(remote_cwd.join(file_name))?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
@@ -1174,7 +1174,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
||||
let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd_uri,
|
||||
@@ -1188,7 +1188,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let target_path_uri = PathUri::from_path(&target_path)?;
|
||||
let target_path_uri = PathUri::from_host_native_path(&target_path)?;
|
||||
let _ = fs::remove_file(&target_path);
|
||||
test.fs()
|
||||
.remove(
|
||||
@@ -1362,7 +1362,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
||||
let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd_uri,
|
||||
@@ -1417,7 +1417,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm
|
||||
let remote_contents = test
|
||||
.fs()
|
||||
.read_file_text(
|
||||
&PathUri::from_path(remote_cwd.join(file_name))?,
|
||||
&PathUri::from_host_native_path(remote_cwd.join(file_name))?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
@@ -1455,8 +1455,8 @@ async fn remote_test_env_sandboxed_read_allows_readable_root() -> Result<()> {
|
||||
|
||||
let allowed_dir = PathBuf::from(format!("/tmp/codex-remote-readable-{}", std::process::id()));
|
||||
let file_path = allowed_dir.join("note.txt");
|
||||
let allowed_dir_uri = PathUri::from_path(&allowed_dir)?;
|
||||
let file_path_uri = PathUri::from_path(&file_path)?;
|
||||
let allowed_dir_uri = PathUri::from_host_native_path(&allowed_dir)?;
|
||||
let file_path_uri = PathUri::from_host_native_path(&file_path)?;
|
||||
file_system
|
||||
.create_directory(
|
||||
&allowed_dir_uri,
|
||||
@@ -1516,7 +1516,7 @@ async fn remote_test_env_sandboxed_read_rejects_symlink_parent_dotdot_escape() -
|
||||
))?;
|
||||
|
||||
let requested_path =
|
||||
PathUri::from_path(allowed_dir.join("link").join("..").join("secret.txt"))?;
|
||||
PathUri::from_host_native_path(allowed_dir.join("link").join("..").join("secret.txt"))?;
|
||||
let sandbox = read_only_sandbox(allowed_dir.clone());
|
||||
let error = match file_system.read_file(&requested_path, Some(&sandbox)).await {
|
||||
Ok(_) => anyhow::bail!("read should fail after path normalization"),
|
||||
@@ -1564,7 +1564,7 @@ async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> {
|
||||
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
||||
file_system
|
||||
.remove(
|
||||
&PathUri::from_path(&symlink_path)?,
|
||||
&PathUri::from_host_native_path(&symlink_path)?,
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
force: false,
|
||||
@@ -1582,13 +1582,16 @@ async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> {
|
||||
.is_ok();
|
||||
assert!(!symlink_exists);
|
||||
let outside = file_system
|
||||
.read_file_text(&PathUri::from_path(&outside_file)?, /*sandbox*/ None)
|
||||
.read_file_text(
|
||||
&PathUri::from_host_native_path(&outside_file)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(outside, "outside");
|
||||
|
||||
file_system
|
||||
.remove(
|
||||
&PathUri::from_path(&root)?,
|
||||
&PathUri::from_host_native_path(&root)?,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
@@ -1630,8 +1633,8 @@ async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> {
|
||||
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
||||
file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_symlink)?,
|
||||
&PathUri::from_path(&copied_symlink)?,
|
||||
&PathUri::from_host_native_path(&source_symlink)?,
|
||||
&PathUri::from_host_native_path(&copied_symlink)?,
|
||||
CopyOptions { recursive: false },
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -1664,7 +1667,7 @@ async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> {
|
||||
|
||||
file_system
|
||||
.remove(
|
||||
&PathUri::from_path(&root)?,
|
||||
&PathUri::from_host_native_path(&root)?,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
|
||||
@@ -747,7 +747,7 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R
|
||||
let fixture = test_codex()
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let configured_cwd = cwd.join("mcp-configured-cwd");
|
||||
let configured_cwd_uri = PathUri::from_path(&configured_cwd)?;
|
||||
let configured_cwd_uri = PathUri::from_host_native_path(&configured_cwd)?;
|
||||
fs.create_directory(
|
||||
&configured_cwd_uri,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
|
||||
@@ -31,7 +31,7 @@ async fn write_repo_skill(
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
let skill_dir = cwd.join(".agents").join("skills").join(name);
|
||||
let skill_dir_uri = PathUri::from_path(&skill_dir)?;
|
||||
let skill_dir_uri = PathUri::from_host_native_path(&skill_dir)?;
|
||||
fs.create_directory(
|
||||
&skill_dir_uri,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
@@ -40,7 +40,7 @@ async fn write_repo_skill(
|
||||
.await?;
|
||||
let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n");
|
||||
let path = skill_dir.join("SKILL.md");
|
||||
let path_uri = PathUri::from_path(&path)?;
|
||||
let path_uri = PathUri::from_host_native_path(&path)?;
|
||||
fs.write_file(&path_uri, contents.into_bytes(), /*sandbox*/ None)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core::config::TokenBudgetConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG;
|
||||
use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -79,6 +80,22 @@ fn tool_names(request: &ResponsesRequest) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ev_completed_with_usage(id: &str, input_tokens: i64, output_tokens: i64) -> Value {
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": id,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"input_tokens_details": null,
|
||||
"output_tokens": output_tokens,
|
||||
"output_tokens_details": null,
|
||||
"total_tokens": input_tokens + output_tokens
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -264,6 +281,70 @@ async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn token_budget_reminder_uses_body_after_prefix_window() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_completed_with_tokens("resp-1", /*total_tokens*/ 8_000),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_completed_with_tokens("resp-2", /*total_tokens*/ 8_600),
|
||||
]),
|
||||
sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.model_context_window = Some(10_000);
|
||||
config.model_auto_compact_token_limit = Some(1_000);
|
||||
config.model_auto_compact_token_limit_scope =
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix;
|
||||
config.token_budget = Some(TokenBudgetConfig {
|
||||
reminder_threshold_tokens: Some(600),
|
||||
..TokenBudgetConfig::default()
|
||||
});
|
||||
config
|
||||
.features
|
||||
.enable(Feature::TokenBudget)
|
||||
.expect("test config should allow token budget");
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
test.submit_turn("establish prefix").await?;
|
||||
test.submit_turn("grow body").await?;
|
||||
test.submit_turn("observe reminder").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
let reminder = "Your context window is nearly exhausted (only 400 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows.";
|
||||
assert!(
|
||||
requests[1]
|
||||
.message_input_texts("developer")
|
||||
.into_iter()
|
||||
.all(|text| text != reminder),
|
||||
"first-window prefix should not count against the body-after-prefix reminder threshold"
|
||||
);
|
||||
assert_eq!(
|
||||
requests[2]
|
||||
.message_input_texts("developer")
|
||||
.into_iter()
|
||||
.filter(|text| text == reminder)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -327,6 +408,70 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_context_remaining_uses_body_after_prefix_window() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "remaining-call";
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "noted"),
|
||||
ev_completed_with_usage(
|
||||
"resp-1", /*input_tokens*/ 2_000, /*output_tokens*/ 500,
|
||||
),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_function_call(call_id, "get_context_remaining", "{}"),
|
||||
ev_completed_with_tokens("resp-2", /*total_tokens*/ 2_500),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_assistant_message("msg-3", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.model_context_window = Some(10_000);
|
||||
config.model_auto_compact_token_limit = Some(7_000);
|
||||
config.model_auto_compact_token_limit_scope =
|
||||
AutoCompactTokenLimitScope::BodyAfterPrefix;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::TokenBudget)
|
||||
.expect("test config should allow token budget");
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
test.submit_turn("spend some tokens").await?;
|
||||
test.submit_turn("check remaining context").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert!(
|
||||
tool_names(&requests[1])
|
||||
.iter()
|
||||
.any(|name| name == "get_context_remaining"),
|
||||
"get_context_remaining should be exposed when token budget is enabled"
|
||||
);
|
||||
|
||||
let remaining_context = "You have 6500 tokens left in this context window.".to_string();
|
||||
assert_eq!(
|
||||
requests[2].function_call_output_content_and_success(call_id),
|
||||
Some((Some(remaining_context), None))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_context_remaining_returns_unknown_when_window_is_unavailable() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -229,7 +229,7 @@ async fn create_workspace_directory(
|
||||
rel_path: impl AsRef<std::path::Path>,
|
||||
) -> Result<std::path::PathBuf> {
|
||||
let abs_path = test.config.cwd.join(rel_path.as_ref());
|
||||
let abs_path_uri = PathUri::from_path(&abs_path)?;
|
||||
let abs_path_uri = PathUri::from_host_native_path(&abs_path)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&abs_path_uri,
|
||||
@@ -437,7 +437,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
|
||||
assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec");
|
||||
|
||||
assert_eq!(begin_event.cwd, PathUri::from_path(&cwd)?);
|
||||
assert_eq!(begin_event.cwd, PathUri::from_host_native_path(&cwd)?);
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
@@ -508,7 +508,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
|
||||
|
||||
assert_eq!(
|
||||
begin_event.cwd,
|
||||
PathUri::from_path(&workdir)?,
|
||||
PathUri::from_host_native_path(&workdir)?,
|
||||
"exec_command cwd should resolve relative workdir against turn cwd",
|
||||
);
|
||||
|
||||
@@ -570,7 +570,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
|
||||
assert_eq!(
|
||||
begin_event.cwd,
|
||||
PathUri::from_path(&workdir)?,
|
||||
PathUri::from_host_native_path(&workdir)?,
|
||||
"exec_command cwd should reflect the requested workdir override"
|
||||
);
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result<Vec<u8>>
|
||||
|
||||
async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result<PathBuf> {
|
||||
let abs_path = test.config.cwd.join(rel_path);
|
||||
let abs_path_uri = PathUri::from_path(&abs_path)?;
|
||||
let abs_path_uri = PathUri::from_host_native_path(&abs_path)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&abs_path_uri,
|
||||
@@ -151,7 +151,7 @@ async fn write_workspace_file(
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let abs_path = test.config.cwd.join(rel_path);
|
||||
if let Some(parent) = abs_path.parent() {
|
||||
let parent_uri = PathUri::from_path(&parent)?;
|
||||
let parent_uri = PathUri::from_host_native_path(&parent)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&parent_uri,
|
||||
@@ -160,7 +160,7 @@ async fn write_workspace_file(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let abs_path_uri = PathUri::from_path(&abs_path)?;
|
||||
let abs_path_uri = PathUri::from_host_native_path(&abs_path)?;
|
||||
test.fs()
|
||||
.write_file(&abs_path_uri, contents, /*sandbox*/ None)
|
||||
.await?;
|
||||
@@ -589,7 +589,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<()
|
||||
))
|
||||
.abs();
|
||||
let image_path = remote_cwd.join("remote.png");
|
||||
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
||||
let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd_uri,
|
||||
@@ -598,7 +598,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<()
|
||||
)
|
||||
.await?;
|
||||
let png = png_bytes(/*width*/ 1, /*height*/ 1, [0, 255, 0, 255])?;
|
||||
let image_path_uri = PathUri::from_path(&image_path)?;
|
||||
let image_path_uri = PathUri::from_host_native_path(&image_path)?;
|
||||
test.fs()
|
||||
.write_file(&image_path_uri, png, /*sandbox*/ None)
|
||||
.await?;
|
||||
|
||||
@@ -1151,8 +1151,10 @@ mod tests {
|
||||
.start(crate::ExecParams {
|
||||
process_id: ProcessId::from("default-env-proc"),
|
||||
argv: vec!["true".to_string()],
|
||||
cwd: PathUri::from_path(std::env::current_dir().expect("read current dir"))
|
||||
.expect("cwd URI"),
|
||||
cwd: PathUri::from_host_native_path(
|
||||
std::env::current_dir().expect("read current dir"),
|
||||
)
|
||||
.expect("cwd URI"),
|
||||
env_policy: None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -1188,8 +1190,10 @@ mod tests {
|
||||
.start(crate::ExecParams {
|
||||
process_id: ProcessId::from("local-sandbox-proc"),
|
||||
argv: vec!["true".to_string()],
|
||||
cwd: PathUri::from_path(std::env::current_dir().expect("read current dir"))
|
||||
.expect("cwd URI"),
|
||||
cwd: PathUri::from_host_native_path(
|
||||
std::env::current_dir().expect("read current dir"),
|
||||
)
|
||||
.expect("cwd URI"),
|
||||
env_policy: None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
|
||||
@@ -314,8 +314,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn helper_protocol_uses_path_uris() -> serde_json::Result<()> {
|
||||
let local_path = PathUri::from_path(std::env::current_dir().expect("cwd").join("file"))
|
||||
.expect("path URI");
|
||||
let local_path =
|
||||
PathUri::from_host_native_path(std::env::current_dir().expect("cwd").join("file"))
|
||||
.expect("path URI");
|
||||
let paths = [
|
||||
local_path,
|
||||
PathUri::parse("file://server/share/file").expect("path URI"),
|
||||
|
||||
@@ -956,7 +956,8 @@ mod tests {
|
||||
ExecParams {
|
||||
process_id: ProcessId::from("env-test"),
|
||||
argv: vec!["true".to_string()],
|
||||
cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"),
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd"))
|
||||
.expect("cwd URI"),
|
||||
env_policy: None,
|
||||
env,
|
||||
tty: false,
|
||||
|
||||
@@ -519,7 +519,7 @@ mod tests {
|
||||
let legacy_cwd = std::env::current_dir().expect("current directory");
|
||||
let native_sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
|
||||
PermissionProfile::default(),
|
||||
PathUri::from_path(&legacy_cwd).expect("cwd URI"),
|
||||
PathUri::from_host_native_path(&legacy_cwd).expect("cwd URI"),
|
||||
);
|
||||
let mut legacy_sandbox =
|
||||
serde_json::to_value(&native_sandbox).expect("sandbox should serialize");
|
||||
@@ -531,7 +531,7 @@ mod tests {
|
||||
.expect("legacy absolute path should deserialize");
|
||||
let expected_sandbox = native_sandbox;
|
||||
let expected = FsReadFileParams {
|
||||
path: PathUri::from_path(legacy_path).expect("path URI"),
|
||||
path: PathUri::from_host_native_path(legacy_path).expect("path URI"),
|
||||
sandbox: Some(expected_sandbox.clone()),
|
||||
};
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ mod tests {
|
||||
)
|
||||
.expect("runtime paths");
|
||||
let handler = FileSystemHandler::new(runtime_paths);
|
||||
let sandbox_cwd = PathUri::from_path(temp_dir.path()).expect("tempdir URI");
|
||||
let sandbox_cwd = PathUri::from_host_native_path(temp_dir.path()).expect("tempdir URI");
|
||||
let sandbox_context = |sandbox_policy| {
|
||||
FileSystemSandboxContext::from_legacy_sandbox_policy(
|
||||
sandbox_policy,
|
||||
@@ -292,7 +292,8 @@ mod tests {
|
||||
},
|
||||
),
|
||||
] {
|
||||
let path = PathUri::from_path(temp_dir.path().join(file_name)).expect("path URI");
|
||||
let path =
|
||||
PathUri::from_host_native_path(temp_dir.path().join(file_name)).expect("path URI");
|
||||
|
||||
handler
|
||||
.write_file(FsWriteFileParams {
|
||||
@@ -312,7 +313,7 @@ mod tests {
|
||||
.expect("canonicalize file");
|
||||
assert_eq!(
|
||||
canonicalized.path,
|
||||
PathUri::from_path(
|
||||
PathUri::from_host_native_path(
|
||||
std::fs::canonicalize(temp_dir.path().join(file_name)).expect("canonical path"),
|
||||
)
|
||||
.expect("canonical path URI"),
|
||||
|
||||
@@ -27,7 +27,8 @@ fn exec_params_with_argv(process_id: &str, argv: Vec<String>) -> ExecParams {
|
||||
ExecParams {
|
||||
process_id: ProcessId::from(process_id),
|
||||
argv,
|
||||
cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"),
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd"))
|
||||
.expect("cwd URI"),
|
||||
env_policy: None,
|
||||
env: inherited_path_env(),
|
||||
tty: false,
|
||||
|
||||
@@ -432,7 +432,8 @@ mod tests {
|
||||
ExecParams {
|
||||
process_id,
|
||||
argv: sleep_then_print_argv(),
|
||||
cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"),
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd"))
|
||||
.expect("cwd URI"),
|
||||
env_policy: None,
|
||||
env,
|
||||
tty: false,
|
||||
|
||||
@@ -75,7 +75,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
|
||||
.start(ExecParams {
|
||||
process_id: ProcessId::from("proc-1"),
|
||||
argv: vec!["true".to_string()],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -218,7 +218,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
|
||||
"-c".to_string(),
|
||||
"sleep 0.05; printf 'session output\\n'".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -251,7 +251,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> {
|
||||
"-c".to_string(),
|
||||
"printf 'event output\\n'; sleep 0.1; printf 'event err\\n' >&2; sleep 0.1; exit 7".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -300,7 +300,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res
|
||||
"-c".to_string(),
|
||||
"printf 'late one\\n'; printf 'late two\\n'".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -350,7 +350,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close(
|
||||
DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG.to_string(),
|
||||
release_path.to_string_lossy().into_owned(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -425,7 +425,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
|
||||
"-c".to_string(),
|
||||
"IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: true,
|
||||
@@ -464,7 +464,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re
|
||||
"-c".to_string(),
|
||||
"IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -499,7 +499,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool)
|
||||
"-c".to_string(),
|
||||
"sleep 0.3; if IFS= read -r line; then printf 'read:%s\\n' \"$line\"; else printf 'eof\\n'; fi".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -535,7 +535,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu
|
||||
"-c".to_string(),
|
||||
"trap 'printf \"signal:2\\n\"; exit 7' INT; printf 'ready\\n'; while :; do :; done".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -590,7 +590,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b
|
||||
"/C".to_string(),
|
||||
"echo ready && ping -n 30 127.0.0.1 >NUL".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -632,7 +632,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
|
||||
"-c".to_string(),
|
||||
"printf 'queued output\\n'".to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
@@ -683,7 +683,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()>
|
||||
)
|
||||
.to_string(),
|
||||
],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: HashMap::from([
|
||||
(
|
||||
|
||||
@@ -45,7 +45,10 @@ async fn stream_stops_after_an_exact_block_boundary() -> Result<()> {
|
||||
std::fs::write(&path, vec![b'x'; BLOCK_SIZE * 2])?;
|
||||
|
||||
let chunks = file_system
|
||||
.read_file_stream(&PathUri::from_path(path)?, /*sandbox*/ None)
|
||||
.read_file_stream(
|
||||
&PathUri::from_host_native_path(path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
@@ -64,7 +67,7 @@ async fn completed_streams_release_handle_capacity() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let path = tmp.path().join("repeated.txt");
|
||||
std::fs::write(&path, b"repeated")?;
|
||||
let path = PathUri::from_path(path)?;
|
||||
let path = PathUri::from_host_native_path(path)?;
|
||||
|
||||
for _ in 0..=OPEN_FILE_LIMIT {
|
||||
let chunks = file_system
|
||||
@@ -88,7 +91,7 @@ async fn stream_rejects_platform_sandbox() -> Result<()> {
|
||||
|
||||
let result = file_system
|
||||
.read_file_stream(
|
||||
&PathUri::from_path(&path)?,
|
||||
&PathUri::from_host_native_path(&path)?,
|
||||
Some(&read_only_sandbox(tmp.path().to_path_buf())),
|
||||
)
|
||||
.await;
|
||||
@@ -120,7 +123,7 @@ async fn file_reads_reject_fifo_without_waiting_for_a_writer() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let path_uri = PathUri::from_path(&path)?;
|
||||
let path_uri = PathUri::from_host_native_path(&path)?;
|
||||
let read_error = timeout(
|
||||
Duration::from_secs(1),
|
||||
file_system.read_file(&path_uri, /*sandbox*/ None),
|
||||
@@ -158,7 +161,7 @@ async fn file_reads_reject_named_pipes() -> Result<()> {
|
||||
let read_error = timeout(
|
||||
Duration::from_secs(1),
|
||||
file_system.read_file(
|
||||
&PathUri::from_path(std::path::Path::new(&read_path))?,
|
||||
&PathUri::from_host_native_path(std::path::Path::new(&read_path))?,
|
||||
/*sandbox*/ None,
|
||||
),
|
||||
)
|
||||
@@ -173,7 +176,7 @@ async fn file_reads_reject_named_pipes() -> Result<()> {
|
||||
let stream_result = timeout(
|
||||
Duration::from_secs(1),
|
||||
file_system.read_file_stream(
|
||||
&PathUri::from_path(std::path::Path::new(&stream_path))?,
|
||||
&PathUri::from_host_native_path(std::path::Path::new(&stream_path))?,
|
||||
/*sandbox*/ None,
|
||||
),
|
||||
)
|
||||
@@ -202,7 +205,10 @@ async fn stream_keeps_reading_the_open_file_after_path_replacement() -> Result<(
|
||||
let path = tmp.path().join("replaceable.bin");
|
||||
std::fs::write(&path, vec![b'a'; BLOCK_SIZE + 1])?;
|
||||
let mut stream = file_system
|
||||
.read_file_stream(&PathUri::from_path(&path)?, /*sandbox*/ None)
|
||||
.read_file_stream(
|
||||
&PathUri::from_host_native_path(&path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
@@ -236,7 +242,7 @@ async fn read_block_supports_non_sequential_offsets_and_lengths() -> Result<()>
|
||||
let open = client
|
||||
.fs_open(FsOpenParams {
|
||||
handle_id: Uuid::new_v4().simple().to_string(),
|
||||
path: PathUri::from_path(path)?,
|
||||
path: PathUri::from_host_native_path(path)?,
|
||||
sandbox: None,
|
||||
})
|
||||
.await?;
|
||||
@@ -295,7 +301,7 @@ async fn open_enforces_the_per_connection_limit_and_close_releases_capacity() ->
|
||||
let tmp = TempDir::new()?;
|
||||
let path = tmp.path().join("limited.bin");
|
||||
std::fs::write(&path, b"limited")?;
|
||||
let path = PathUri::from_path(path)?;
|
||||
let path = PathUri::from_host_native_path(path)?;
|
||||
let mut handles = Vec::with_capacity(OPEN_FILE_LIMIT);
|
||||
for _ in 0..OPEN_FILE_LIMIT {
|
||||
let open = client
|
||||
@@ -359,7 +365,7 @@ async fn open_rejects_handle_ids_longer_than_32_bytes() -> Result<()> {
|
||||
let error = client
|
||||
.fs_open(FsOpenParams {
|
||||
handle_id: "x".repeat(33),
|
||||
path: PathUri::from_path(path)?,
|
||||
path: PathUri::from_host_native_path(path)?,
|
||||
sandbox: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -66,7 +66,10 @@ async fn file_system_get_metadata_reports_files_and_directories(
|
||||
std::fs::create_dir(&directory_path)?;
|
||||
|
||||
let file_metadata = file_system
|
||||
.get_metadata(&PathUri::from_path(&file_path)?, /*sandbox*/ None)
|
||||
.get_metadata(
|
||||
&PathUri::from_host_native_path(&file_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(
|
||||
@@ -83,7 +86,10 @@ async fn file_system_get_metadata_reports_files_and_directories(
|
||||
assert!(file_metadata.modified_at_ms > 0);
|
||||
|
||||
let directory_metadata = file_system
|
||||
.get_metadata(&PathUri::from_path(&directory_path)?, /*sandbox*/ None)
|
||||
.get_metadata(
|
||||
&PathUri::from_host_native_path(&directory_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(
|
||||
@@ -116,7 +122,7 @@ async fn file_system_create_directory_creates_nested_directories(
|
||||
|
||||
file_system
|
||||
.create_directory(
|
||||
&PathUri::from_path(&nested_dir)?,
|
||||
&PathUri::from_host_native_path(&nested_dir)?,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -140,7 +146,7 @@ async fn file_system_write_file_writes_bytes(
|
||||
let file_path = tmp.path().join("note.txt");
|
||||
file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&file_path)?,
|
||||
&PathUri::from_host_native_path(&file_path)?,
|
||||
b"hello from trait".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -155,21 +161,21 @@ async fn file_system_write_file_writes_bytes(
|
||||
fn path_uri_join_and_parent_preserve_lexical_paths() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let source_dir = tmp.path().join("source");
|
||||
let source_dir_uri = PathUri::from_path(&source_dir)?;
|
||||
let source_dir_uri = PathUri::from_host_native_path(&source_dir)?;
|
||||
let joined_nested = source_dir_uri.join("nested/note.txt")?;
|
||||
assert_eq!(
|
||||
joined_nested,
|
||||
PathUri::from_path(source_dir.join("nested").join("note.txt"))?
|
||||
PathUri::from_host_native_path(source_dir.join("nested").join("note.txt"))?
|
||||
);
|
||||
let joined_parent = joined_nested.parent();
|
||||
assert_eq!(
|
||||
joined_parent,
|
||||
Some(PathUri::from_path(source_dir.join("nested"))?)
|
||||
Some(PathUri::from_host_native_path(source_dir.join("nested"))?)
|
||||
);
|
||||
let joined_parent_traversal = source_dir_uri.join("../outside")?;
|
||||
assert_eq!(
|
||||
joined_parent_traversal,
|
||||
PathUri::from_path(source_dir.join("../outside"))?
|
||||
PathUri::from_host_native_path(source_dir.join("../outside"))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -188,7 +194,10 @@ async fn file_system_read_file_returns_bytes(
|
||||
std::fs::write(&file_path, "hello from trait")?;
|
||||
|
||||
let contents = file_system
|
||||
.read_file(&PathUri::from_path(&file_path)?, /*sandbox*/ None)
|
||||
.read_file(
|
||||
&PathUri::from_host_native_path(&file_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(contents, b"hello from trait");
|
||||
@@ -213,7 +222,10 @@ async fn file_system_read_file_stream_returns_bounded_chunks(
|
||||
std::fs::write(&file_path, &contents)?;
|
||||
|
||||
let chunks = file_system
|
||||
.read_file_stream(&PathUri::from_path(file_path)?, /*sandbox*/ None)
|
||||
.read_file_stream(
|
||||
&PathUri::from_host_native_path(file_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?
|
||||
.try_collect::<Vec<_>>()
|
||||
@@ -249,7 +261,10 @@ async fn file_system_read_file_text_returns_string(
|
||||
std::fs::write(&file_path, "hello from trait")?;
|
||||
|
||||
let contents = file_system
|
||||
.read_file_text(&PathUri::from_path(&file_path)?, /*sandbox*/ None)
|
||||
.read_file_text(
|
||||
&PathUri::from_host_native_path(&file_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(contents, "hello from trait");
|
||||
@@ -271,8 +286,8 @@ async fn file_system_copy_copies_file(implementation: FileSystemImplementation)
|
||||
|
||||
file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_file)?,
|
||||
&PathUri::from_path(&copied_file)?,
|
||||
&PathUri::from_host_native_path(&source_file)?,
|
||||
&PathUri::from_host_native_path(&copied_file)?,
|
||||
CopyOptions { recursive: false },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -302,8 +317,8 @@ async fn file_system_copy_copies_directory_recursively(
|
||||
|
||||
file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_dir)?,
|
||||
&PathUri::from_path(&copied_dir)?,
|
||||
&PathUri::from_host_native_path(&source_dir)?,
|
||||
&PathUri::from_host_native_path(&copied_dir)?,
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -332,7 +347,10 @@ async fn file_system_read_directory_lists_entries(
|
||||
std::fs::write(source_dir.join("root.txt"), "hello")?;
|
||||
|
||||
let mut entries = file_system
|
||||
.read_directory(&PathUri::from_path(&source_dir)?, /*sandbox*/ None)
|
||||
.read_directory(
|
||||
&PathUri::from_host_native_path(&source_dir)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
entries.sort_by(|left, right| left.file_name.cmp(&right.file_name));
|
||||
@@ -370,7 +388,7 @@ async fn file_system_remove_removes_directory(
|
||||
|
||||
file_system
|
||||
.remove(
|
||||
&PathUri::from_path(&directory_path)?,
|
||||
&PathUri::from_host_native_path(&directory_path)?,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
@@ -398,7 +416,7 @@ async fn file_system_write_file_reports_missing_parent(
|
||||
|
||||
let error = match file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&missing_parent_path)?,
|
||||
&PathUri::from_host_native_path(&missing_parent_path)?,
|
||||
b"hello from trait".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -432,8 +450,8 @@ async fn file_system_copy_rejects_directory_without_recursive(
|
||||
|
||||
let error = file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_dir)?,
|
||||
&PathUri::from_path(tmp.path().join("dest"))?,
|
||||
&PathUri::from_host_native_path(&source_dir)?,
|
||||
&PathUri::from_host_native_path(tmp.path().join("dest"))?,
|
||||
CopyOptions { recursive: false },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -465,7 +483,7 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root(
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
|
||||
let metadata = file_system
|
||||
.get_metadata(&PathUri::from_path(&file_path)?, Some(&sandbox))
|
||||
.get_metadata(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox))
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(
|
||||
@@ -481,7 +499,7 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root(
|
||||
);
|
||||
|
||||
let contents = file_system
|
||||
.read_file(&PathUri::from_path(&file_path)?, Some(&sandbox))
|
||||
.read_file(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox))
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(contents, b"sandboxed hello");
|
||||
@@ -505,8 +523,8 @@ pub(crate) async fn assert_canonicalize_resolves_directory_alias(
|
||||
std::fs::write(&file_path, "canonical hello")?;
|
||||
create_directory_alias(&source_dir, &alias_dir)?;
|
||||
|
||||
let requested_path = PathUri::from_path(alias_dir.join("nested").join("note.txt"))?;
|
||||
let expected_path = PathUri::from_path(std::fs::canonicalize(&file_path)?)?;
|
||||
let requested_path = PathUri::from_host_native_path(alias_dir.join("nested").join("note.txt"))?;
|
||||
let expected_path = PathUri::from_host_native_path(std::fs::canonicalize(&file_path)?)?;
|
||||
assert_ne!(requested_path, expected_path);
|
||||
|
||||
let canonical_path = file_system
|
||||
@@ -535,8 +553,8 @@ pub(crate) async fn assert_sandboxed_canonicalize_resolves_directory_alias(
|
||||
create_directory_alias(&source_dir, &alias_dir)?;
|
||||
let sandbox = read_only_sandbox(tmp.path().to_path_buf());
|
||||
|
||||
let requested_path = PathUri::from_path(alias_dir.join("nested").join("note.txt"))?;
|
||||
let expected_path = PathUri::from_path(std::fs::canonicalize(&file_path)?)?;
|
||||
let requested_path = PathUri::from_host_native_path(alias_dir.join("nested").join("note.txt"))?;
|
||||
let expected_path = PathUri::from_host_native_path(std::fs::canonicalize(&file_path)?)?;
|
||||
assert_ne!(requested_path, expected_path);
|
||||
|
||||
let canonical_path = file_system
|
||||
@@ -591,7 +609,7 @@ async fn file_system_sandboxed_write_allows_additional_write_root(
|
||||
|
||||
file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&file_path)?,
|
||||
&PathUri::from_host_native_path(&file_path)?,
|
||||
b"created".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -617,8 +635,8 @@ async fn file_system_copy_rejects_copying_directory_into_descendant(
|
||||
|
||||
let error = file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_dir)?,
|
||||
&PathUri::from_path(source_dir.join("nested").join("copy"))?,
|
||||
&PathUri::from_host_native_path(&source_dir)?,
|
||||
&PathUri::from_host_native_path(source_dir.join("nested").join("copy"))?,
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result<
|
||||
|
||||
file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&file_path)?,
|
||||
&PathUri::from_host_native_path(&file_path)?,
|
||||
b"written through fs helper".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -221,7 +221,10 @@ async fn file_system_get_metadata_reports_symlink_targets(
|
||||
let symlink_path = tmp.path().join("note-link.txt");
|
||||
symlink(&file_path, &symlink_path)?;
|
||||
let symlink_metadata = file_system
|
||||
.get_metadata(&PathUri::from_path(&symlink_path)?, /*sandbox*/ None)
|
||||
.get_metadata(
|
||||
&PathUri::from_host_native_path(&symlink_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(
|
||||
@@ -243,7 +246,7 @@ async fn file_system_get_metadata_reports_symlink_targets(
|
||||
symlink(&dir_path, &dir_symlink_path)?;
|
||||
let dir_symlink_metadata = file_system
|
||||
.get_metadata(
|
||||
&PathUri::from_path(&dir_symlink_path)?,
|
||||
&PathUri::from_host_native_path(&dir_symlink_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
@@ -278,7 +281,7 @@ async fn file_system_sandboxed_write_rejects_unwritable_path(
|
||||
let sandbox = read_only_sandbox(tmp.path().to_path_buf());
|
||||
let error = match file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&blocked_path)?,
|
||||
&PathUri::from_host_native_path(&blocked_path)?,
|
||||
b"nope".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -314,7 +317,7 @@ async fn file_system_sandboxed_write_allows_explicit_alias_roots(
|
||||
|
||||
file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&file_path)?,
|
||||
&PathUri::from_host_native_path(&file_path)?,
|
||||
b"created".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -345,7 +348,10 @@ async fn file_system_sandboxed_read_rejects_symlink_escape(
|
||||
let requested_path = allowed_dir.join("link").join("secret.txt");
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.read_file(&PathUri::from_path(&requested_path)?, Some(&sandbox))
|
||||
.read_file(
|
||||
&PathUri::from_host_native_path(&requested_path)?,
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => anyhow::bail!("read should be blocked"),
|
||||
@@ -375,7 +381,7 @@ async fn file_system_sandboxed_read_rejects_symlink_parent_dotdot_escape(
|
||||
symlink(&outside_dir, allowed_dir.join("link"))?;
|
||||
|
||||
let requested_path =
|
||||
PathUri::from_path(allowed_dir.join("link").join("..").join("secret.txt"))?;
|
||||
PathUri::from_host_native_path(allowed_dir.join("link").join("..").join("secret.txt"))?;
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system.read_file(&requested_path, Some(&sandbox)).await {
|
||||
Ok(_) => anyhow::bail!("read should fail after path normalization"),
|
||||
@@ -411,7 +417,7 @@ async fn file_system_sandboxed_write_rejects_symlink_escape(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&requested_path)?,
|
||||
&PathUri::from_host_native_path(&requested_path)?,
|
||||
b"nope".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -449,7 +455,7 @@ async fn file_system_sandboxed_write_preserves_existing_hard_link(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&hard_link)?,
|
||||
&PathUri::from_host_native_path(&hard_link)?,
|
||||
b"updated through existing hard link\n".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -495,7 +501,7 @@ async fn file_system_create_directory_rejects_symlink_escape(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.create_directory(
|
||||
&PathUri::from_path(&requested_path)?,
|
||||
&PathUri::from_host_native_path(&requested_path)?,
|
||||
CreateDirectoryOptions { recursive: false },
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -530,7 +536,10 @@ async fn file_system_read_directory_rejects_symlink_escape(
|
||||
let requested_path = allowed_dir.join("link");
|
||||
let sandbox = read_only_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.read_directory(&PathUri::from_path(&requested_path)?, Some(&sandbox))
|
||||
.read_directory(
|
||||
&PathUri::from_host_native_path(&requested_path)?,
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => anyhow::bail!("read_directory should be blocked"),
|
||||
@@ -562,8 +571,8 @@ async fn file_system_copy_rejects_symlink_escape_destination(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
||||
let error = match file_system
|
||||
.copy(
|
||||
&PathUri::from_path(allowed_dir.join("source.txt"))?,
|
||||
&PathUri::from_path(&requested_destination)?,
|
||||
&PathUri::from_host_native_path(allowed_dir.join("source.txt"))?,
|
||||
&PathUri::from_host_native_path(&requested_destination)?,
|
||||
CopyOptions { recursive: false },
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -600,7 +609,7 @@ async fn file_system_remove_removes_symlink_not_target(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
file_system
|
||||
.remove(
|
||||
&PathUri::from_path(&symlink_path)?,
|
||||
&PathUri::from_host_native_path(&symlink_path)?,
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
force: false,
|
||||
@@ -640,8 +649,8 @@ async fn file_system_copy_preserves_symlink_source(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
||||
file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_symlink)?,
|
||||
&PathUri::from_path(&copied_symlink)?,
|
||||
&PathUri::from_host_native_path(&source_symlink)?,
|
||||
&PathUri::from_host_native_path(&copied_symlink)?,
|
||||
CopyOptions { recursive: false },
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -677,7 +686,7 @@ async fn file_system_remove_rejects_symlink_escape(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.remove(
|
||||
&PathUri::from_path(&requested_path)?,
|
||||
&PathUri::from_host_native_path(&requested_path)?,
|
||||
RemoveOptions {
|
||||
recursive: false,
|
||||
force: false,
|
||||
@@ -718,8 +727,8 @@ async fn file_system_copy_rejects_symlink_escape_source(
|
||||
let sandbox = workspace_write_sandbox(allowed_dir);
|
||||
let error = match file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&requested_source)?,
|
||||
&PathUri::from_path(&requested_destination)?,
|
||||
&PathUri::from_host_native_path(&requested_source)?,
|
||||
&PathUri::from_host_native_path(&requested_destination)?,
|
||||
CopyOptions { recursive: false },
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -752,8 +761,8 @@ async fn file_system_copy_preserves_symlinks_in_recursive_copy(
|
||||
|
||||
file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_dir)?,
|
||||
&PathUri::from_path(&copied_dir)?,
|
||||
&PathUri::from_host_native_path(&source_dir)?,
|
||||
&PathUri::from_host_native_path(&copied_dir)?,
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -798,8 +807,8 @@ async fn file_system_copy_ignores_unknown_special_files_in_recursive_copy(
|
||||
|
||||
file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&source_dir)?,
|
||||
&PathUri::from_path(&copied_dir)?,
|
||||
&PathUri::from_host_native_path(&source_dir)?,
|
||||
&PathUri::from_host_native_path(&copied_dir)?,
|
||||
CopyOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
@@ -837,8 +846,8 @@ async fn file_system_copy_rejects_standalone_fifo_source(
|
||||
|
||||
let error = file_system
|
||||
.copy(
|
||||
&PathUri::from_path(&fifo_path)?,
|
||||
&PathUri::from_path(tmp.path().join("copied"))?,
|
||||
&PathUri::from_host_native_path(&fifo_path)?,
|
||||
&PathUri::from_host_native_path(tmp.path().join("copied"))?,
|
||||
CopyOptions { recursive: false },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
|
||||
@@ -74,7 +74,10 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() ->
|
||||
let readable_file = readonly_dir.join("readable.txt");
|
||||
std::fs::write(&readable_file, b"readable")?;
|
||||
let read_result = file_system
|
||||
.read_file(&PathUri::from_path(&readable_file)?, Some(&sandbox))
|
||||
.read_file(
|
||||
&PathUri::from_host_native_path(&readable_file)?,
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await;
|
||||
// Some local Windows hosts cannot create restricted tokens. Reaching that
|
||||
// error still proves the remote fs helper went through the Windows sandbox
|
||||
@@ -87,7 +90,7 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() ->
|
||||
let blocked_file = readonly_dir.join("blocked.txt");
|
||||
let error = file_system
|
||||
.write_file(
|
||||
&PathUri::from_path(&blocked_file)?,
|
||||
&PathUri::from_host_native_path(&blocked_file)?,
|
||||
b"blocked".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
@@ -104,7 +107,7 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() ->
|
||||
fn read_only_sandbox_for_cwd(cwd: std::path::PathBuf) -> Result<FileSystemSandboxContext> {
|
||||
Ok(FileSystemSandboxContext::from_legacy_sandbox_policy(
|
||||
SandboxPolicy::new_read_only_policy(),
|
||||
PathUri::from_path(cwd)?,
|
||||
PathUri::from_host_native_path(cwd)?,
|
||||
)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> {
|
||||
.exec(ExecParams {
|
||||
process_id: ProcessId::from("proc-1"),
|
||||
argv: vec!["true".to_string()],
|
||||
cwd: PathUri::from_path(std::env::current_dir()?)?,
|
||||
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
||||
env_policy: None,
|
||||
env: HashMap::new(),
|
||||
tty: false,
|
||||
@@ -167,7 +167,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> {
|
||||
std::fs::write(&large_file_path, &large_file_contents)?;
|
||||
let read_response = client
|
||||
.fs_read_file(FsReadFileParams {
|
||||
path: PathUri::from_path(large_file_path)?,
|
||||
path: PathUri::from_host_native_path(large_file_path)?,
|
||||
sandbox: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use codex_api::ApiError;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use http::StatusCode;
|
||||
|
||||
pub(super) const BEDROCK_EXPIRED_SIGNATURE_MESSAGE: &str = concat!(
|
||||
"Amazon Bedrock rejected the request because its AWS signature has expired. ",
|
||||
"Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, ",
|
||||
"update or unset it, then restart Codex",
|
||||
);
|
||||
|
||||
pub(super) fn map_api_error(error: ApiError) -> CodexErr {
|
||||
let mut error = codex_api::map_api_error(error);
|
||||
if let CodexErr::UnexpectedStatus(response) = &mut error
|
||||
&& response.status == StatusCode::UNAUTHORIZED
|
||||
&& response.body.contains("Signature expired:")
|
||||
{
|
||||
response.user_message = Some(BEDROCK_EXPIRED_SIGNATURE_MESSAGE.to_string());
|
||||
}
|
||||
error
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use codex_api::ApiError;
|
||||
use codex_api::TransportError;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use http::StatusCode;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::error::BEDROCK_EXPIRED_SIGNATURE_MESSAGE;
|
||||
use super::error::map_api_error;
|
||||
|
||||
const BEDROCK_RESPONSES_URL: &str = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses";
|
||||
|
||||
fn http_error(status: StatusCode, body: &str) -> ApiError {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-request-id", HeaderValue::from_static("req-bedrock"));
|
||||
ApiError::Transport(TransportError::Http {
|
||||
status,
|
||||
url: Some(BEDROCK_RESPONSES_URL.to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(body.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_signature_has_actionable_guidance() {
|
||||
let error = map_api_error(http_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z",
|
||||
));
|
||||
|
||||
let CodexErr::UnexpectedStatus(response) = &error else {
|
||||
panic!("expected unexpected status error, got {error:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
response.user_message.as_deref(),
|
||||
Some(BEDROCK_EXPIRED_SIGNATURE_MESSAGE)
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"{BEDROCK_EXPIRED_SIGNATURE_MESSAGE}, url: {BEDROCK_RESPONSES_URL}, request id: req-bedrock"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_unauthorized_errors_remain_generic() {
|
||||
let error = map_api_error(http_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"The security token included in the request is invalid",
|
||||
));
|
||||
|
||||
let CodexErr::UnexpectedStatus(response) = &error else {
|
||||
panic!("expected unexpected status error, got {error:?}");
|
||||
};
|
||||
assert_eq!(response.user_message, None);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"unexpected status 401 Unauthorized: The security token included in the request is invalid, url: {BEDROCK_RESPONSES_URL}, request id: req-bedrock"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_errors_with_other_statuses_remain_generic() {
|
||||
let error = map_api_error(http_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Signature expired: old is now earlier than new",
|
||||
));
|
||||
|
||||
let CodexErr::UnexpectedStatus(response) = &error else {
|
||||
panic!("expected unexpected status error, got {error:?}");
|
||||
};
|
||||
assert_eq!(response.user_message, None);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
mod auth;
|
||||
mod catalog;
|
||||
mod error;
|
||||
mod mantle;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_api::ApiError;
|
||||
use codex_api::Provider;
|
||||
use codex_api::SharedAuthProvider;
|
||||
use codex_login::AuthManager;
|
||||
@@ -17,6 +19,7 @@ use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_models_manager::manager::StaticModelsManager;
|
||||
use codex_protocol::account::AmazonBedrockCredentialSource;
|
||||
use codex_protocol::account::ProviderAccount;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
|
||||
@@ -142,6 +145,10 @@ impl ModelProvider for AmazonBedrockModelProvider {
|
||||
})
|
||||
}
|
||||
|
||||
fn map_api_error(&self, error: ApiError) -> CodexErr {
|
||||
error::map_api_error(error)
|
||||
}
|
||||
|
||||
fn api_provider(&self) -> ModelProviderFuture<'_, Result<Provider>> {
|
||||
Box::pin(AmazonBedrockModelProvider::api_provider(self))
|
||||
}
|
||||
@@ -166,6 +173,10 @@ impl ModelProvider for AmazonBedrockModelProvider {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "error_tests.rs"]
|
||||
mod error_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::HeaderValue;
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_api::ApiError;
|
||||
use codex_api::Provider;
|
||||
use codex_api::SharedAuthProvider;
|
||||
use codex_login::AuthManager;
|
||||
@@ -13,6 +14,7 @@ use codex_models_manager::manager::OpenAiModelsManager;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_models_manager::manager::StaticModelsManager;
|
||||
use codex_protocol::account::ProviderAccount;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
|
||||
use crate::amazon_bedrock::AmazonBedrockModelProvider;
|
||||
@@ -142,6 +144,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
|
||||
/// Returns the current app-visible account state for this provider.
|
||||
fn account_state(&self) -> ProviderAccountResult;
|
||||
|
||||
/// Maps an API client error into the provider's user-facing error representation.
|
||||
fn map_api_error(&self, error: ApiError) -> CodexErr {
|
||||
codex_api::map_api_error(error)
|
||||
}
|
||||
|
||||
/// Returns provider configuration adapted for the API client.
|
||||
fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result<Provider>> {
|
||||
Box::pin(async move {
|
||||
|
||||
@@ -305,6 +305,7 @@ impl std::fmt::Display for ResponseStreamFailed {
|
||||
pub struct UnexpectedResponseError {
|
||||
pub status: StatusCode,
|
||||
pub body: String,
|
||||
pub user_message: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub cf_ray: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
@@ -312,8 +313,6 @@ pub struct UnexpectedResponseError {
|
||||
pub identity_error_code: Option<String>,
|
||||
}
|
||||
|
||||
const CLOUDFLARE_BLOCKED_MESSAGE: &str =
|
||||
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region";
|
||||
const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000;
|
||||
|
||||
impl UnexpectedResponseError {
|
||||
@@ -343,18 +342,17 @@ impl UnexpectedResponseError {
|
||||
Some(message.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly_message(&self) -> Option<String> {
|
||||
if self.status != StatusCode::FORBIDDEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !self.body.contains("Cloudflare") || !self.body.contains("blocked") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = self.status;
|
||||
let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})");
|
||||
impl std::fmt::Display for UnexpectedResponseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut message = if let Some(user_message) = &self.user_message {
|
||||
user_message.clone()
|
||||
} else {
|
||||
let status = self.status;
|
||||
let body = self.display_body();
|
||||
format!("unexpected status {status}: {body}")
|
||||
};
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
@@ -370,36 +368,7 @@ impl UnexpectedResponseError {
|
||||
if let Some(error_code) = &self.identity_error_code {
|
||||
message.push_str(&format!(", auth error code: {error_code}"));
|
||||
}
|
||||
|
||||
Some(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UnexpectedResponseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(friendly) = self.friendly_message() {
|
||||
write!(f, "{friendly}")
|
||||
} else {
|
||||
let status = self.status;
|
||||
let body = self.display_body();
|
||||
let mut message = format!("unexpected status {status}: {body}");
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
if let Some(cf_ray) = &self.cf_ray {
|
||||
message.push_str(&format!(", cf-ray: {cf_ray}"));
|
||||
}
|
||||
if let Some(id) = &self.request_id {
|
||||
message.push_str(&format!(", request id: {id}"));
|
||||
}
|
||||
if let Some(auth_error) = &self.identity_authorization_error {
|
||||
message.push_str(&format!(", auth error: {auth_error}"));
|
||||
}
|
||||
if let Some(error_code) = &self.identity_error_code {
|
||||
message.push_str(&format!(", auth error code: {error_code}"));
|
||||
}
|
||||
write!(f, "{message}")
|
||||
}
|
||||
write!(f, "{message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -400,31 +400,12 @@ fn usage_limit_reached_includes_minutes_when_available() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_cloudflare_html_is_simplified() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "<html><body>Cloudflare error: Sorry, you have been blocked</body></html>"
|
||||
.to_string(),
|
||||
url: Some("http://example.com/blocked".to_string()),
|
||||
cf_ray: Some("ray-id".to_string()),
|
||||
request_id: None,
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
let status = StatusCode::FORBIDDEN.to_string();
|
||||
let url = "http://example.com/blocked";
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_non_html_is_unchanged() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "plain text error".to_string(),
|
||||
user_message: None,
|
||||
url: Some("http://example.com/plain".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: None,
|
||||
@@ -439,12 +420,32 @@ fn unexpected_status_non_html_is_unchanged() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_uses_user_message_and_preserves_response_context() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: "provider-specific response".to_string(),
|
||||
user_message: Some("Provider-specific guidance".to_string()),
|
||||
url: Some("https://example.com/v1/responses".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: Some("req-provider".to_string()),
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Provider-specific guidance, url: https://example.com/v1/responses, request id: req-provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_prefers_error_message_when_present() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"#
|
||||
.to_string(),
|
||||
user_message: None,
|
||||
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: Some("req-123".to_string()),
|
||||
@@ -466,6 +467,7 @@ fn unexpected_status_truncates_long_body_with_ellipsis() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
body: long_body,
|
||||
user_message: None,
|
||||
url: Some("http://example.com/long".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: Some("req-long".to_string()),
|
||||
@@ -487,6 +489,7 @@ fn unexpected_status_includes_cf_ray_and_request_id() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: "plain text error".to_string(),
|
||||
user_message: None,
|
||||
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
|
||||
cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()),
|
||||
request_id: Some("req-xyz".to_string()),
|
||||
@@ -507,6 +510,7 @@ fn unexpected_status_includes_identity_auth_details() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: "plain text error".to_string(),
|
||||
user_message: None,
|
||||
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
|
||||
cf_ray: Some("cf-ray-auth-401-test".to_string()),
|
||||
request_id: Some("req-auth".to_string()),
|
||||
|
||||
@@ -488,7 +488,7 @@ impl ExecutorStdioServerLauncher {
|
||||
// before sending an executor request.
|
||||
let argv = Self::process_api_argv(&program, &args).map_err(io::Error::other)?;
|
||||
let env = Self::process_api_env(envs).map_err(io::Error::other)?;
|
||||
let cwd = PathUri::from_path(cwd)?;
|
||||
let cwd = PathUri::from_host_native_path(cwd)?;
|
||||
let process_id = ExecutorProcessTransport::next_process_id();
|
||||
// Start the MCP server process on the executor with raw pipes. `tty=false`
|
||||
// keeps stdout as a clean protocol stream, while `pipe_stdin=true` lets
|
||||
|
||||
@@ -90,12 +90,15 @@ mod tests {
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginListResponse;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::PluginShareContext;
|
||||
use codex_app_server_protocol::PluginShareDiscoverability;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn plugin_mentions_use_plugin_list_summaries_and_gui_eligibility() {
|
||||
let active = plugin_summary("active");
|
||||
let active_shared = shared_plugin_summary("active-shared");
|
||||
let mut disabled_by_admin = plugin_summary("disabled-by-admin");
|
||||
disabled_by_admin.availability = PluginAvailability::DisabledByAdmin;
|
||||
let mut disabled = plugin_summary("disabled");
|
||||
@@ -108,7 +111,13 @@ mod tests {
|
||||
name: "server-marketplace".to_string(),
|
||||
path: None,
|
||||
interface: None,
|
||||
plugins: vec![active, disabled_by_admin, disabled, uninstalled],
|
||||
plugins: vec![
|
||||
active,
|
||||
active_shared,
|
||||
disabled_by_admin,
|
||||
disabled,
|
||||
uninstalled,
|
||||
],
|
||||
}],
|
||||
marketplace_load_errors: Vec::new(),
|
||||
featured_plugin_ids: Vec::new(),
|
||||
@@ -116,17 +125,42 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
plugin_mentions_from_list_response(response),
|
||||
vec![PluginCapabilitySummary {
|
||||
config_name: "active@server-marketplace".to_string(),
|
||||
display_name: "active".to_string(),
|
||||
description: Some("server-marketplace".to_string()),
|
||||
has_skills: false,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}]
|
||||
vec![
|
||||
PluginCapabilitySummary {
|
||||
config_name: "active@server-marketplace".to_string(),
|
||||
display_name: "active".to_string(),
|
||||
description: Some("server-marketplace".to_string()),
|
||||
has_skills: false,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
},
|
||||
PluginCapabilitySummary {
|
||||
config_name: "active-shared@server-marketplace".to_string(),
|
||||
display_name: "active-shared".to_string(),
|
||||
description: Some("server-marketplace".to_string()),
|
||||
has_skills: false,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn shared_plugin_summary(name: &str) -> PluginSummary {
|
||||
PluginSummary {
|
||||
share_context: Some(PluginShareContext {
|
||||
remote_plugin_id: format!("plugins~{name}"),
|
||||
remote_version: Some("7".to_string()),
|
||||
discoverability: Some(PluginShareDiscoverability::Private),
|
||||
share_url: Some(format!("https://chatgpt.com/codex/plugins/share/{name}")),
|
||||
creator_account_user_id: None,
|
||||
creator_name: Some("Test User".to_string()),
|
||||
share_principals: None,
|
||||
}),
|
||||
..plugin_summary(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_summary(name: &str) -> PluginSummary {
|
||||
PluginSummary {
|
||||
id: format!("{name}@server-marketplace"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! App-level orchestration tests for the TUI.
|
||||
|
||||
mod model_catalog;
|
||||
mod plugin_catalog;
|
||||
mod session_summary;
|
||||
mod startup;
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_plugin_uninstall_dispatches_plugin_list_refresh() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let cwd = app.chat_widget.config_ref().cwd.to_path_buf();
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||
app.chat_widget.config_ref(),
|
||||
))
|
||||
.await?;
|
||||
let control = Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::PluginUninstallLoaded {
|
||||
cwd: cwd.clone(),
|
||||
plugin_id: "plugin-docs".to_string(),
|
||||
plugin_display_name: "Docs".to_string(),
|
||||
result: Ok(PluginUninstallResponse {}),
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
assert!(matches!(control, AppRunControl::Continue));
|
||||
|
||||
let refresh_result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
match app_event_rx.recv().await {
|
||||
Some(AppEvent::PluginsLoaded {
|
||||
cwd: event_cwd,
|
||||
result,
|
||||
}) if event_cwd == cwd => break result,
|
||||
Some(_) => {}
|
||||
None => panic!("app event channel closed before plugin refresh completed"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("dispatcher should initiate a plugin list refresh");
|
||||
refresh_result.expect("plugin list refresh should succeed");
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: strip_osc8_for_snapshot(&popup)
|
||||
---
|
||||
Plugins
|
||||
Docs · Can be installed · Local
|
||||
Data shared with this app is subject to the app's terms of service and privacy policy. Learn more.
|
||||
Workspace docs.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Install plugin Install this plugin now.
|
||||
Source Local
|
||||
Auth Auth on install
|
||||
Version remote 7
|
||||
Sharing Private · creator Test User · https://chatgpt.com/codex/plugins/share/docs
|
||||
Skills No plugin skills.
|
||||
Hooks No plugin hooks.
|
||||
|
||||
Press esc to close.
|
||||
+2
-2
@@ -6,12 +6,12 @@ expression: popup
|
||||
Browse plugins from available marketplaces.
|
||||
Installed 1 of 4 available plugins.
|
||||
|
||||
[All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace
|
||||
[All Plugins] Installed (1) OpenAI Curated Workspace Shared with me Repo Marketplace Add Marketplace
|
||||
|
||||
Type to search plugins
|
||||
› [ ] Alpha Sync Disabled Space to enable; Enter view details.
|
||||
[-] Bravo Search Available · OpenAI Curated · Search docs and tickets.
|
||||
[-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugi…
|
||||
[-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
|
||||
[-] Starter Available by default · OpenAI Curated · Included by default.
|
||||
|
||||
space enable/disable · ←/→ select marketplace · enter view details · esc close
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ expression: popup
|
||||
Browse plugins from available marketplaces.
|
||||
Installed 0 of 3 available plugins.
|
||||
|
||||
[All Plugins] Installed (0) OpenAI Curated Add Marketplace
|
||||
[All Plugins] Installed (0) OpenAI Curated Workspace Shared with me Add Marketplace
|
||||
|
||||
sla
|
||||
› [-] Slack Available Press Enter to install or view plugin details.
|
||||
|
||||
@@ -244,6 +244,8 @@ mod history_replay;
|
||||
mod mcp_startup;
|
||||
mod permissions;
|
||||
mod plan_mode;
|
||||
#[path = "tests/plugin_catalog_tests.rs"]
|
||||
mod plugin_catalog;
|
||||
mod popups_and_settings;
|
||||
mod review_mode;
|
||||
mod side;
|
||||
|
||||
@@ -1284,6 +1284,13 @@ pub(super) fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
.abs()
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_personal_marketplace_path() -> AbsolutePathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("home directory should be available")
|
||||
.join(".agents/plugins/marketplace.json")
|
||||
.abs()
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_interface(
|
||||
display_name: Option<&str>,
|
||||
short_description: Option<&str>,
|
||||
@@ -1372,6 +1379,21 @@ pub(super) fn plugins_test_remote_summary(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_remote_marketplace(
|
||||
name: &str,
|
||||
display_name: &str,
|
||||
plugins: Vec<PluginSummary>,
|
||||
) -> PluginMarketplaceEntry {
|
||||
PluginMarketplaceEntry {
|
||||
name: name.to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some(display_name.to_string()),
|
||||
}),
|
||||
plugins,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_curated_marketplace(
|
||||
plugins: Vec<PluginSummary>,
|
||||
) -> PluginMarketplaceEntry {
|
||||
@@ -1408,11 +1430,23 @@ pub(super) fn plugins_test_response(
|
||||
|
||||
pub(super) fn render_loaded_plugins_popup(
|
||||
chat: &mut ChatWidget,
|
||||
response: PluginListResponse,
|
||||
mut response: PluginListResponse,
|
||||
) -> String {
|
||||
let cwd = chat.config.cwd.clone();
|
||||
let remote_marketplaces = response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.filter(|marketplace| marketplace.path.is_none())
|
||||
.cloned()
|
||||
.collect();
|
||||
response
|
||||
.marketplaces
|
||||
.retain(|marketplace| marketplace.path.is_some());
|
||||
let response_for_refresh = response.clone();
|
||||
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response_for_refresh));
|
||||
chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), remote_marketplaces, Vec::new());
|
||||
render_bottom_popup(chat, /*width*/ 100)
|
||||
}
|
||||
|
||||
@@ -1470,12 +1504,48 @@ pub(super) fn plugins_test_detail(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_remote_detail(
|
||||
marketplace_name: &str,
|
||||
summary: PluginSummary,
|
||||
description: Option<&str>,
|
||||
) -> PluginDetail {
|
||||
PluginDetail {
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
marketplace_path: None,
|
||||
summary,
|
||||
share_url: None,
|
||||
description: description.map(str::to_string),
|
||||
skills: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize {
|
||||
popup
|
||||
.find(needle)
|
||||
.unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}"))
|
||||
}
|
||||
|
||||
pub(super) fn select_plugins_tab_containing(
|
||||
chat: &mut ChatWidget,
|
||||
width: u16,
|
||||
visible_text: &str,
|
||||
) -> String {
|
||||
for _ in 0..8 {
|
||||
let popup = render_bottom_popup(chat, width);
|
||||
if popup.contains(visible_text) {
|
||||
return popup;
|
||||
}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
|
||||
}
|
||||
|
||||
let popup = render_bottom_popup(chat, width);
|
||||
panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}");
|
||||
}
|
||||
|
||||
pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
|
||||
for ch in query.chars() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_uses_product_labels_for_remote_and_personal_tabs() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![
|
||||
plugins_test_remote_marketplace(
|
||||
"workspace-directory",
|
||||
"Raw Workspace Directory",
|
||||
vec![plugins_test_remote_summary(
|
||||
"plugins~Plugin_buildkite",
|
||||
"buildkite",
|
||||
Some("Buildkite"),
|
||||
Some("Workspace CI."),
|
||||
/*installed*/ false,
|
||||
)],
|
||||
),
|
||||
plugins_test_remote_marketplace(
|
||||
"workspace-shared-with-me-private",
|
||||
"Raw Shared Private",
|
||||
vec![plugins_test_remote_summary(
|
||||
"plugins~Plugin_docs",
|
||||
"docs",
|
||||
Some("Docs"),
|
||||
Some("Shared docs."),
|
||||
/*installed*/ false,
|
||||
)],
|
||||
),
|
||||
plugins_test_remote_marketplace(
|
||||
"workspace-shared-with-me-unlisted",
|
||||
"Raw Shared Link",
|
||||
vec![plugins_test_remote_summary(
|
||||
"plugins~Plugin_link",
|
||||
"link",
|
||||
Some("Link Share"),
|
||||
Some("Shared by link."),
|
||||
/*installed*/ false,
|
||||
)],
|
||||
),
|
||||
PluginMarketplaceEntry {
|
||||
name: "codex-curated".to_string(),
|
||||
path: Some(plugins_test_personal_marketplace_path()),
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Personal".to_string()),
|
||||
}),
|
||||
plugins: vec![plugins_test_summary(
|
||||
"plugin-local-docs",
|
||||
"local-docs",
|
||||
Some("Local Docs"),
|
||||
Some("Local editable docs."),
|
||||
/*installed*/ false,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
let rows = [
|
||||
(
|
||||
"[Workspace]",
|
||||
"Workspace.",
|
||||
"Buildkite",
|
||||
"Raw Workspace Directory.",
|
||||
),
|
||||
(
|
||||
"[Shared with me]",
|
||||
"Shared with me.",
|
||||
"Docs",
|
||||
"Raw Shared Private.",
|
||||
),
|
||||
(
|
||||
"[Shared with me (link)]",
|
||||
"Shared with me (link).",
|
||||
"Link Share",
|
||||
"Raw Shared Link.",
|
||||
),
|
||||
("[Local]", "Local.", "Local Docs", "Personal."),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(selected_tab, product_label, plugin_name, raw_label)| {
|
||||
let popup = select_plugins_tab_containing(&mut chat, /*width*/ 120, selected_tab);
|
||||
assert!(
|
||||
popup.contains(product_label)
|
||||
&& popup.contains(plugin_name)
|
||||
&& !popup.contains(raw_label),
|
||||
"expected {selected_tab} to use its product label, got:\n{popup}"
|
||||
);
|
||||
popup
|
||||
.lines()
|
||||
.find(|line| line.contains(plugin_name))
|
||||
.expect("expected plugin row")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
insta::assert_snapshot!(
|
||||
rows,
|
||||
@r###"
|
||||
› [-] Buildkite Available Press Enter to install or view plugin details.
|
||||
› [-] Docs Available Press Enter to install or view plugin details.
|
||||
› [-] Link Share Available Press Enter to install or view plugin details.
|
||||
› [-] Local Docs Available Press Enter to install or view plugin details.
|
||||
"###
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_preserves_workspace_tab_across_load_and_detail_navigation() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let workspace_marketplace = plugins_test_remote_marketplace(
|
||||
"workspace-directory",
|
||||
"Raw Workspace Directory",
|
||||
vec![plugins_test_remote_summary(
|
||||
"plugins~Plugin_buildkite",
|
||||
"buildkite",
|
||||
Some("Buildkite"),
|
||||
Some("Buildkite pipelines."),
|
||||
/*installed*/ false,
|
||||
)],
|
||||
);
|
||||
chat.add_plugins_output();
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(plugins_test_response(vec![
|
||||
plugins_test_curated_marketplace(Vec::new()),
|
||||
])),
|
||||
);
|
||||
let loading_popup =
|
||||
select_plugins_tab_containing(&mut chat, /*width*/ 100, "Loading Workspace plugins.");
|
||||
assert!(
|
||||
loading_popup.contains("Loading Workspace plugins."),
|
||||
"expected Workspace loading tab before remote sections resolve, got:\n{loading_popup}"
|
||||
);
|
||||
|
||||
chat.on_plugin_remote_sections_loaded(
|
||||
cwd.to_path_buf(),
|
||||
vec![workspace_marketplace.clone()],
|
||||
Vec::new(),
|
||||
);
|
||||
let workspace_popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
workspace_popup.contains("Workspace.")
|
||||
&& workspace_popup.contains("Buildkite")
|
||||
&& !workspace_popup.contains("Loading Workspace plugins."),
|
||||
"expected remote section refresh to keep the Workspace tab active, got:\n{workspace_popup}"
|
||||
);
|
||||
|
||||
chat.open_plugin_detail_loading_popup("Buildkite");
|
||||
chat.open_plugins_list(
|
||||
cwd.to_path_buf(),
|
||||
plugins_test_response(vec![
|
||||
plugins_test_curated_marketplace(Vec::new()),
|
||||
workspace_marketplace,
|
||||
]),
|
||||
);
|
||||
let reopened_popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
reopened_popup.contains("Workspace.") && reopened_popup.contains("Buildkite"),
|
||||
"expected Back to plugins to preserve the Workspace tab, got:\n{reopened_popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_remote_local_dedupe_prefers_installed_remote_after_mapped_shares() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let remote_plugin_id = "plugins~Plugin_docs";
|
||||
let local_summary = PluginSummary {
|
||||
remote_plugin_id: Some(remote_plugin_id.to_string()),
|
||||
..plugins_test_summary(
|
||||
"plugin-docs",
|
||||
"docs",
|
||||
Some("Docs"),
|
||||
Some("Local curated docs plugin."),
|
||||
/*installed*/ false,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)
|
||||
};
|
||||
let cwd = chat.config.cwd.clone();
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![local_summary])]),
|
||||
);
|
||||
chat.on_plugin_remote_sections_loaded(
|
||||
cwd.to_path_buf(),
|
||||
vec![plugins_test_remote_marketplace(
|
||||
"workspace-shared-with-me-private",
|
||||
"Shared with me",
|
||||
vec![plugins_test_remote_summary(
|
||||
remote_plugin_id,
|
||||
"docs",
|
||||
Some("Docs"),
|
||||
Some("Remote installed docs plugin."),
|
||||
/*installed*/ true,
|
||||
)],
|
||||
)],
|
||||
Vec::new(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let PluginsCacheState::Ready(response) = &chat.plugins_cache else {
|
||||
panic!("expected cached plugins after remote section refresh");
|
||||
};
|
||||
assert_eq!(
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.map(|marketplace| marketplace.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
"workspace-shared-with-me-private"
|
||||
]
|
||||
);
|
||||
let all_plugins_row = popup
|
||||
.lines()
|
||||
.find(|line| line.contains("Docs"))
|
||||
.expect("expected all-plugins row");
|
||||
assert!(
|
||||
popup.contains("Installed 1 of 1 available plugins."),
|
||||
"expected header count to reflect deduped plugin rows, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
all_plugins_row.contains("Installed")
|
||||
&& !all_plugins_row.contains("Local curated docs plugin."),
|
||||
"expected installed remote duplicate to win when local row is not a mapped share, got:\n{all_plugins_row}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_not_installable_plugin_disables_install_action() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = plugins_test_summary(
|
||||
"plugin-internal",
|
||||
"internal",
|
||||
Some("Internal"),
|
||||
Some("Internal only."),
|
||||
/*installed*/ false,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::NotAvailable,
|
||||
);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(plugins_test_response(vec![
|
||||
plugins_test_curated_marketplace(vec![summary.clone()]),
|
||||
])),
|
||||
);
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: plugins_test_detail(summary, Some("Internal only."), &[], &[], &[], &[]),
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let install_row = popup
|
||||
.lines()
|
||||
.find(|line| line.contains("Install plugin"))
|
||||
.expect("expected install row");
|
||||
assert!(
|
||||
install_row.contains("This plugin is not installable from this marketplace."),
|
||||
"expected disabled not-installable row, got:\n{install_row}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
assert_eq!(
|
||||
render_bottom_popup(&chat, /*width*/ 100),
|
||||
popup,
|
||||
"expected navigation to skip the disabled install row"
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,12 @@ use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::HookErrorInfo;
|
||||
use codex_app_server_protocol::HooksListEntry;
|
||||
use codex_app_server_protocol::HooksListResponse;
|
||||
use codex_app_server_protocol::MarketplaceLoadErrorInfo;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginShareContext;
|
||||
use codex_app_server_protocol::PluginShareDiscoverability;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -197,7 +200,8 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_
|
||||
PluginInstallPolicy::Available,
|
||||
)]),
|
||||
]);
|
||||
let popup = render_loaded_plugins_popup(&mut chat, response);
|
||||
render_loaded_plugins_popup(&mut chat, response);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 320);
|
||||
assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup);
|
||||
assert!(
|
||||
popup.contains("Hidden Repo Plugin"),
|
||||
@@ -214,6 +218,27 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_keeps_loaded_marketplace_state_with_load_errors() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let mut response = plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]);
|
||||
response
|
||||
.marketplace_load_errors
|
||||
.push(MarketplaceLoadErrorInfo {
|
||||
marketplace_path: plugins_test_absolute_path("marketplaces/broken"),
|
||||
message: "failed to load marketplace manifest".to_string(),
|
||||
});
|
||||
|
||||
let popup = render_loaded_plugins_popup(&mut chat, response);
|
||||
assert!(
|
||||
popup.contains("No marketplace plugins available")
|
||||
&& !popup.contains("Marketplace unavailable"),
|
||||
"expected /plugins to keep the loaded marketplace state, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugins_popup_truncates_long_descriptions_in_list_rows() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -272,14 +297,14 @@ async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() {
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
for _ in 0..3 {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
|
||||
}
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let popup = select_plugins_tab_containing(
|
||||
&mut chat,
|
||||
/*width*/ 100,
|
||||
"Add a marketplace from a Git repo or local root.",
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Add a marketplace from a Git repo or local root."),
|
||||
"expected Add Marketplace tab, got:\n{popup}"
|
||||
"expected Add marketplace tab, got:\n{popup}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
@@ -349,11 +374,7 @@ async fn plugins_popup_upgrades_user_configured_git_marketplace_from_marketplace
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
for _ in 0..3 {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
|
||||
}
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let popup = select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace.");
|
||||
assert!(
|
||||
popup.contains("Repo Marketplace.")
|
||||
&& popup.contains("ctrl + u upgrade")
|
||||
@@ -513,10 +534,8 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() {
|
||||
);
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
for _ in 0..3 {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
|
||||
}
|
||||
let repo_tab = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let repo_tab =
|
||||
select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace.");
|
||||
assert!(
|
||||
repo_tab.contains("Repo Marketplace.")
|
||||
&& repo_tab.contains("ctrl + u upgrade")
|
||||
@@ -839,31 +858,23 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() {
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
|
||||
name: "workspace-shared-with-me-private".to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Shared with me".to_string()),
|
||||
}),
|
||||
plugins: vec![summary.clone()],
|
||||
}])),
|
||||
Ok(plugins_test_response(vec![
|
||||
plugins_test_remote_marketplace(
|
||||
"workspace-shared-with-me-private",
|
||||
"Shared with me",
|
||||
vec![summary.clone()],
|
||||
),
|
||||
])),
|
||||
);
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: PluginDetail {
|
||||
marketplace_name: "workspace-shared-with-me-private".to_string(),
|
||||
marketplace_path: None,
|
||||
plugin: plugins_test_remote_detail(
|
||||
"workspace-shared-with-me-private",
|
||||
summary,
|
||||
share_url: None,
|
||||
description: Some("Installed shared Linear plugin.".to_string()),
|
||||
skills: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
},
|
||||
Some("Installed shared Linear plugin."),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -894,7 +905,7 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
@@ -912,31 +923,23 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() {
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(plugins_test_response(vec![PluginMarketplaceEntry {
|
||||
name: "workspace-shared-with-me-private".to_string(),
|
||||
path: None,
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("Shared with me".to_string()),
|
||||
}),
|
||||
plugins: vec![summary.clone()],
|
||||
}])),
|
||||
Ok(plugins_test_response(vec![
|
||||
plugins_test_remote_marketplace(
|
||||
"workspace-shared-with-me-private",
|
||||
"Shared with me",
|
||||
vec![summary.clone()],
|
||||
),
|
||||
])),
|
||||
);
|
||||
chat.add_plugins_output();
|
||||
chat.on_plugin_detail_loaded(
|
||||
cwd.to_path_buf(),
|
||||
Ok(PluginReadResponse {
|
||||
plugin: PluginDetail {
|
||||
marketplace_name: "workspace-shared-with-me-private".to_string(),
|
||||
marketplace_path: None,
|
||||
plugin: plugins_test_remote_detail(
|
||||
"workspace-shared-with-me-private",
|
||||
summary,
|
||||
share_url: None,
|
||||
description: Some("Installed shared Linear plugin.".to_string()),
|
||||
skills: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
},
|
||||
Some("Installed shared Linear plugin."),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -947,15 +950,58 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() {
|
||||
"expected missing remote ID to disable uninstall, got:\n{popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected no action after rendering disabled uninstall state"
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
assert_eq!(
|
||||
render_bottom_popup(&chat, /*width*/ 120),
|
||||
popup,
|
||||
"expected navigation to skip the disabled uninstall row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_admin_disabled_plugin_blocks_install() {
|
||||
async fn plugin_detail_popup_shows_local_share_context_as_read_only_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
let summary = PluginSummary {
|
||||
share_context: Some(PluginShareContext {
|
||||
remote_plugin_id: "plugins~Plugin_docs".to_string(),
|
||||
remote_version: Some("7".to_string()),
|
||||
discoverability: Some(PluginShareDiscoverability::Private),
|
||||
share_url: Some("https://chatgpt.com/codex/plugins/share/docs".to_string()),
|
||||
creator_account_user_id: None,
|
||||
creator_name: Some("Test User".to_string()),
|
||||
share_principals: None,
|
||||
}),
|
||||
..plugins_test_summary(
|
||||
"plugin-docs",
|
||||
"docs",
|
||||
Some("Docs"),
|
||||
Some("Workspace docs."),
|
||||
/*installed*/ false,
|
||||
/*enabled*/ true,
|
||||
PluginInstallPolicy::Available,
|
||||
)
|
||||
};
|
||||
let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![
|
||||
summary.clone(),
|
||||
])]);
|
||||
let cwd = chat.config.cwd.clone();
|
||||
chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response));
|
||||
chat.add_plugins_output();
|
||||
let mut detail = plugins_test_detail(summary, Some("Workspace docs."), &[], &[], &[], &[]);
|
||||
detail.marketplace_path = Some(plugins_test_personal_marketplace_path());
|
||||
chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin: detail }));
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 120);
|
||||
assert_chatwidget_snapshot!(
|
||||
"plugin_detail_popup_local_share_read_only",
|
||||
strip_osc8_for_snapshot(&popup)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_detail_popup_shows_admin_disabled_status_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
|
||||
|
||||
@@ -984,10 +1030,17 @@ async fn plugin_detail_admin_disabled_plugin_blocks_install() {
|
||||
}),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 120);
|
||||
let status_row = popup
|
||||
.lines()
|
||||
.find(|line| line.contains("Disabled by admin"))
|
||||
.expect("expected admin-disabled status row");
|
||||
insta::assert_snapshot!(
|
||||
status_row,
|
||||
@" Admin Blocked · Disabled by admin · ChatGPT Marketplace"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("Admin Blocked · Disabled by admin")
|
||||
&& popup.contains("This plugin is disabled by your workspace admin.")
|
||||
popup.contains("This plugin is disabled by your workspace admin.")
|
||||
&& !popup.contains("Install this plugin now."),
|
||||
"expected admin-disabled detail to block install, got:\n{popup}"
|
||||
);
|
||||
@@ -1021,18 +1074,18 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() {
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]),
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 120);
|
||||
assert!(
|
||||
popup.contains("Disabled by admin")
|
||||
&& popup.contains("Press Enter to view plugin details.")
|
||||
&& !popup.contains("Space to disable"),
|
||||
"expected admin-disabled installed plugin to omit toggle hint, got:\n{popup}"
|
||||
"expected admin-disabled installed row to omit toggle hint, got:\n{popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
let before = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let before = render_bottom_popup(&chat, /*width*/ 120);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
|
||||
let after = render_bottom_popup(&chat, /*width*/ 100);
|
||||
let after = render_bottom_popup(&chat, /*width*/ 120);
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"space should not toggle admin-disabled installed plugins"
|
||||
@@ -1232,6 +1285,14 @@ async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share
|
||||
"expected installed remote duplicate to win over local mapped share, got:\n{popup}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
|
||||
let installed_popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
installed_popup.contains("Showing 1 installed plugins.")
|
||||
&& installed_popup.contains("Docs"),
|
||||
"expected installed remote duplicate in the Installed tab, got:\n{installed_popup}"
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
@@ -1765,7 +1826,14 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() {
|
||||
chat.on_plugins_loaded(cwd.clone(), Ok(response.clone()));
|
||||
chat.add_plugins_output();
|
||||
|
||||
for _ in 0..4 {
|
||||
for _ in 0..10 {
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
if popup.contains("Duplicate Marketplace (2/2).")
|
||||
&& popup.contains("Repo Plugin")
|
||||
&& !popup.contains("Home Plugin")
|
||||
{
|
||||
break;
|
||||
}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Right));
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
fn with_screen_size_and_cursor_position(
|
||||
pub(crate) fn with_screen_size_and_cursor_position(
|
||||
backend: B,
|
||||
screen_size: Size,
|
||||
cursor_pos: Position,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
■ Amazon Bedrock rejected the request because its AWS signature has expired. Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or unset it, then restart Codex, url: https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses
|
||||
@@ -15,11 +15,13 @@ use codex_otel::RuntimeMetricTotals;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::error::UnexpectedResponseError;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use dirs::home_dir;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -752,6 +754,31 @@ fn error_event_oversized_input_snapshot() {
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_event_bedrock_expired_signature_snapshot() {
|
||||
let error = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: "Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z \
|
||||
(20260614T063025Z - 5 min.)"
|
||||
.to_string(),
|
||||
user_message: Some(
|
||||
"Amazon Bedrock rejected the request because its AWS signature has expired. \
|
||||
Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or \
|
||||
unset it, then restart Codex"
|
||||
.to_string(),
|
||||
),
|
||||
url: Some("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: None,
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
let cell = new_error_event(error.to_string());
|
||||
let rendered = render_lines(&cell.display_lines(/*width*/ 100)).join("\n");
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tools_output_masks_sensitive_values() {
|
||||
let mut config = test_config().await;
|
||||
|
||||
@@ -60,6 +60,8 @@ mod frame_requester;
|
||||
mod job_control;
|
||||
mod keyboard_modes;
|
||||
mod terminal_stderr;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
|
||||
/// Target frame interval for UI redraw scheduling.
|
||||
pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use std::io;
|
||||
use std::io::stdout;
|
||||
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Size;
|
||||
|
||||
use super::Tui;
|
||||
use super::terminal_stderr::TerminalStderrGuard;
|
||||
use crate::custom_terminal::Terminal;
|
||||
|
||||
pub(crate) fn make_test_tui() -> io::Result<Tui> {
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let terminal = Terminal::with_screen_size_and_cursor_position(
|
||||
backend,
|
||||
Size {
|
||||
width: 80,
|
||||
height: 24,
|
||||
},
|
||||
Position { x: 0, y: 0 },
|
||||
);
|
||||
let stderr_guard = TerminalStderrGuard::install()?;
|
||||
Ok(Tui::new(
|
||||
terminal,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
stderr_guard,
|
||||
))
|
||||
}
|
||||
@@ -121,7 +121,7 @@ impl PathUri {
|
||||
/// Relative paths are reported as invalid input. Absolute paths without a
|
||||
/// valid URI representation use the fallback documented on
|
||||
/// [`Self::from_abs_path`].
|
||||
pub fn from_path(path: impl AsRef<Path>) -> io::Result<Self> {
|
||||
pub fn from_host_native_path(path: impl AsRef<Path>) -> io::Result<Self> {
|
||||
let path = AbsolutePathBuf::from_absolute_path_checked(path)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
Ok(Self::from_abs_path(&path))
|
||||
|
||||
@@ -423,7 +423,8 @@ fn path_uri_deserializes_legacy_absolute_paths() {
|
||||
|
||||
#[test]
|
||||
fn path_uri_rejects_relative_native_paths() {
|
||||
let error = PathUri::from_path("src/lib.rs").expect_err("relative path should be rejected");
|
||||
let error =
|
||||
PathUri::from_host_native_path("src/lib.rs").expect_err("relative path should be rejected");
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ app-server-test-client *args:
|
||||
|
||||
# Format the justfile, Rust, Bazel/Starlark, Python SDK code, and Python scripts.
|
||||
fmt:
|
||||
{{ python }} ../scripts/format.py
|
||||
@{{ python }} ../scripts/format.py
|
||||
|
||||
# Check formatting without modifying files.
|
||||
fmt-check:
|
||||
{{ python }} ../scripts/format.py --check
|
||||
@{{ python }} ../scripts/format.py --check
|
||||
|
||||
fix *args:
|
||||
cargo clippy --fix --tests --allow-dirty {args}
|
||||
|
||||
+12
-26
@@ -18,7 +18,6 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
class Command:
|
||||
args: tuple[str, ...]
|
||||
cwd: Path = REPO_ROOT
|
||||
discard_stderr: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,13 +44,7 @@ def rust_formatter_group(*, check: bool) -> FormatterGroup:
|
||||
args = ["cargo", "fmt", "--", "--config", "imports_granularity=Item"]
|
||||
if check:
|
||||
args.append("--check")
|
||||
# Stable rustfmt repeats a nightly-only `imports_granularity` warning
|
||||
# for each crate, so suppress that expected stderr noise.
|
||||
command = Command(
|
||||
tuple(args),
|
||||
REPO_ROOT / "codex-rs",
|
||||
discard_stderr=True,
|
||||
)
|
||||
command = Command(tuple(args), REPO_ROOT / "codex-rs")
|
||||
return FormatterGroup("Rust", (command,))
|
||||
|
||||
|
||||
@@ -152,31 +145,27 @@ def formatter_groups(*, check: bool) -> tuple[FormatterGroup, ...]:
|
||||
|
||||
def run_formatter_group(group: FormatterGroup) -> FormatterResult:
|
||||
"""Run one formatter group sequentially and return its buffered output."""
|
||||
output: list[str] = []
|
||||
for command in group.commands:
|
||||
output.append(f"$ {shlex.join(command.args)}\n")
|
||||
try:
|
||||
process = subprocess.run(
|
||||
command.args,
|
||||
cwd=command.cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
if command.discard_stderr
|
||||
else subprocess.STDOUT,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as error:
|
||||
output.append(f"{error}\n")
|
||||
return FormatterResult(group.name, "".join(output), 1)
|
||||
output = f"$ {shlex.join(command.args)}\n{error}\n"
|
||||
return FormatterResult(group.name, output, 1)
|
||||
|
||||
output.append(process.stdout)
|
||||
if process.stdout and not process.stdout.endswith("\n"):
|
||||
output.append("\n")
|
||||
if process.returncode != 0:
|
||||
return FormatterResult(group.name, "".join(output), process.returncode)
|
||||
output = f"$ {shlex.join(command.args)}\n{process.stdout}"
|
||||
if process.stdout and not process.stdout.endswith("\n"):
|
||||
output += "\n"
|
||||
return FormatterResult(group.name, output, process.returncode)
|
||||
|
||||
return FormatterResult(group.name, "".join(output), 0)
|
||||
return FormatterResult(group.name, "", 0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -191,16 +180,13 @@ def main() -> int:
|
||||
|
||||
failures: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=len(groups)) as executor:
|
||||
futures = {}
|
||||
for group in groups:
|
||||
print(f"Starting {group.name} formatter...", flush=True)
|
||||
futures[executor.submit(run_formatter_group, group)] = group.name
|
||||
futures = [executor.submit(run_formatter_group, group) for group in groups]
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
print(f"==> {result.name} formatter finished")
|
||||
print(result.output, end="")
|
||||
if result.returncode != 0:
|
||||
failures.append(result.name)
|
||||
print(f"==> {result.name} formatter failed", file=sys.stderr)
|
||||
print(result.output, end="", file=sys.stderr)
|
||||
|
||||
if failures:
|
||||
print(f"Formatting failed: {', '.join(failures)}", file=sys.stderr)
|
||||
|
||||
@@ -112,9 +112,9 @@ def test_root_fmt_recipes_use_shared_formatter_driver() -> None:
|
||||
"fmt_comment": (
|
||||
"# Format the justfile, Rust, Bazel/Starlark, Python SDK code, and Python scripts."
|
||||
),
|
||||
"fmt_commands": ["{{ python }} ../scripts/format.py"],
|
||||
"fmt_commands": ["@{{ python }} ../scripts/format.py"],
|
||||
"fmt_check_comment": "# Check formatting without modifying files.",
|
||||
"fmt_check_commands": ["{{ python }} ../scripts/format.py --check"],
|
||||
"fmt_check_commands": ["@{{ python }} ../scripts/format.py --check"],
|
||||
}
|
||||
|
||||
assert actual == expected, (
|
||||
@@ -245,6 +245,75 @@ def test_root_format_driver_covers_all_formatter_groups(
|
||||
]
|
||||
|
||||
|
||||
def test_root_format_driver_discards_successful_command_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
script = _load_root_format_script_module()
|
||||
processes = iter(
|
||||
(
|
||||
script.subprocess.CompletedProcess(("first",), 0, "routine output\n"),
|
||||
script.subprocess.CompletedProcess(("second",), 2, "failure output\n"),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(script.subprocess, "run", lambda *args, **kwargs: next(processes))
|
||||
group = script.FormatterGroup(
|
||||
"Test",
|
||||
(script.Command(("first",)), script.Command(("second",))),
|
||||
)
|
||||
|
||||
assert script.run_formatter_group(group) == script.FormatterResult(
|
||||
"Test",
|
||||
"$ second\nfailure output\n",
|
||||
2,
|
||||
)
|
||||
|
||||
|
||||
def test_root_format_driver_is_silent_when_all_formatters_succeed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
script = _load_root_format_script_module()
|
||||
groups = (script.FormatterGroup("Quiet", ()),)
|
||||
monkeypatch.setattr(script, "formatter_groups", lambda *, check: groups)
|
||||
monkeypatch.setattr(
|
||||
script,
|
||||
"run_formatter_group",
|
||||
lambda group: script.FormatterResult(group.name, "hidden output\n", 0),
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", ["format.py"])
|
||||
|
||||
assert script.main() == 0
|
||||
captured = capsys.readouterr()
|
||||
assert (captured.out, captured.err) == ("", "")
|
||||
|
||||
|
||||
def test_root_format_driver_reports_only_failed_formatters(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
script = _load_root_format_script_module()
|
||||
groups = (
|
||||
script.FormatterGroup("Quiet", ()),
|
||||
script.FormatterGroup("Broken", ()),
|
||||
)
|
||||
monkeypatch.setattr(script, "formatter_groups", lambda *, check: groups)
|
||||
|
||||
def fake_run(group):
|
||||
if group.name == "Broken":
|
||||
return script.FormatterResult(group.name, "$ broken\nfailure output\n", 2)
|
||||
return script.FormatterResult(group.name, "hidden output\n", 0)
|
||||
|
||||
monkeypatch.setattr(script, "run_formatter_group", fake_run)
|
||||
monkeypatch.setattr(sys, "argv", ["format.py"])
|
||||
|
||||
assert script.main() == 1
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert captured.err == (
|
||||
"==> Broken formatter failed\n$ broken\nfailure output\nFormatting failed: Broken\n"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_types_wires_all_generation_steps() -> None:
|
||||
"""The type generation command should refresh every schema-derived artifact."""
|
||||
source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text()
|
||||
|
||||
Reference in New Issue
Block a user