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
parent d959664420
commit ecfe174d5f
34 changed files with 546 additions and 233 deletions
+2
View File
@@ -2109,6 +2109,7 @@ dependencies = [
"codex-shell-command", "codex-shell-command",
"codex-utils-absolute-path", "codex-utils-absolute-path",
"codex-utils-cargo-bin", "codex-utils-cargo-bin",
"codex-utils-path-uri",
"inventory", "inventory",
"pretty_assertions", "pretty_assertions",
"rmcp", "rmcp",
@@ -4050,6 +4051,7 @@ dependencies = [
"codex-utils-home-dir", "codex-utils-home-dir",
"codex-utils-oss", "codex-utils-oss",
"codex-utils-path", "codex-utils-path",
"codex-utils-path-uri",
"codex-utils-plugins", "codex-utils-plugins",
"codex-utils-sandbox-summary", "codex-utils-sandbox-summary",
"codex-utils-sleep-inhibitor", "codex-utils-sleep-inhibitor",
+1
View File
@@ -19,6 +19,7 @@ codex-experimental-api-macros = { workspace = true }
codex-protocol = { workspace = true } codex-protocol = { workspace = true }
codex-shell-command = { workspace = true } codex-shell-command = { workspace = true }
codex-utils-absolute-path = { workspace = true } codex-utils-absolute-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
schemars = { workspace = true } schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
@@ -27,7 +27,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -37,7 +37,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -84,6 +84,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"CommandAction": { "CommandAction": {
"oneOf": [ "oneOf": [
{ {
@@ -286,7 +289,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -27,7 +27,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -37,7 +37,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -58,6 +58,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"FileSystemAccessMode": { "FileSystemAccessMode": {
"enum": [ "enum": [
"read", "read",
@@ -71,7 +74,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -1,10 +1,6 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"definitions": { "definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AdditionalFileSystemPermissions": { "AdditionalFileSystemPermissions": {
"properties": { "properties": {
"entries": { "entries": {
@@ -27,7 +23,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -37,7 +33,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -58,6 +54,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"FileSystemAccessMode": { "FileSystemAccessMode": {
"enum": [ "enum": [
"read", "read",
@@ -71,7 +70,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -107,7 +107,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -117,7 +117,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -164,6 +164,9 @@
"AgentPath": { "AgentPath": {
"type": "string" "type": "string"
}, },
"ApiPathString": {
"type": "string"
},
"AppBranding": { "AppBranding": {
"description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "description": "EXPERIMENTAL - app metadata returned by app-list APIs.",
"properties": { "properties": {
@@ -1318,7 +1321,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
+6 -3
View File
@@ -27,7 +27,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -37,7 +37,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -84,6 +84,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"ApplyPatchApprovalParams": { "ApplyPatchApprovalParams": {
"properties": { "properties": {
"callId": { "callId": {
@@ -640,7 +643,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -6020,7 +6020,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/v2/AbsolutePathBuf" "$ref": "#/definitions/v2/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -6030,7 +6030,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/v2/AbsolutePathBuf" "$ref": "#/definitions/v2/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -6135,6 +6135,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"AppBranding": { "AppBranding": {
"description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "description": "EXPERIMENTAL - app metadata returned by app-list APIs.",
"properties": { "properties": {
@@ -9372,7 +9375,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/v2/AbsolutePathBuf" "$ref": "#/definitions/v2/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -265,7 +265,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -275,7 +275,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -380,6 +380,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"AppBranding": { "AppBranding": {
"description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "description": "EXPERIMENTAL - app metadata returned by app-list APIs.",
"properties": { "properties": {
@@ -5685,7 +5688,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -27,7 +27,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -37,7 +37,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -58,6 +58,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"AutoReviewDecisionSource": { "AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.",
"enum": [ "enum": [
@@ -78,7 +81,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -27,7 +27,7 @@
"read": { "read": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -37,7 +37,7 @@
"write": { "write": {
"description": "This will be removed in favor of `entries`.", "description": "This will be removed in favor of `entries`.",
"items": { "items": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": [ "type": [
"array", "array",
@@ -58,6 +58,9 @@
}, },
"type": "object" "type": "object"
}, },
"ApiPathString": {
"type": "string"
},
"FileSystemAccessMode": { "FileSystemAccessMode": {
"enum": [ "enum": [
"read", "read",
@@ -71,7 +74,7 @@
{ {
"properties": { "properties": {
"path": { "path": {
"$ref": "#/definitions/AbsolutePathBuf" "$ref": "#/definitions/ApiPathString"
}, },
"type": { "type": {
"enum": [ "enum": [
@@ -0,0 +1,26 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A UTF-8 path for preserving raw path compatibility at the app-server API
* boundary while Codex migrates to [`PathUri`].
*
* Supports storing arbitrary strings read from the API and converting to and
* from [`PathUri`] using an explicitly selected native path convention.
*
* When converting from [`PathUri`], "native" refers to the supplied
* [`PathConvention`], which may be foreign to the operating system running
* this process. The inner string is private so path-producing code must convert
* from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing
* the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8
* lossily because this API value is serialized as a JSON string.
*
* Deserialization accepts any UTF-8 string without interpreting or validating
* it. That unrestricted construction path is intentionally available only to
* serde: Codex-internal code cannot construct this type directly from a raw
* `String` and is instead encouraged to convert through [`PathUri`] or
* [`AbsolutePathBuf`]. Relative path text remains valid until an operation
* such as [`Self::to_path_uri`] requires an absolute path.
*/
export type ApiPathString = string;
+1
View File
@@ -3,6 +3,7 @@
export type { AbsolutePathBuf } from "./AbsolutePathBuf"; export type { AbsolutePathBuf } from "./AbsolutePathBuf";
export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentMessageInputContent } from "./AgentMessageInputContent";
export type { AgentPath } from "./AgentPath"; export type { AgentPath } from "./AgentPath";
export type { ApiPathString } from "./ApiPathString";
export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams";
export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse";
export type { AuthMode } from "./AuthMode"; export type { AuthMode } from "./AuthMode";
@@ -1,15 +1,15 @@
// GENERATED CODE! DO NOT MODIFY BY HAND! // GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ApiPathString } from "../ApiPathString";
import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry";
export type AdditionalFileSystemPermissions = { export type AdditionalFileSystemPermissions = {
/** /**
* This will be removed in favor of `entries`. * This will be removed in favor of `entries`.
*/ */
read: Array<AbsolutePathBuf> | null, read: Array<ApiPathString> | null,
/** /**
* This will be removed in favor of `entries`. * This will be removed in favor of `entries`.
*/ */
write: Array<AbsolutePathBuf> | null, globScanMaxDepth?: number, entries?: Array<FileSystemSandboxEntry>, }; write: Array<ApiPathString> | null, globScanMaxDepth?: number, entries?: Array<FileSystemSandboxEntry>, };
@@ -1,7 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND! // GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ApiPathString } from "../ApiPathString";
import type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; import type { FileSystemSpecialPath } from "./FileSystemSpecialPath";
export type FileSystemPath = { "type": "path", path: AbsolutePathBuf, } | { "type": "glob_pattern", pattern: string, } | { "type": "special", value: FileSystemSpecialPath, }; export type FileSystemPath = { "type": "path", path: ApiPathString, } | { "type": "glob_pattern", pattern: string, } | { "type": "special", value: FileSystemSpecialPath, };
@@ -3504,7 +3504,7 @@ mod tests {
additional_permissions: Some(v2::AdditionalPermissionProfile { additional_permissions: Some(v2::AdditionalPermissionProfile {
network: None, network: None,
file_system: Some(v2::AdditionalFileSystemPermissions { file_system: Some(v2::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]), read: Some(vec![absolute_path("/tmp/allowed").into()]),
write: None, write: None,
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
@@ -37,6 +37,7 @@ use serde::Serialize;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use serde_with::serde_as; use serde_with::serde_as;
use std::collections::HashMap; use std::collections::HashMap;
use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use ts_rs::TS; use ts_rs::TS;
@@ -687,9 +688,11 @@ impl From<CoreGuardianAssessmentAction> for GuardianApprovalReviewAction {
} }
} }
impl From<GuardianApprovalReviewAction> for CoreGuardianAssessmentAction { impl TryFrom<GuardianApprovalReviewAction> for CoreGuardianAssessmentAction {
fn from(value: GuardianApprovalReviewAction) -> Self { type Error = io::Error;
match value {
fn try_from(value: GuardianApprovalReviewAction) -> Result<Self, Self::Error> {
Ok(match value {
GuardianApprovalReviewAction::Command { GuardianApprovalReviewAction::Command {
source, source,
command, command,
@@ -742,9 +745,9 @@ impl From<GuardianApprovalReviewAction> for CoreGuardianAssessmentAction {
permissions, permissions,
} => Self::RequestPermissions { } => Self::RequestPermissions {
reason, reason,
permissions: permissions.into(), permissions: permissions.try_into()?,
}, },
} })
} }
} }
@@ -16,9 +16,12 @@ use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::ApiPathString;
use codex_utils_path_uri::PathConvention;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use std::io;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::path::PathBuf; use std::path::PathBuf;
use ts_rs::TS; use ts_rs::TS;
@@ -54,9 +57,9 @@ impl From<CoreNetworkApprovalContext> for NetworkApprovalContext {
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
pub struct AdditionalFileSystemPermissions { pub struct AdditionalFileSystemPermissions {
/// This will be removed in favor of `entries`. /// This will be removed in favor of `entries`.
pub read: Option<Vec<AbsolutePathBuf>>, pub read: Option<Vec<ApiPathString>>,
/// This will be removed in favor of `entries`. /// This will be removed in favor of `entries`.
pub write: Option<Vec<AbsolutePathBuf>>, pub write: Option<Vec<ApiPathString>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)] #[ts(optional)]
pub glob_scan_max_depth: Option<NonZeroUsize>, pub glob_scan_max_depth: Option<NonZeroUsize>,
@@ -65,27 +68,32 @@ pub struct AdditionalFileSystemPermissions {
pub entries: Option<Vec<FileSystemSandboxEntry>>, pub entries: Option<Vec<FileSystemSandboxEntry>>,
} }
impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions { // TODO(anp): Remove this conversion once core permission paths use PathUri.
fn from(value: CoreFileSystemPermissions) -> Self { impl From<CoreFileSystemPermissions<AbsolutePathBuf>> for AdditionalFileSystemPermissions {
fn from(value: CoreFileSystemPermissions<AbsolutePathBuf>) -> Self {
if let Some((read, write)) = value.legacy_read_write_roots() { if let Some((read, write)) = value.legacy_read_write_roots() {
let mut entries = Vec::with_capacity( let mut entries = Vec::with_capacity(
read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len),
); );
if let Some(paths) = read.as_ref() { if let Some(paths) = read.as_ref() {
entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { entries.extend(paths.iter().map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path: path.clone() }, path: FileSystemPath::Path {
path: ApiPathString::from_abs_path(path),
},
access: FileSystemAccessMode::Read, access: FileSystemAccessMode::Read,
})); }));
} }
if let Some(paths) = write.as_ref() { if let Some(paths) = write.as_ref() {
entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { entries.extend(paths.iter().map(|path| FileSystemSandboxEntry {
path: FileSystemPath::Path { path: path.clone() }, path: FileSystemPath::Path {
path: ApiPathString::from_abs_path(path),
},
access: FileSystemAccessMode::Write, access: FileSystemAccessMode::Write,
})); }));
} }
Self { Self {
read, read: read.map(|paths| paths.iter().map(ApiPathString::from_abs_path).collect()),
write, write: write.map(|paths| paths.iter().map(ApiPathString::from_abs_path).collect()),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: Some(entries), entries: Some(entries),
} }
@@ -106,21 +114,50 @@ impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
} }
} }
impl From<AdditionalFileSystemPermissions> for CoreFileSystemPermissions { // TODO(anp): Remove this conversion once core permission paths use PathUri.
fn from(value: AdditionalFileSystemPermissions) -> Self { impl TryFrom<AdditionalFileSystemPermissions> for CoreFileSystemPermissions<AbsolutePathBuf> {
type Error = io::Error;
fn try_from(value: AdditionalFileSystemPermissions) -> Result<Self, Self::Error> {
let mut permissions = if let Some(entries) = value.entries { let mut permissions = if let Some(entries) = value.entries {
Self { Self {
entries: entries entries: entries
.into_iter() .into_iter()
.map(CoreFileSystemSandboxEntry::from) .map(CoreFileSystemSandboxEntry::<AbsolutePathBuf>::try_from)
.collect(), .collect::<io::Result<_>>()?,
glob_scan_max_depth: None, glob_scan_max_depth: None,
} }
} else { } else {
CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) let read = value
.read
.map(|paths| {
paths
.into_iter()
.map(|path| {
path.to_path_uri(PathConvention::native())
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?
.to_abs_path()
})
.collect::<io::Result<Vec<_>>>()
})
.transpose()?;
let write = value
.write
.map(|paths| {
paths
.into_iter()
.map(|path| {
path.to_path_uri(PathConvention::native())
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?
.to_abs_path()
})
.collect::<io::Result<Vec<_>>>()
})
.transpose()?;
CoreFileSystemPermissions::from_read_write_roots(read, write)
}; };
permissions.glob_scan_max_depth = value.glob_scan_max_depth; permissions.glob_scan_max_depth = value.glob_scan_max_depth;
permissions Ok(permissions)
} }
} }
@@ -156,6 +193,7 @@ pub struct RequestPermissionProfile {
pub file_system: Option<AdditionalFileSystemPermissions>, pub file_system: Option<AdditionalFileSystemPermissions>,
} }
// TODO(anp): Remove this conversion once core permission paths use PathUri.
impl From<CoreRequestPermissionProfile> for RequestPermissionProfile { impl From<CoreRequestPermissionProfile> for RequestPermissionProfile {
fn from(value: CoreRequestPermissionProfile) -> Self { fn from(value: CoreRequestPermissionProfile) -> Self {
Self { Self {
@@ -165,12 +203,17 @@ impl From<CoreRequestPermissionProfile> for RequestPermissionProfile {
} }
} }
impl From<RequestPermissionProfile> for CoreRequestPermissionProfile { impl TryFrom<RequestPermissionProfile> for CoreRequestPermissionProfile {
fn from(value: RequestPermissionProfile) -> Self { type Error = io::Error;
Self {
fn try_from(value: RequestPermissionProfile) -> Result<Self, Self::Error> {
Ok(Self {
network: value.network.map(CoreNetworkPermissions::from), network: value.network.map(CoreNetworkPermissions::from),
file_system: value.file_system.map(CoreFileSystemPermissions::from), file_system: value
} .file_system
.map(CoreFileSystemPermissions::<AbsolutePathBuf>::try_from)
.transpose()?,
})
} }
} }
@@ -231,16 +274,20 @@ impl From<FileSystemSpecialPath> for CoreFileSystemSpecialPath {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")] #[ts(tag = "type")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
// TODO(anp): Rename this type to distinguish it from the generic protocol FileSystemPath.
pub enum FileSystemPath { pub enum FileSystemPath {
Path { path: AbsolutePathBuf }, Path { path: ApiPathString },
GlobPattern { pattern: String }, GlobPattern { pattern: String },
Special { value: FileSystemSpecialPath }, Special { value: FileSystemSpecialPath },
} }
impl From<CoreFileSystemPath> for FileSystemPath { // TODO(anp): Remove this conversion once core permission paths use PathUri.
fn from(value: CoreFileSystemPath) -> Self { impl From<CoreFileSystemPath<AbsolutePathBuf>> for FileSystemPath {
fn from(value: CoreFileSystemPath<AbsolutePathBuf>) -> Self {
match value { match value {
CoreFileSystemPath::Path { path } => Self::Path { path }, CoreFileSystemPath::Path { path } => Self::Path {
path: ApiPathString::from_abs_path(&path),
},
CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern },
CoreFileSystemPath::Special { value } => Self::Special { CoreFileSystemPath::Special { value } => Self::Special {
value: value.into(), value: value.into(),
@@ -249,15 +296,23 @@ impl From<CoreFileSystemPath> for FileSystemPath {
} }
} }
impl From<FileSystemPath> for CoreFileSystemPath { // TODO(anp): Remove this conversion once core permission paths use PathUri.
fn from(value: FileSystemPath) -> Self { impl TryFrom<FileSystemPath> for CoreFileSystemPath<AbsolutePathBuf> {
match value { type Error = io::Error;
FileSystemPath::Path { path } => Self::Path { path },
fn try_from(value: FileSystemPath) -> Result<Self, Self::Error> {
Ok(match value {
FileSystemPath::Path { path } => Self::Path {
path: path
.to_path_uri(PathConvention::native())
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?
.to_abs_path()?,
},
FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern },
FileSystemPath::Special { value } => Self::Special { FileSystemPath::Special { value } => Self::Special {
value: value.into(), value: value.into(),
}, },
} })
} }
} }
@@ -269,8 +324,9 @@ pub struct FileSystemSandboxEntry {
pub access: FileSystemAccessMode, pub access: FileSystemAccessMode,
} }
impl From<CoreFileSystemSandboxEntry> for FileSystemSandboxEntry { // TODO(anp): Remove this conversion once core permission paths use PathUri.
fn from(value: CoreFileSystemSandboxEntry) -> Self { impl From<CoreFileSystemSandboxEntry<AbsolutePathBuf>> for FileSystemSandboxEntry {
fn from(value: CoreFileSystemSandboxEntry<AbsolutePathBuf>) -> Self {
Self { Self {
path: value.path.into(), path: value.path.into(),
access: value.access.into(), access: value.access.into(),
@@ -278,12 +334,14 @@ impl From<CoreFileSystemSandboxEntry> for FileSystemSandboxEntry {
} }
} }
impl From<FileSystemSandboxEntry> for CoreFileSystemSandboxEntry { impl TryFrom<FileSystemSandboxEntry> for CoreFileSystemSandboxEntry<AbsolutePathBuf> {
fn from(value: FileSystemSandboxEntry) -> Self { type Error = io::Error;
Self {
path: value.path.into(), fn try_from(value: FileSystemSandboxEntry) -> Result<Self, Self::Error> {
Ok(Self {
path: value.path.try_into()?,
access: value.access.to_core(), access: value.access.to_core(),
} })
} }
} }
@@ -375,6 +433,7 @@ pub struct AdditionalPermissionProfile {
pub file_system: Option<AdditionalFileSystemPermissions>, pub file_system: Option<AdditionalFileSystemPermissions>,
} }
// TODO(anp): Remove this conversion once core permission paths use PathUri.
impl From<CoreAdditionalPermissionProfile> for AdditionalPermissionProfile { impl From<CoreAdditionalPermissionProfile> for AdditionalPermissionProfile {
fn from(value: CoreAdditionalPermissionProfile) -> Self { fn from(value: CoreAdditionalPermissionProfile) -> Self {
Self { Self {
@@ -384,12 +443,17 @@ impl From<CoreAdditionalPermissionProfile> for AdditionalPermissionProfile {
} }
} }
impl From<AdditionalPermissionProfile> for CoreAdditionalPermissionProfile { impl TryFrom<AdditionalPermissionProfile> for CoreAdditionalPermissionProfile {
fn from(value: AdditionalPermissionProfile) -> Self { type Error = io::Error;
Self {
fn try_from(value: AdditionalPermissionProfile) -> Result<Self, Self::Error> {
Ok(Self {
network: value.network.map(CoreNetworkPermissions::from), network: value.network.map(CoreNetworkPermissions::from),
file_system: value.file_system.map(CoreFileSystemPermissions::from), file_system: value
} .file_system
.map(CoreFileSystemPermissions::<AbsolutePathBuf>::try_from)
.transpose()?,
})
} }
} }
@@ -405,12 +469,17 @@ pub struct GrantedPermissionProfile {
pub file_system: Option<AdditionalFileSystemPermissions>, pub file_system: Option<AdditionalFileSystemPermissions>,
} }
impl From<GrantedPermissionProfile> for CoreAdditionalPermissionProfile { impl TryFrom<GrantedPermissionProfile> for CoreAdditionalPermissionProfile {
fn from(value: GrantedPermissionProfile) -> Self { type Error = io::Error;
Self {
fn try_from(value: GrantedPermissionProfile) -> Result<Self, Self::Error> {
Ok(Self {
network: value.network.map(CoreNetworkPermissions::from), network: value.network.map(CoreNetworkPermissions::from),
file_system: value.file_system.map(CoreFileSystemPermissions::from), file_system: value
} .file_system
.map(CoreFileSystemPermissions::<AbsolutePathBuf>::try_from)
.transpose()?,
})
} }
} }
@@ -35,6 +35,7 @@ use codex_protocol::user_input::UserInput as CoreUserInput;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf; use codex_utils_absolute_path::test_support::test_path_buf;
use codex_utils_path_uri::ApiPathString;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use serde_json::json; use serde_json::json;
@@ -379,8 +380,8 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() {
} }
#[test] #[test]
fn command_execution_request_approval_rejects_relative_additional_permission_paths() { fn command_execution_request_approval_localization_rejects_relative_additional_permission_paths() {
let err = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({ let params = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({
"threadId": "thr_123", "threadId": "thr_123",
"turnId": "turn_123", "turnId": "turn_123",
"itemId": "call_123", "itemId": "call_123",
@@ -401,12 +402,14 @@ fn command_execution_request_approval_rejects_relative_additional_permission_pat
"proposedNetworkPolicyAmendments": null, "proposedNetworkPolicyAmendments": null,
"availableDecisions": null "availableDecisions": null
})) }))
.expect_err("relative additional permission paths should fail"); .expect("API paths should deserialize before localization");
assert!( let additional_permissions = params
err.to_string() .additional_permissions
.contains("AbsolutePathBuf deserialized without a base path"), .expect("additional permissions should be present");
"unexpected error: {err}"
); let err = CoreAdditionalPermissionProfile::try_from(additional_permissions)
.expect_err("relative additional permission paths should fail localization");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
} }
#[test] #[test]
@@ -451,12 +454,12 @@ fn permissions_request_approval_uses_request_permission_profile() {
}), }),
file_system: Some(AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![ read: Some(vec![
AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) serde_json::from_value(json!(read_only_path))
.expect("path must be absolute"), .expect("API path string should deserialize")
]), ]),
write: Some(vec![ write: Some(vec![
AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) serde_json::from_value(json!(read_write_path))
.expect("path must be absolute"), .expect("API path string should deserialize")
]), ]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
@@ -465,7 +468,8 @@ fn permissions_request_approval_uses_request_permission_profile() {
); );
assert_eq!( assert_eq!(
CoreRequestPermissionProfile::from(params.permissions), CoreRequestPermissionProfile::try_from(params.permissions)
.expect("API paths should convert to native paths"),
CoreRequestPermissionProfile { CoreRequestPermissionProfile {
network: Some(CoreNetworkPermissions { network: Some(CoreNetworkPermissions {
enabled: Some(true), enabled: Some(true),
@@ -559,7 +563,8 @@ fn additional_file_system_permissions_preserves_canonical_entries() {
} }
); );
assert_eq!( assert_eq!(
CoreFileSystemPermissions::from(permissions), CoreFileSystemPermissions::try_from(permissions)
.expect("API paths should convert to native paths"),
core_permissions core_permissions
); );
} }
@@ -574,23 +579,25 @@ fn additional_file_system_permissions_populates_entries_for_legacy_roots() {
); );
let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone());
let read_only_api_path = ApiPathString::from_abs_path(&read_only_path);
let read_write_api_path = ApiPathString::from_abs_path(&read_write_path);
assert_eq!( assert_eq!(
permissions, permissions,
AdditionalFileSystemPermissions { AdditionalFileSystemPermissions {
read: Some(vec![read_only_path.clone()]), read: Some(vec![read_only_api_path.clone()]),
write: Some(vec![read_write_path.clone()]), write: Some(vec![read_write_api_path.clone()]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: Some(vec![ entries: Some(vec![
FileSystemSandboxEntry { FileSystemSandboxEntry {
path: FileSystemPath::Path { path: FileSystemPath::Path {
path: read_only_path, path: read_only_api_path,
}, },
access: FileSystemAccessMode::Read, access: FileSystemAccessMode::Read,
}, },
FileSystemSandboxEntry { FileSystemSandboxEntry {
path: FileSystemPath::Path { path: FileSystemPath::Path {
path: read_write_path, path: read_write_api_path,
}, },
access: FileSystemAccessMode::Write, access: FileSystemAccessMode::Write,
}, },
@@ -598,7 +605,8 @@ fn additional_file_system_permissions_populates_entries_for_legacy_roots() {
} }
); );
assert_eq!( assert_eq!(
CoreFileSystemPermissions::from(permissions), CoreFileSystemPermissions::try_from(permissions)
.expect("API paths should convert to native paths"),
core_permissions core_permissions
); );
} }
@@ -667,12 +675,12 @@ fn permissions_request_approval_response_uses_granted_permission_profile_without
}), }),
file_system: Some(AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![ read: Some(vec![
AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) serde_json::from_value(json!(read_only_path))
.expect("path must be absolute"), .expect("API path string should deserialize")
]), ]),
write: Some(vec![ write: Some(vec![
AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) serde_json::from_value(json!(read_write_path))
.expect("path must be absolute"), .expect("API path string should deserialize")
]), ]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
@@ -681,7 +689,8 @@ fn permissions_request_approval_response_uses_granted_permission_profile_without
); );
assert_eq!( assert_eq!(
CoreAdditionalPermissionProfile::from(response.permissions), CoreAdditionalPermissionProfile::try_from(response.permissions)
.expect("API paths should convert to native paths"),
CoreAdditionalPermissionProfile { CoreAdditionalPermissionProfile {
network: Some(CoreNetworkPermissions { network: Some(CoreNetworkPermissions {
enabled: Some(true), enabled: Some(true),
@@ -786,6 +786,8 @@ pub(crate) async fn apply_bespoke_event_handling(
.await; .await;
let pending_response = PendingRequestPermissionsResponse { let pending_response = PendingRequestPermissionsResponse {
call_id: request.call_id, call_id: request.call_id,
conversation_id,
turn_id: request.turn_id,
requested_permissions, requested_permissions,
request_cwd, request_cwd,
pending_request_id, 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), codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
additional_details: None, additional_details: None,
}; };
handle_error(conversation_id, turn_error.clone(), &thread_state).await; handle_error_notification(
outgoing conversation_id,
.send_server_notification(ServerNotification::Error(ErrorNotification { &event_turn_id,
error: turn_error.clone(), turn_error,
will_retry: false, &outgoing,
thread_id: conversation_id.to_string(), &thread_state,
turn_id: event_turn_id.clone(), )
})) .await;
.await;
} }
EventMsg::StreamError(ev) => { EventMsg::StreamError(ev) => {
// We don't need to update the turn summary store for stream errors as they are intermediate error states for retries, // 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); 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( async fn on_request_user_input_response(
event_turn_id: String, event_turn_id: String,
pending_request_id: RequestId, pending_request_id: RequestId,
@@ -1796,6 +1815,8 @@ async fn on_request_permissions_response(
) { ) {
let PendingRequestPermissionsResponse { let PendingRequestPermissionsResponse {
call_id, call_id,
conversation_id,
turn_id,
requested_permissions, requested_permissions,
request_cwd, request_cwd,
pending_request_id, pending_request_id,
@@ -1806,12 +1827,34 @@ async fn on_request_permissions_response(
let response = receiver.await; let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id.clone()).await; resolve_server_request_on_thread_listener(&thread_state, pending_request_id.clone()).await;
drop(request_permissions_guard); drop(request_permissions_guard);
let Some(response) = request_permissions_response_from_client_result( let response = match request_permissions_response_from_client_result(
requested_permissions, requested_permissions,
response, response,
request_cwd.as_path(), 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()); outgoing.track_effective_permissions_approval_response(pending_request_id, response.clone());
@@ -1828,6 +1871,8 @@ async fn on_request_permissions_response(
struct PendingRequestPermissionsResponse { struct PendingRequestPermissionsResponse {
call_id: String, call_id: String,
conversation_id: ThreadId,
turn_id: String,
requested_permissions: CoreRequestPermissionProfile, requested_permissions: CoreRequestPermissionProfile,
request_cwd: AbsolutePathBuf, request_cwd: AbsolutePathBuf,
pending_request_id: RequestId, pending_request_id: RequestId,
@@ -1840,25 +1885,25 @@ fn request_permissions_response_from_client_result(
requested_permissions: CoreRequestPermissionProfile, requested_permissions: CoreRequestPermissionProfile,
response: std::result::Result<ClientRequestResult, oneshot::error::RecvError>, response: std::result::Result<ClientRequestResult, oneshot::error::RecvError>,
cwd: &std::path::Path, cwd: &std::path::Path,
) -> Option<CoreRequestPermissionsResponse> { ) -> std::io::Result<Option<CoreRequestPermissionsResponse>> {
let value = match response { let value = match response {
Ok(Ok(value)) => value, 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)) => { Ok(Err(err)) => {
error!("request failed with client error: {err:?}"); error!("request failed with client error: {err:?}");
return Some(CoreRequestPermissionsResponse { return Ok(Some(CoreRequestPermissionsResponse {
permissions: Default::default(), permissions: Default::default(),
scope: CorePermissionGrantScope::Turn, scope: CorePermissionGrantScope::Turn,
strict_auto_review: false, strict_auto_review: false,
}); }));
} }
Err(err) => { Err(err) => {
error!("request failed: {err:?}"); error!("request failed: {err:?}");
return Some(CoreRequestPermissionsResponse { return Ok(Some(CoreRequestPermissionsResponse {
permissions: Default::default(), permissions: Default::default(),
scope: CorePermissionGrantScope::Turn, scope: CorePermissionGrantScope::Turn,
strict_auto_review: false, 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"); error!("strict auto review is only supported for turn-scoped permission grants");
return Some(CoreRequestPermissionsResponse { return Ok(Some(CoreRequestPermissionsResponse {
permissions: Default::default(), permissions: Default::default(),
scope: CorePermissionGrantScope::Turn, scope: CorePermissionGrantScope::Turn,
strict_auto_review: false, 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() { let permissions = if granted_permissions.is_empty() {
CoreRequestPermissionProfile::default() CoreRequestPermissionProfile::default()
} else { } else {
intersect_permission_profiles(requested_permissions.into(), granted_permissions, cwd).into() intersect_permission_profiles(requested_permissions.into(), granted_permissions, cwd).into()
}; };
Some(CoreRequestPermissionsResponse { Ok(Some(CoreRequestPermissionsResponse {
permissions, permissions,
scope: response.scope.to_core(), scope: response.scope.to_core(),
strict_auto_review, strict_auto_review,
}) }))
} }
const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response.";
@@ -2899,7 +2944,8 @@ mod tests {
CoreRequestPermissionProfile::default(), CoreRequestPermissionProfile::default(),
Ok(Err(error)), Ok(Err(error)),
std::env::current_dir().expect("current dir").as_path(), std::env::current_dir().expect("current dir").as_path(),
); )
.expect("paths should localize");
assert_eq!(response, None); assert_eq!(response, None);
} }
@@ -2994,6 +3040,7 @@ mod tests {
}))), }))),
cwd.as_path(), cwd.as_path(),
) )
.expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
assert_eq!( assert_eq!(
@@ -3017,6 +3064,7 @@ mod tests {
}))), }))),
std::env::current_dir().expect("current dir").as_path(), std::env::current_dir().expect("current dir").as_path(),
) )
.expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
assert_eq!( assert_eq!(
@@ -3044,6 +3092,7 @@ mod tests {
}))), }))),
std::env::current_dir().expect("current dir").as_path(), std::env::current_dir().expect("current dir").as_path(),
) )
.expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
assert_eq!( assert_eq!(
@@ -3075,6 +3124,7 @@ mod tests {
}))), }))),
std::env::current_dir().expect("current dir").as_path(), std::env::current_dir().expect("current dir").as_path(),
) )
.expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
assert_eq!(response.scope, CorePermissionGrantScope::Turn); assert_eq!(response.scope, CorePermissionGrantScope::Turn);
@@ -3110,6 +3160,7 @@ mod tests {
}))), }))),
cwd.as_path(), cwd.as_path(),
) )
.expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
assert_eq!( assert_eq!(
@@ -3156,6 +3207,7 @@ mod tests {
}))), }))),
request_cwd.as_path(), request_cwd.as_path(),
) )
.expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
assert_eq!( assert_eq!(
@@ -3197,6 +3249,7 @@ mod tests {
}))), }))),
cwd.as_path(), cwd.as_path(),
) )
.expect("paths should localize")
.expect("response should be accepted"); .expect("response should be accepted");
assert_eq!( assert_eq!(
+2 -2
View File
@@ -260,7 +260,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou
network: None, network: None,
file_system: Some( file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions { codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]), read: Some(vec![absolute_path("/tmp/allowed").into()]),
write: None, write: None,
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
@@ -325,7 +325,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca
network: None, network: None,
file_system: Some( file_system: Some(
codex_app_server_protocol::AdditionalFileSystemPermissions { codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/allowed")]), read: Some(vec![absolute_path("/tmp/allowed").into()]),
write: None, write: None,
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
+1
View File
@@ -151,6 +151,7 @@ codex-cli = { workspace = true }
codex-mcp = { workspace = true } codex-mcp = { workspace = true }
core_test_support = { workspace = true } core_test_support = { workspace = true }
codex-utils-cargo-bin = { workspace = true } codex-utils-cargo-bin = { workspace = true }
codex-utils-path-uri = { workspace = true }
assert_matches = { workspace = true } assert_matches = { workspace = true }
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
insta = { workspace = true } insta = { workspace = true }
+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::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequest;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
impl App { impl App {
pub(super) async fn reject_app_server_request( pub(super) async fn reject_app_server_request(
@@ -103,6 +104,18 @@ impl PendingAppServerRequests {
None None
} }
ServerRequest::PermissionsRequestApproval { request_id, params } => { 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 self.permissions_approvals
.insert(params.item_id.clone(), request_id.clone()); .insert(params.item_id.clone(), request_id.clone());
None None
@@ -397,6 +410,7 @@ struct McpRequestKey {
mod tests { mod tests {
use super::PendingAppServerRequests; use super::PendingAppServerRequests;
use super::ResolvedAppServerRequest; use super::ResolvedAppServerRequest;
use super::UnsupportedAppServerRequest;
use crate::app_command::AppCommand as Op; use crate::app_command::AppCommand as Op;
use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions;
@@ -465,6 +479,54 @@ mod tests {
assert_eq!(resolution.result, json!({ "decision": "accept" })); 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] #[test]
fn resolves_permissions_and_user_input_through_app_server_request_id() { fn resolves_permissions_and_user_input_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default(); let mut pending = PendingAppServerRequests::default();
@@ -544,19 +606,19 @@ mod tests {
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![absolute_path(read_path)]), read: Some(vec![absolute_path(read_path).into()]),
write: Some(vec![absolute_path(write_path)]), write: Some(vec![absolute_path(write_path).into()]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: Some(vec![ entries: Some(vec![
codex_app_server_protocol::FileSystemSandboxEntry { codex_app_server_protocol::FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Path { 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, access: codex_app_server_protocol::FileSystemAccessMode::Read,
}, },
codex_app_server_protocol::FileSystemSandboxEntry { codex_app_server_protocol::FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Path { 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, 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 { if self.discard_side_thread(app_server, side_thread_id).await {
self.surface_pending_inactive_thread_interactive_requests() self.surface_pending_inactive_thread_interactive_requests()
.await; .await?;
} else if active_thread_id_before_switch == Some(side_thread_id) { } else if active_thread_id_before_switch == Some(side_thread_id) {
self.keep_side_thread_visible_after_cleanup_failure( self.keep_side_thread_visible_after_cleanup_failure(
tui, 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.side_threads.remove(&side_thread_id);
app.active_thread_id = Some(main_thread_id); app.active_thread_id = Some(main_thread_id);
app.surface_pending_inactive_thread_interactive_requests() app.surface_pending_inactive_thread_interactive_requests()
.await; .await?;
assert_eq!(app.chat_widget.has_active_view(), true); assert_eq!(app.chat_widget.has_active_view(), true);
@@ -2462,8 +2462,8 @@ async fn inactive_thread_exec_approval_preserves_context() {
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]), read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write")]), write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
}), }),
@@ -2481,6 +2481,7 @@ async fn inactive_thread_exec_approval_preserves_context() {
})) = app })) = app
.interactive_request_for_thread_request(thread_id, &request) .interactive_request_for_thread_request(thread_id, &request)
.await .await
.expect("valid localized paths")
else { else {
panic!("expected exec approval request"); panic!("expected exec approval request");
}; };
@@ -2499,8 +2500,8 @@ async fn inactive_thread_exec_approval_preserves_context() {
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]), read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write")]), write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: 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 let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { command, .. })) = app
.interactive_request_for_thread_request(thread_id, &request) .interactive_request_for_thread_request(thread_id, &request)
.await .await
.expect("valid localized paths")
else { else {
panic!("expected exec approval request"); panic!("expected exec approval request");
}; };
@@ -2595,6 +2597,7 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
let request = app let request = app
.interactive_request_for_thread_request(thread_id, &request) .interactive_request_for_thread_request(thread_id, &request)
.await .await
.expect("valid localized paths")
.expect("expected file change approval request"); .expect("expected file change approval request");
let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch { let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch {
@@ -2646,8 +2649,8 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions(
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]), read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write")]), write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
}), }),
@@ -2662,6 +2665,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions(
})) = app })) = app
.interactive_request_for_thread_request(thread_id, &request) .interactive_request_for_thread_request(thread_id, &request)
.await .await
.expect("valid localized paths")
else { else {
panic!("expected permissions approval request"); 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 let Some(ThreadInteractiveRequest::AppLink(params)) = app
.interactive_request_for_thread_request(thread_id, &request) .interactive_request_for_thread_request(thread_id, &request)
.await .await
.expect("valid localized paths")
else { else {
panic!("expected app link request"); panic!("expected app link request");
}; };
@@ -2742,6 +2747,7 @@ async fn inactive_thread_invalid_url_elicitation_is_declined() {
assert!( assert!(
app.interactive_request_for_thread_request(thread_id, &request) app.interactive_request_for_thread_request(thread_id, &request)
.await .await
.expect("valid localized paths")
.is_none() .is_none()
); );
assert_matches!( assert_matches!(
+30 -17
View File
@@ -210,9 +210,9 @@ impl App {
&self, &self,
thread_id: ThreadId, thread_id: ThreadId,
request: &ServerRequest, request: &ServerRequest,
) -> Option<ThreadInteractiveRequest> { ) -> std::io::Result<Option<ThreadInteractiveRequest>> {
let thread_label = Some(self.thread_label(thread_id)); let thread_label = Some(self.thread_label(thread_id));
match request { Ok(match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => { ServerRequest::CommandExecutionRequestApproval { params, .. } => {
let network_approval_context = params.network_approval_context.clone(); let network_approval_context = params.network_approval_context.clone();
let additional_permissions = params.additional_permissions.clone(); let additional_permissions = params.additional_permissions.clone();
@@ -305,18 +305,28 @@ impl App {
} }
} }
} }
ServerRequest::PermissionsRequestApproval { params, .. } => Some( ServerRequest::PermissionsRequestApproval { params, .. } => {
ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions { // TODO(anp): Remove this native-path localization error path once core permission
thread_id, // paths remain PathUri after crossing the app-server boundary.
thread_label, let permissions = params.permissions.clone().try_into().map_err(|err| {
call_id: params.item_id.clone(), std::io::Error::new(
environment_id: params.environment_id.clone(), std::io::ErrorKind::InvalidInput,
reason: params.reason.clone(), format!("failed to localize requested filesystem paths: {err}"),
permissions: params.permissions.clone().into(), )
}), })?;
), 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, _ => None,
} })
} }
pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) { pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) {
@@ -376,20 +386,23 @@ impl App {
requests 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() { if self.active_side_parent_thread_id().is_some() {
return; return Ok(());
} }
let requests = self.pending_inactive_thread_requests().await; let requests = self.pending_inactive_thread_requests().await;
for (thread_id, request) in requests { for (thread_id, request) in requests {
if let Some(request) = self if let Some(request) = self
.interactive_request_for_thread_request(thread_id, &request) .interactive_request_for_thread_request(thread_id, &request)
.await .await?
{ {
self.push_thread_interactive_request(request); self.push_thread_interactive_request(request);
} }
} }
Ok(())
} }
pub(super) async fn submit_active_thread_op( pub(super) async fn submit_active_thread_op(
@@ -991,7 +1004,7 @@ impl App {
) -> Result<()> { ) -> Result<()> {
let inactive_interactive_request = if self.active_thread_id != Some(thread_id) { let inactive_interactive_request = if self.active_thread_id != Some(thread_id) {
self.interactive_request_for_thread_request(thread_id, &request) self.interactive_request_for_thread_request(thread_id, &request)
.await .await?
} else { } else {
None None
}; };
@@ -54,8 +54,16 @@ mod tests {
use super::file_update_changes_to_display; use super::file_update_changes_to_display;
use super::granted_permission_profile_from_request; use super::granted_permission_profile_from_request;
use crate::diff_model::FileChange; use crate::diff_model::FileChange;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::GrantedPermissionProfile;
use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@@ -85,40 +93,42 @@ mod tests {
#[test] #[test]
fn converts_request_permissions_into_granted_permissions() { fn converts_request_permissions_into_granted_permissions() {
let request = RequestPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/read-only").into()]),
write: Some(vec![absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None,
entries: None,
}),
};
let request = CoreRequestPermissionProfile::try_from(request)
.expect("API paths should convert to native paths");
assert_eq!( assert_eq!(
granted_permission_profile_from_request(CoreRequestPermissionProfile::from( granted_permission_profile_from_request(request),
codex_app_server_protocol::RequestPermissionProfile { GrantedPermissionProfile {
network: Some(codex_app_server_protocol::AdditionalNetworkPermissions { network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/read-only")]),
write: Some(vec![absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
}
)),
codex_app_server_protocol::GrantedPermissionProfile {
network: Some(codex_app_server_protocol::AdditionalNetworkPermissions {
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![absolute_path("/tmp/read-only")]), read: Some(vec![absolute_path("/tmp/read-only").into()]),
write: Some(vec![absolute_path("/tmp/write")]), write: Some(vec![absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: Some(vec![ entries: Some(vec![
codex_app_server_protocol::FileSystemSandboxEntry { FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Path { path: FileSystemPath::Path {
path: absolute_path("/tmp/read-only"), path: absolute_path("/tmp/read-only").into(),
}, },
access: codex_app_server_protocol::FileSystemAccessMode::Read, access: FileSystemAccessMode::Read,
}, },
codex_app_server_protocol::FileSystemSandboxEntry { FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Path { path: FileSystemPath::Path {
path: absolute_path("/tmp/write"), path: absolute_path("/tmp/write").into(),
}, },
access: codex_app_server_protocol::FileSystemAccessMode::Write, access: FileSystemAccessMode::Write,
}, },
]), ]),
}), }),
@@ -128,35 +138,37 @@ mod tests {
#[test] #[test]
fn converts_request_permissions_into_canonical_granted_permissions() { fn converts_request_permissions_into_canonical_granted_permissions() {
let request = RequestPermissionProfile {
network: None,
file_system: Some(AdditionalFileSystemPermissions {
read: None,
write: None,
glob_scan_max_depth: None,
entries: Some(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
}]),
}),
};
let request = CoreRequestPermissionProfile::try_from(request)
.expect("API paths should convert to native paths");
assert_eq!( assert_eq!(
granted_permission_profile_from_request(CoreRequestPermissionProfile::from( granted_permission_profile_from_request(request),
codex_app_server_protocol::RequestPermissionProfile { GrantedPermissionProfile {
network: None,
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
read: None,
write: None,
glob_scan_max_depth: None,
entries: Some(vec![codex_app_server_protocol::FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Special {
value: codex_app_server_protocol::FileSystemSpecialPath::Root,
},
access: codex_app_server_protocol::FileSystemAccessMode::Write,
}]),
}),
}
)),
codex_app_server_protocol::GrantedPermissionProfile {
network: None, network: None,
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions { file_system: Some(AdditionalFileSystemPermissions {
read: None, read: None,
write: None, write: None,
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: Some(vec![codex_app_server_protocol::FileSystemSandboxEntry { entries: Some(vec![FileSystemSandboxEntry {
path: codex_app_server_protocol::FileSystemPath::Special { path: FileSystemPath::Special {
value: codex_app_server_protocol::FileSystemSpecialPath::Root, value: FileSystemSpecialPath::Root,
}, },
access: codex_app_server_protocol::FileSystemAccessMode::Write, access: FileSystemAccessMode::Write,
},]), }]),
}), }),
} }
); );
@@ -1000,7 +1000,7 @@ fn format_file_system_entry_paths<'a>(
) -> String { ) -> String {
entries entries
.map(|entry| match &entry.path { .map(|entry| match &entry.path {
FileSystemPath::Path { path } => format!("`{}`", path.display()), FileSystemPath::Path { path } => format!("`{path}`"),
FileSystemPath::GlobPattern { pattern } => format!("glob `{pattern}`"), FileSystemPath::GlobPattern { pattern } => format!("glob `{pattern}`"),
FileSystemPath::Special { value } => format!("`{}`", special_path_label(value)), FileSystemPath::Special { value } => format!("`{}`", special_path_label(value)),
}) })
+4 -4
View File
@@ -865,16 +865,16 @@ fn patch_approval_request_from_params(
fn request_permissions_from_params( fn request_permissions_from_params(
params: codex_app_server_protocol::PermissionsRequestApprovalParams, params: codex_app_server_protocol::PermissionsRequestApprovalParams,
) -> RequestPermissionsEvent { ) -> std::io::Result<RequestPermissionsEvent> {
RequestPermissionsEvent { Ok(RequestPermissionsEvent {
turn_id: params.turn_id, turn_id: params.turn_id,
call_id: params.item_id, call_id: params.item_id,
environment_id: params.environment_id, environment_id: params.environment_id,
started_at_ms: params.started_at_ms, started_at_ms: params.started_at_ms,
reason: params.reason, reason: params.reason,
permissions: params.permissions.into(), permissions: params.permissions.try_into()?,
cwd: Some(params.cwd), cwd: Some(params.cwd),
} })
} }
fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsageInfo { fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsageInfo {
@@ -30,7 +30,16 @@ impl ChatWidget {
self.on_elicitation_request(request_id, params); self.on_elicitation_request(request_id, params);
} }
ServerRequest::PermissionsRequestApproval { params, .. } => { ServerRequest::PermissionsRequestApproval { params, .. } => {
self.on_request_permissions(request_permissions_from_params(params)); // TODO(anp): Remove this native-path localization error path once core permission
// paths remain PathUri after crossing the app-server boundary.
match request_permissions_from_params(params) {
Ok(event) => self.on_request_permissions(event),
Err(err) => {
self.add_error_message(format!(
"failed to localize requested filesystem paths: {err}"
));
}
}
} }
ServerRequest::ToolRequestUserInput { params, .. } => { ServerRequest::ToolRequestUserInput { params, .. } => {
self.on_request_user_input(params); self.on_request_user_input(params);
@@ -62,6 +71,17 @@ impl ChatWidget {
completion: Option<(i64, codex_app_server_protocol::AutoReviewDecisionSource)>, completion: Option<(i64, codex_app_server_protocol::AutoReviewDecisionSource)>,
action: GuardianApprovalReviewAction, action: GuardianApprovalReviewAction,
) { ) {
// TODO(anp): Remove this native-path localization error path once core permission paths
// remain PathUri after crossing the app-server boundary.
let action = match action.try_into() {
Ok(action) => action,
Err(err) => {
self.add_error_message(format!(
"failed to localize guardian filesystem paths: {err}"
));
return;
}
};
let (completed_at_ms, decision_source) = match completion { let (completed_at_ms, decision_source) = match completion {
Some((completed_at_ms, decision_source)) => { Some((completed_at_ms, decision_source)) => {
(Some(completed_at_ms), Some(decision_source)) (Some(completed_at_ms), Some(decision_source))
@@ -128,7 +148,7 @@ impl ChatWidget {
GuardianAssessmentDecisionSource::Agent GuardianAssessmentDecisionSource::Agent
} }
}), }),
action: action.into(), action,
}); });
} }
+1
View File
@@ -164,6 +164,7 @@ pub(super) use codex_terminal_detection::TerminalInfo;
pub(super) use codex_terminal_detection::TerminalName; pub(super) use codex_terminal_detection::TerminalName;
pub(super) use codex_utils_absolute_path::AbsolutePathBuf; pub(super) use codex_utils_absolute_path::AbsolutePathBuf;
pub(super) use codex_utils_approval_presets::builtin_approval_presets; pub(super) use codex_utils_approval_presets::builtin_approval_presets;
pub(super) use codex_utils_path_uri::ApiPathString;
pub(super) use crossterm::event::KeyCode; pub(super) use crossterm::event::KeyCode;
pub(super) use crossterm::event::KeyEvent; pub(super) use crossterm::event::KeyEvent;
pub(super) use crossterm::event::KeyModifiers; pub(super) use crossterm::event::KeyModifiers;
@@ -89,6 +89,8 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
.expect("absolute read path"); .expect("absolute read path");
let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write"))) let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write")))
.expect("absolute write path"); .expect("absolute write path");
let read_api_path = ApiPathString::from_abs_path(&read_path);
let write_api_path = ApiPathString::from_abs_path(&write_path);
let request = exec_approval_request_from_params( let request = exec_approval_request_from_params(
AppServerCommandExecutionRequestApprovalParams { AppServerCommandExecutionRequestApprovalParams {
thread_id: "thread-1".to_string(), thread_id: "thread-1".to_string(),
@@ -109,8 +111,8 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(AppServerAdditionalFileSystemPermissions { file_system: Some(AppServerAdditionalFileSystemPermissions {
read: Some(vec![read_path.clone()]), read: Some(vec![read_api_path.clone()]),
write: Some(vec![write_path.clone()]), write: Some(vec![write_api_path.clone()]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
}), }),
@@ -136,8 +138,8 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(AppServerAdditionalFileSystemPermissions { file_system: Some(AppServerAdditionalFileSystemPermissions {
read: Some(vec![read_path]), read: Some(vec![read_api_path]),
write: Some(vec![write_path]), write: Some(vec![write_api_path]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
}), }),
@@ -274,6 +276,8 @@ fn app_server_request_permissions_preserves_file_system_permissions() {
.expect("absolute read path"); .expect("absolute read path");
let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write"))) let write_path = AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp/write")))
.expect("absolute write path"); .expect("absolute write path");
let read_api_path = ApiPathString::from_abs_path(&read_path);
let write_api_path = ApiPathString::from_abs_path(&write_path);
let cwd = let cwd =
AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp"))).expect("absolute cwd"); AbsolutePathBuf::try_from(PathBuf::from(test_path_display("/tmp"))).expect("absolute cwd");
@@ -290,13 +294,14 @@ fn app_server_request_permissions_preserves_file_system_permissions() {
enabled: Some(true), enabled: Some(true),
}), }),
file_system: Some(AppServerAdditionalFileSystemPermissions { file_system: Some(AppServerAdditionalFileSystemPermissions {
read: Some(vec![read_path.clone()]), read: Some(vec![read_api_path]),
write: Some(vec![write_path.clone()]), write: Some(vec![write_api_path]),
glob_scan_max_depth: None, glob_scan_max_depth: None,
entries: None, entries: None,
}), }),
}, },
}); })
.expect("API paths should convert to native paths");
assert_eq!( assert_eq!(
request.permissions, request.permissions,
@@ -16,9 +16,9 @@ use ts_rs::TS;
/// ///
/// When converting from [`PathUri`], "native" refers to the supplied /// When converting from [`PathUri`], "native" refers to the supplied
/// [`PathConvention`], which may be foreign to the operating system running /// [`PathConvention`], which may be foreign to the operating system running
/// this process. The inner string is private so path-producing code must use /// this process. The inner string is private so path-producing code must convert
/// [`Self::from_abs_path`] or [`Self::from_path_uri`] instead of bypassing the /// from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing
/// intended conversion boundary. Non-UTF-8 paths are converted to UTF-8 /// the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8
/// lossily because this API value is serialized as a JSON string. /// lossily because this API value is serialized as a JSON string.
/// ///
/// Deserialization accepts any UTF-8 string without interpreting or validating /// Deserialization accepts any UTF-8 string without interpreting or validating
@@ -102,6 +102,12 @@ impl ApiPathString {
} }
} }
impl From<AbsolutePathBuf> for ApiPathString {
fn from(path: AbsolutePathBuf) -> Self {
Self::from_abs_path(&path)
}
}
fn parse_posix_path(path: &str) -> Option<PathUri> { fn parse_posix_path(path: &str) -> Option<PathUri> {
let path = path.strip_prefix('/')?; let path = path.strip_prefix('/')?;
if path.contains('\0') { if path.contains('\0') {
@@ -444,7 +444,7 @@ fn renders_an_absolute_path_using_the_host_convention() {
.expect("native path should be absolute"); .expect("native path should be absolute");
assert_eq!( assert_eq!(
ApiPathString::from_abs_path(&path), ApiPathString::from(path),
ApiPathString(native_path.to_string()) ApiPathString(native_path.to_string())
); );
} }