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
+66 -4
View File
@@ -12,6 +12,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
impl App {
pub(super) async fn reject_app_server_request(
@@ -103,6 +104,18 @@ impl PendingAppServerRequests {
None
}
ServerRequest::PermissionsRequestApproval { request_id, params } => {
// TODO(anp): Remove this duplicate validation once core permission paths remain
// PathUri after crossing the app-server boundary. Native permission paths do not
// yet have an ingress validation step, so validate them here before recording the
// request as pending. Discovering an invalid path later in a UI delivery path
// would leave the app-server RPC waiting without a clean rejection path.
if let Err(err) = CoreRequestPermissionProfile::try_from(params.permissions.clone())
{
return Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: format!("failed to localize requested filesystem paths: {err}"),
});
}
self.permissions_approvals
.insert(params.item_id.clone(), request_id.clone());
None
@@ -397,6 +410,7 @@ struct McpRequestKey {
mod tests {
use super::PendingAppServerRequests;
use super::ResolvedAppServerRequest;
use super::UnsupportedAppServerRequest;
use crate::app_command::AppCommand as Op;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
@@ -465,6 +479,54 @@ mod tests {
assert_eq!(resolution.result, json!({ "decision": "accept" }));
}
#[test]
fn rejects_permissions_with_paths_that_cannot_be_localized() {
let mut pending = PendingAppServerRequests::default();
let request_id = AppServerRequestId::Integer(7);
let permissions = codex_app_server_protocol::RequestPermissionProfile {
network: None,
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![
serde_json::from_value(json!("relative/path"))
.expect("relative API path should deserialize"),
]),
write: None,
glob_scan_max_depth: None,
entries: None,
}),
};
let localization_error =
RequestPermissionProfile::try_from(permissions.clone()).expect_err("relative path");
let cwd = AbsolutePathBuf::try_from(PathBuf::from(if cfg!(windows) {
r"C:\tmp"
} else {
"/tmp"
}))
.expect("path must be absolute");
assert_eq!(
pending.note_server_request(&ServerRequest::PermissionsRequestApproval {
request_id: request_id.clone(),
params: PermissionsRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "perm-1".to_string(),
environment_id: None,
started_at_ms: 0,
cwd,
reason: None,
permissions,
},
}),
Some(UnsupportedAppServerRequest {
request_id,
message: format!(
"failed to localize requested filesystem paths: {localization_error}"
),
})
);
}
#[test]
fn resolves_permissions_and_user_input_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
@@ -544,19 +606,19 @@ mod tests {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![absolute_path(read_path)]),
write: Some(vec![absolute_path(write_path)]),
read: Some(vec![absolute_path(read_path).into()]),
write: Some(vec![absolute_path(write_path).into()]),
glob_scan_max_depth: None,
entries: Some(vec![
codex_app_server_protocol::FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Path {
path: absolute_path(read_path),
path: absolute_path(read_path).into(),
},
access: codex_app_server_protocol::FileSystemAccessMode::Read,
},
codex_app_server_protocol::FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Path {
path: absolute_path(write_path),
path: absolute_path(write_path).into(),
},
access: codex_app_server_protocol::FileSystemAccessMode::Write,
},
+1 -1
View File
@@ -529,7 +529,7 @@ impl App {
{
if self.discard_side_thread(app_server, side_thread_id).await {
self.surface_pending_inactive_thread_interactive_requests()
.await;
.await?;
} else if active_thread_id_before_switch == Some(side_thread_id) {
self.keep_side_thread_visible_after_cleanup_failure(
tui,
+13 -7
View File
@@ -2433,7 +2433,7 @@ async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()>
app.side_threads.remove(&side_thread_id);
app.active_thread_id = Some(main_thread_id);
app.surface_pending_inactive_thread_interactive_requests()
.await;
.await?;
assert_eq!(app.chat_widget.has_active_view(), true);
@@ -2462,8 +2462,8 @@ async fn inactive_thread_exec_approval_preserves_context() {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None,
entries: None,
}),
@@ -2481,6 +2481,7 @@ async fn inactive_thread_exec_approval_preserves_context() {
})) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected exec approval request");
};
@@ -2499,8 +2500,8 @@ async fn inactive_thread_exec_approval_preserves_context() {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None,
entries: None,
}),
@@ -2542,6 +2543,7 @@ async fn inactive_thread_exec_approval_splits_shell_wrapped_command() {
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { command, .. })) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected exec approval request");
};
@@ -2595,6 +2597,7 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
let request = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
.expect("expected file change approval request");
let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch {
@@ -2646,8 +2649,8 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions(
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None,
entries: None,
}),
@@ -2662,6 +2665,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions(
})) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected permissions approval request");
};
@@ -2703,6 +2707,7 @@ async fn inactive_thread_url_elicitation_routes_to_app_link() {
let Some(ThreadInteractiveRequest::AppLink(params)) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected app link request");
};
@@ -2742,6 +2747,7 @@ async fn inactive_thread_invalid_url_elicitation_is_declined() {
assert!(
app.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
.is_none()
);
assert_matches!(
+30 -17
View File
@@ -210,9 +210,9 @@ impl App {
&self,
thread_id: ThreadId,
request: &ServerRequest,
) -> Option<ThreadInteractiveRequest> {
) -> std::io::Result<Option<ThreadInteractiveRequest>> {
let thread_label = Some(self.thread_label(thread_id));
match request {
Ok(match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
let network_approval_context = params.network_approval_context.clone();
let additional_permissions = params.additional_permissions.clone();
@@ -305,18 +305,28 @@ impl App {
}
}
}
ServerRequest::PermissionsRequestApproval { params, .. } => Some(
ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions {
thread_id,
thread_label,
call_id: params.item_id.clone(),
environment_id: params.environment_id.clone(),
reason: params.reason.clone(),
permissions: params.permissions.clone().into(),
}),
),
ServerRequest::PermissionsRequestApproval { params, .. } => {
// TODO(anp): Remove this native-path localization error path once core permission
// paths remain PathUri after crossing the app-server boundary.
let permissions = params.permissions.clone().try_into().map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("failed to localize requested filesystem paths: {err}"),
)
})?;
Some(ThreadInteractiveRequest::Approval(
ApprovalRequest::Permissions {
thread_id,
thread_label,
call_id: params.item_id.clone(),
environment_id: params.environment_id.clone(),
reason: params.reason.clone(),
permissions,
},
))
}
_ => None,
}
})
}
pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) {
@@ -376,20 +386,23 @@ impl App {
requests
}
pub(super) async fn surface_pending_inactive_thread_interactive_requests(&mut self) {
pub(super) async fn surface_pending_inactive_thread_interactive_requests(
&mut self,
) -> Result<()> {
if self.active_side_parent_thread_id().is_some() {
return;
return Ok(());
}
let requests = self.pending_inactive_thread_requests().await;
for (thread_id, request) in requests {
if let Some(request) = self
.interactive_request_for_thread_request(thread_id, &request)
.await
.await?
{
self.push_thread_interactive_request(request);
}
}
Ok(())
}
pub(super) async fn submit_active_thread_op(
@@ -991,7 +1004,7 @@ impl App {
) -> Result<()> {
let inactive_interactive_request = if self.active_thread_id != Some(thread_id) {
self.interactive_request_for_thread_request(thread_id, &request)
.await
.await?
} else {
None
};