Use ApiPathString in app-server filesystem permission paths (#28367)

## Why

Clients running an app-server on one OS and an exec-server on another OS
need to be able to pass sandbox config to app-server that refers to
resources on the executor's foreign OS.

## What

`AbsolutePathBuf` can't represent these paths and we don't want users to
be exposed to `PathUri` yet, so this moves the public app-server API to
be expressed in terms of `ApiPathString`.

Stacked on #28165.

- change app-server v2 filesystem permission paths, including legacy
read/write roots, to `ApiPathString`
- localize API paths through `PathUri` when converting into the current
native core permission types
- make path-bearing permission conversions fallible and surface
localization failures instead of silently treating malformed grants as
ordinary denials
- propagate conversion failures through app-server and TUI approval
handling
- regenerate the app-server JSON and TypeScript schemas
- leave migration TODOs on native-path conversions so they can be
removed once core permission paths use `PathUri`
This commit is contained in:
Adam Perry @ OpenAI
2026-06-15 19:25:54 -07:00
committed by GitHub
Unverified
parent d959664420
commit ecfe174d5f
34 changed files with 546 additions and 233 deletions
@@ -786,6 +786,8 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
let pending_response = PendingRequestPermissionsResponse {
call_id: request.call_id,
conversation_id,
turn_id: request.turn_id,
requested_permissions,
request_cwd,
pending_request_id,
@@ -948,15 +950,14 @@ pub(crate) async fn apply_bespoke_event_handling(
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
additional_details: None,
};
handle_error(conversation_id, turn_error.clone(), &thread_state).await;
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
error: turn_error.clone(),
will_retry: false,
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
}))
.await;
handle_error_notification(
conversation_id,
&event_turn_id,
turn_error,
&outgoing,
&thread_state,
)
.await;
}
EventMsg::StreamError(ev) => {
// We don't need to update the turn summary store for stream errors as they are intermediate error states for retries,
@@ -1641,6 +1642,24 @@ async fn handle_error(
state.turn_summary.last_error = Some(error);
}
async fn handle_error_notification(
conversation_id: ThreadId,
event_turn_id: &str,
error: TurnError,
outgoing: &ThreadScopedOutgoingMessageSender,
thread_state: &Arc<Mutex<ThreadState>>,
) {
handle_error(conversation_id, error.clone(), thread_state).await;
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
error,
will_retry: false,
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.to_string(),
}))
.await;
}
async fn on_request_user_input_response(
event_turn_id: String,
pending_request_id: RequestId,
@@ -1796,6 +1815,8 @@ async fn on_request_permissions_response(
) {
let PendingRequestPermissionsResponse {
call_id,
conversation_id,
turn_id,
requested_permissions,
request_cwd,
pending_request_id,
@@ -1806,12 +1827,34 @@ async fn on_request_permissions_response(
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id.clone()).await;
drop(request_permissions_guard);
let Some(response) = request_permissions_response_from_client_result(
let response = match request_permissions_response_from_client_result(
requested_permissions,
response,
request_cwd.as_path(),
) else {
return;
) {
Ok(Some(response)) => response,
Ok(None) => return,
// TODO(anp): Remove this native-path localization error path once core permission paths
// remain PathUri after crossing the app-server boundary.
Err(err) => {
let message = format!("failed to localize granted filesystem paths: {err}");
handle_error_notification(
conversation_id,
&turn_id,
TurnError {
message,
codex_error_info: None,
additional_details: None,
},
&outgoing,
&thread_state,
)
.await;
if let Err(err) = conversation.submit(Op::Interrupt).await {
error!("failed to interrupt turn after invalid permission paths: {err}");
}
return;
}
};
outgoing.track_effective_permissions_approval_response(pending_request_id, response.clone());
@@ -1828,6 +1871,8 @@ async fn on_request_permissions_response(
struct PendingRequestPermissionsResponse {
call_id: String,
conversation_id: ThreadId,
turn_id: String,
requested_permissions: CoreRequestPermissionProfile,
request_cwd: AbsolutePathBuf,
pending_request_id: RequestId,
@@ -1840,25 +1885,25 @@ fn request_permissions_response_from_client_result(
requested_permissions: CoreRequestPermissionProfile,
response: std::result::Result<ClientRequestResult, oneshot::error::RecvError>,
cwd: &std::path::Path,
) -> Option<CoreRequestPermissionsResponse> {
) -> std::io::Result<Option<CoreRequestPermissionsResponse>> {
let value = match response {
Ok(Ok(value)) => value,
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return None,
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return Ok(None),
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
return Some(CoreRequestPermissionsResponse {
return Ok(Some(CoreRequestPermissionsResponse {
permissions: Default::default(),
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
});
}));
}
Err(err) => {
error!("request failed: {err:?}");
return Some(CoreRequestPermissionsResponse {
return Ok(Some(CoreRequestPermissionsResponse {
permissions: Default::default(),
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
});
}));
}
};
@@ -1879,23 +1924,23 @@ fn request_permissions_response_from_client_result(
)
{
error!("strict auto review is only supported for turn-scoped permission grants");
return Some(CoreRequestPermissionsResponse {
return Ok(Some(CoreRequestPermissionsResponse {
permissions: Default::default(),
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
});
}));
}
let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.into();
let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.try_into()?;
let permissions = if granted_permissions.is_empty() {
CoreRequestPermissionProfile::default()
} else {
intersect_permission_profiles(requested_permissions.into(), granted_permissions, cwd).into()
};
Some(CoreRequestPermissionsResponse {
Ok(Some(CoreRequestPermissionsResponse {
permissions,
scope: response.scope.to_core(),
strict_auto_review,
})
}))
}
const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response.";
@@ -2899,7 +2944,8 @@ mod tests {
CoreRequestPermissionProfile::default(),
Ok(Err(error)),
std::env::current_dir().expect("current dir").as_path(),
);
)
.expect("paths should localize");
assert_eq!(response, None);
}
@@ -2994,6 +3040,7 @@ mod tests {
}))),
cwd.as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
@@ -3017,6 +3064,7 @@ mod tests {
}))),
std::env::current_dir().expect("current dir").as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
@@ -3044,6 +3092,7 @@ mod tests {
}))),
std::env::current_dir().expect("current dir").as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
@@ -3075,6 +3124,7 @@ mod tests {
}))),
std::env::current_dir().expect("current dir").as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(response.scope, CorePermissionGrantScope::Turn);
@@ -3110,6 +3160,7 @@ mod tests {
}))),
cwd.as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
@@ -3156,6 +3207,7 @@ mod tests {
}))),
request_cwd.as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
@@ -3197,6 +3249,7 @@ mod tests {
}))),
cwd.as_path(),
)
.expect("paths should localize")
.expect("response should be accepted");
assert_eq!(
+2 -2
View File
@@ -260,7 +260,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou
network: None,
file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]),
read: Some(vec![absolute_path("/tmp/allowed").into()]),
write: None,
glob_scan_max_depth: None,
entries: None,
@@ -325,7 +325,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca
network: None,
file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]),
read: Some(vec![absolute_path("/tmp/allowed").into()]),
write: None,
glob_scan_max_depth: None,
entries: None,