app-server: add v2 filesystem APIs (#14245)

Add a protocol-level filesystem surface to the v2 app-server so Codex
clients can read and write files, inspect directories, and subscribe to
path changes without relying on host-specific helpers.

High-level changes:
- define the new v2 fs/readFile, fs/writeFile, fs/createDirectory,
fs/getMetadata, fs/readDirectory, fs/remove, fs/copy RPCs
- implement the app-server handlers, including absolute-path validation,
base64 file payloads, recursive copy/remove semantics
- document the API, regenerate protocol schemas/types, and add
end-to-end tests for filesystem operations, copy edge cases

Testing plan:
- validate protocol serialization and generated schema output for the
new fs request, response, and notification types
- run app-server integration coverage for file and directory CRUD paths,
metadata/readDirectory responses, copy failure modes, and absolute-path
validation
This commit is contained in:
Ruslan Nigmatullin
2026-03-13 14:42:20 -07:00
committed by GitHub
parent 36dfb84427
commit f8f82bfc2b
46 changed files with 3391 additions and 8 deletions
+1
View File
@@ -68,6 +68,7 @@ tokio-tungstenite = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] }
uuid = { workspace = true, features = ["serde", "v7"] }
walkdir = { workspace = true }
[dev-dependencies]
app_test_support = { workspace = true }
+47
View File
@@ -153,6 +153,13 @@ Example with notification opt-out:
- `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`.
- `command/exec/terminate` — terminate a running `command/exec` session by `processId`; returns `{}`.
- `command/exec/outputDelta` — notification emitted for base64-encoded stdout/stderr chunks from a streaming `command/exec` session.
- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`.
- `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`.
- `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`.
- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `createdAtMs`, and `modifiedAtMs`.
- `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path.
- `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`.
- `fs/copy` — copy between absolute paths; directory copies require `recursive: true`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
@@ -711,6 +718,46 @@ Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes:
- `command/exec.params.env` overrides the server-computed environment per key; set a key to `null` to unset an inherited variable.
- `command/exec/resize` is only supported for PTY-backed `command/exec` sessions.
### Example: Filesystem utilities
These methods operate on absolute paths on the host filesystem and cover reading, writing, directory traversal, copying, removal, and change notifications.
All filesystem paths in this section must be absolute.
```json
{ "method": "fs/createDirectory", "id": 40, "params": {
"path": "/tmp/example/nested",
"recursive": true
} }
{ "id": 40, "result": {} }
{ "method": "fs/writeFile", "id": 41, "params": {
"path": "/tmp/example/nested/note.txt",
"dataBase64": "aGVsbG8="
} }
{ "id": 41, "result": {} }
{ "method": "fs/getMetadata", "id": 42, "params": {
"path": "/tmp/example/nested/note.txt"
} }
{ "id": 42, "result": {
"isDirectory": false,
"isFile": true,
"createdAtMs": 1730910000000,
"modifiedAtMs": 1730910000000
} }
{ "method": "fs/readFile", "id": 43, "params": {
"path": "/tmp/example/nested/note.txt"
} }
{ "id": 43, "result": {
"dataBase64": "aGVsbG8="
} }
```
- `fs/getMetadata` returns whether the path currently resolves to a directory or regular file, plus `createdAtMs` and `modifiedAtMs` in Unix milliseconds. If a timestamp is unavailable on the current platform, that field is `0`.
- `fs/createDirectory` defaults `recursive` to `true` when omitted.
- `fs/remove` defaults both `recursive` and `force` to `true` when omitted.
- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`.
- `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped.
## Events
Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications.
@@ -899,6 +899,15 @@ impl CodexMessageProcessor {
| ClientRequest::ConfigBatchWrite { .. } => {
warn!("Config request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::FsReadFile { .. }
| ClientRequest::FsWriteFile { .. }
| ClientRequest::FsCreateDirectory { .. }
| ClientRequest::FsGetMetadata { .. }
| ClientRequest::FsReadDirectory { .. }
| ClientRequest::FsRemove { .. }
| ClientRequest::FsCopy { .. } => {
warn!("Filesystem request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::ConfigRequirementsRead { .. } => {
warn!("ConfigRequirementsRead request reached CodexMessageProcessor unexpectedly");
}
+365
View File
@@ -0,0 +1,365 @@
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCopyResponse;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateDirectoryResponse;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryEntry;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadDirectoryResponse;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsRemoveResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::FsWriteFileResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
#[derive(Clone, Default)]
pub(crate) struct FsApi;
impl FsApi {
pub(crate) async fn read_file(
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = tokio::fs::read(params.path).await.map_err(map_io_error)?;
Ok(FsReadFileResponse {
data_base64: STANDARD.encode(bytes),
})
}
pub(crate) async fn write_file(
&self,
params: FsWriteFileParams,
) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
invalid_request(format!(
"fs/writeFile requires valid base64 dataBase64: {err}"
))
})?;
tokio::fs::write(params.path, bytes)
.await
.map_err(map_io_error)?;
Ok(FsWriteFileResponse {})
}
pub(crate) async fn create_directory(
&self,
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
if params.recursive.unwrap_or(true) {
tokio::fs::create_dir_all(params.path)
.await
.map_err(map_io_error)?;
} else {
tokio::fs::create_dir(params.path)
.await
.map_err(map_io_error)?;
}
Ok(FsCreateDirectoryResponse {})
}
pub(crate) async fn get_metadata(
&self,
params: FsGetMetadataParams,
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let metadata = tokio::fs::metadata(params.path)
.await
.map_err(map_io_error)?;
Ok(FsGetMetadataResponse {
is_directory: metadata.is_dir(),
is_file: metadata.is_file(),
created_at_ms: metadata.created().ok().map_or(0, system_time_to_unix_ms),
modified_at_ms: metadata.modified().ok().map_or(0, system_time_to_unix_ms),
})
}
pub(crate) async fn read_directory(
&self,
params: FsReadDirectoryParams,
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(params.path)
.await
.map_err(map_io_error)?;
while let Some(entry) = read_dir.next_entry().await.map_err(map_io_error)? {
let metadata = tokio::fs::metadata(entry.path())
.await
.map_err(map_io_error)?;
entries.push(FsReadDirectoryEntry {
file_name: entry.file_name().to_string_lossy().into_owned(),
is_directory: metadata.is_dir(),
is_file: metadata.is_file(),
});
}
Ok(FsReadDirectoryResponse { entries })
}
pub(crate) async fn remove(
&self,
params: FsRemoveParams,
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
let path = params.path.as_path();
let recursive = params.recursive.unwrap_or(true);
let force = params.force.unwrap_or(true);
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => {
let file_type = metadata.file_type();
if file_type.is_dir() {
if recursive {
tokio::fs::remove_dir_all(path)
.await
.map_err(map_io_error)?;
} else {
tokio::fs::remove_dir(path).await.map_err(map_io_error)?;
}
} else {
tokio::fs::remove_file(path).await.map_err(map_io_error)?;
}
Ok(FsRemoveResponse {})
}
Err(err) if err.kind() == io::ErrorKind::NotFound && force => Ok(FsRemoveResponse {}),
Err(err) => Err(map_io_error(err)),
}
}
pub(crate) async fn copy(
&self,
params: FsCopyParams,
) -> Result<FsCopyResponse, JSONRPCErrorError> {
let FsCopyParams {
source_path,
destination_path,
recursive,
} = params;
tokio::task::spawn_blocking(move || -> Result<(), JSONRPCErrorError> {
let metadata =
std::fs::symlink_metadata(source_path.as_path()).map_err(map_io_error)?;
let file_type = metadata.file_type();
if file_type.is_dir() {
if !recursive {
return Err(invalid_request(
"fs/copy requires recursive: true when sourcePath is a directory",
));
}
if destination_is_same_or_descendant_of_source(
source_path.as_path(),
destination_path.as_path(),
)
.map_err(map_io_error)?
{
return Err(invalid_request(
"fs/copy cannot copy a directory to itself or one of its descendants",
));
}
copy_dir_recursive(source_path.as_path(), destination_path.as_path())
.map_err(map_io_error)?;
return Ok(());
}
if file_type.is_symlink() {
copy_symlink(source_path.as_path(), destination_path.as_path())
.map_err(map_io_error)?;
return Ok(());
}
if file_type.is_file() {
std::fs::copy(source_path.as_path(), destination_path.as_path())
.map_err(map_io_error)?;
return Ok(());
}
Err(invalid_request(
"fs/copy only supports regular files, directories, and symlinks",
))
})
.await
.map_err(map_join_error)??;
Ok(FsCopyResponse {})
}
}
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
for entry in WalkDir::new(source) {
let entry = entry.map_err(|err| {
if let Some(io_err) = err.io_error() {
io::Error::new(io_err.kind(), io_err.to_string())
} else {
io::Error::other(err.to_string())
}
})?;
let relative_path = entry.path().strip_prefix(source).map_err(|err| {
io::Error::other(format!(
"failed to compute relative path for {} under {}: {err}",
entry.path().display(),
source.display()
))
})?;
let target_path = target.join(relative_path);
let file_type = entry.file_type();
if file_type.is_dir() {
std::fs::create_dir_all(&target_path)?;
continue;
}
if file_type.is_file() {
std::fs::copy(entry.path(), &target_path)?;
continue;
}
if file_type.is_symlink() {
copy_symlink(entry.path(), &target_path)?;
continue;
}
// For now ignore special files such as FIFOs, sockets, and device nodes during recursive copies.
}
Ok(())
}
fn destination_is_same_or_descendant_of_source(
source: &Path,
destination: &Path,
) -> io::Result<bool> {
let source = std::fs::canonicalize(source)?;
let destination = resolve_copy_destination_path(destination)?;
Ok(destination.starts_with(&source))
}
fn resolve_copy_destination_path(path: &Path) -> io::Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Normal(part) => normalized.push(part),
}
}
let mut unresolved_suffix = Vec::new();
let mut existing_path = normalized.as_path();
while !existing_path.exists() {
let Some(file_name) = existing_path.file_name() else {
break;
};
unresolved_suffix.push(file_name.to_os_string());
let Some(parent) = existing_path.parent() else {
break;
};
existing_path = parent;
}
let mut resolved = std::fs::canonicalize(existing_path)?;
for file_name in unresolved_suffix.iter().rev() {
resolved.push(file_name);
}
Ok(resolved)
}
fn copy_symlink(source: &Path, target: &Path) -> io::Result<()> {
let link_target = std::fs::read_link(source)?;
#[cfg(unix)]
{
std::os::unix::fs::symlink(&link_target, target)
}
#[cfg(windows)]
{
if symlink_points_to_directory(source)? {
std::os::windows::fs::symlink_dir(&link_target, target)
} else {
std::os::windows::fs::symlink_file(&link_target, target)
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = link_target;
let _ = target;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"copying symlinks is unsupported on this platform",
))
}
}
#[cfg(windows)]
fn symlink_points_to_directory(source: &Path) -> io::Result<bool> {
use std::os::windows::fs::FileTypeExt;
Ok(std::fs::symlink_metadata(source)?
.file_type()
.is_symlink_dir())
}
fn system_time_to_unix_ms(time: SystemTime) -> i64 {
time.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_millis()).ok())
.unwrap_or(0)
}
pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: None,
}
}
fn map_join_error(err: tokio::task::JoinError) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("filesystem task failed: {err}"),
data: None,
}
}
pub(crate) fn map_io_error(err: io::Error) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
}
}
#[cfg(all(test, windows))]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn symlink_points_to_directory_handles_dangling_directory_symlinks() -> io::Result<()> {
use std::os::windows::fs::symlink_dir;
let temp_dir = tempfile::TempDir::new()?;
let source_dir = temp_dir.path().join("source");
let link_path = temp_dir.path().join("source-link");
std::fs::create_dir(&source_dir)?;
if symlink_dir(&source_dir, &link_path).is_err() {
return Ok(());
}
std::fs::remove_dir(&source_dir)?;
assert_eq!(symlink_points_to_directory(&link_path)?, true);
Ok(())
}
}
+1
View File
@@ -65,6 +65,7 @@ mod dynamic_tools;
mod error_code;
mod external_agent_config_api;
mod filters;
mod fs_api;
mod fuzzy_file_search;
pub mod in_process;
mod message_processor;
@@ -10,6 +10,7 @@ use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
@@ -29,6 +30,13 @@ use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
@@ -139,6 +147,7 @@ pub(crate) struct MessageProcessor {
codex_message_processor: CodexMessageProcessor,
config_api: ConfigApi,
external_agent_config_api: ExternalAgentConfigApi,
fs_api: FsApi,
auth_manager: Arc<AuthManager>,
config: Arc<Config>,
config_warnings: Arc<Vec<ConfigWarningNotification>>,
@@ -244,12 +253,14 @@ impl MessageProcessor {
analytics_events_client,
);
let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone());
let fs_api = FsApi;
Self {
outgoing,
codex_message_processor,
config_api,
external_agent_config_api,
fs_api,
auth_manager,
config,
config_warnings: Arc::new(config_warnings),
@@ -666,6 +677,76 @@ impl MessageProcessor {
})
.await;
}
ClientRequest::FsReadFile { request_id, params } => {
self.handle_fs_read_file(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::FsWriteFile { request_id, params } => {
self.handle_fs_write_file(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::FsCreateDirectory { request_id, params } => {
self.handle_fs_create_directory(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::FsGetMetadata { request_id, params } => {
self.handle_fs_get_metadata(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::FsReadDirectory { request_id, params } => {
self.handle_fs_read_directory(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::FsRemove { request_id, params } => {
self.handle_fs_remove(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::FsCopy { request_id, params } => {
self.handle_fs_copy(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
other => {
// Box the delegated future so this wrapper's async state machine does not
// inline the full `CodexMessageProcessor::process_request` future, which
@@ -752,6 +833,71 @@ impl MessageProcessor {
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_read_file(&self, request_id: ConnectionRequestId, params: FsReadFileParams) {
match self.fs_api.read_file(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_write_file(
&self,
request_id: ConnectionRequestId,
params: FsWriteFileParams,
) {
match self.fs_api.write_file(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_create_directory(
&self,
request_id: ConnectionRequestId,
params: FsCreateDirectoryParams,
) {
match self.fs_api.create_directory(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_get_metadata(
&self,
request_id: ConnectionRequestId,
params: FsGetMetadataParams,
) {
match self.fs_api.get_metadata(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_read_directory(
&self,
request_id: ConnectionRequestId,
params: FsReadDirectoryParams,
) {
match self.fs_api.read_directory(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_remove(&self, request_id: ConnectionRequestId, params: FsRemoveParams) {
match self.fs_api.remove(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_copy(&self, request_id: ConnectionRequestId, params: FsCopyParams) {
match self.fs_api.copy(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
}
#[cfg(test)]
@@ -25,6 +25,13 @@ use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetConversationSummaryParams;
@@ -709,6 +716,56 @@ impl McpProcess {
self.send_request("config/batchWrite", params).await
}
pub async fn send_fs_read_file_request(
&mut self,
params: FsReadFileParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/readFile", params).await
}
pub async fn send_fs_write_file_request(
&mut self,
params: FsWriteFileParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/writeFile", params).await
}
pub async fn send_fs_create_directory_request(
&mut self,
params: FsCreateDirectoryParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/createDirectory", params).await
}
pub async fn send_fs_get_metadata_request(
&mut self,
params: FsGetMetadataParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/getMetadata", params).await
}
pub async fn send_fs_read_directory_request(
&mut self,
params: FsReadDirectoryParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/readDirectory", params).await
}
pub async fn send_fs_remove_request(&mut self, params: FsRemoveParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/remove", params).await
}
pub async fn send_fs_copy_request(&mut self, params: FsCopyParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/copy", params).await
}
/// Send an `account/logout` JSON-RPC request.
pub async fn send_logout_account_request(&mut self) -> anyhow::Result<i64> {
self.send_request("account/logout", None).await
+613
View File
@@ -0,0 +1,613 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryEntry;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::RequestId;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(unix)]
use std::process::Command;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
async fn initialized_mcp(codex_home: &TempDir) -> Result<McpProcess> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
Ok(mcp)
}
async fn expect_error_message(
mcp: &mut McpProcess,
request_id: i64,
expected_message: &str,
) -> Result<()> {
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.message, expected_message);
Ok(())
}
#[allow(clippy::expect_used)]
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
assert!(
path.is_absolute(),
"path must be absolute: {}",
path.display()
);
AbsolutePathBuf::try_from(path).expect("path should be absolute")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
let codex_home = TempDir::new()?;
let file_path = codex_home.path().join("note.txt");
std::fs::write(&file_path, "hello")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_get_metadata_request(codex_app_server_protocol::FsGetMetadataParams {
path: absolute_path(file_path.clone()),
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let result = response
.result
.as_object()
.context("fs/getMetadata result should be an object")?;
let mut keys = result.keys().cloned().collect::<Vec<_>>();
keys.sort();
assert_eq!(
keys,
vec![
"createdAtMs".to_string(),
"isDirectory".to_string(),
"isFile".to_string(),
"modifiedAtMs".to_string(),
]
);
let stat: FsGetMetadataResponse = to_response(response)?;
assert_eq!(
stat,
FsGetMetadataResponse {
is_directory: false,
is_file: true,
created_at_ms: stat.created_at_ms,
modified_at_ms: stat.modified_at_ms,
}
);
assert!(
stat.modified_at_ms > 0,
"modifiedAtMs should be populated for existing files"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
let codex_home = TempDir::new()?;
let source_dir = codex_home.path().join("source");
let nested_dir = source_dir.join("nested");
let source_file = source_dir.join("root.txt");
let copied_dir = codex_home.path().join("copied");
let copy_file_path = codex_home.path().join("copy.txt");
let nested_file = nested_dir.join("note.txt");
let mut mcp = initialized_mcp(&codex_home).await?;
let create_directory_request_id = mcp
.send_fs_create_directory_request(codex_app_server_protocol::FsCreateDirectoryParams {
path: absolute_path(nested_dir.clone()),
recursive: None,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(create_directory_request_id)),
)
.await??;
let write_request_id = mcp
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(nested_file.clone()),
data_base64: STANDARD.encode("hello from app-server"),
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_request_id)),
)
.await??;
let root_write_request_id = mcp
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(source_file.clone()),
data_base64: STANDARD.encode("hello from source root"),
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(root_write_request_id)),
)
.await??;
let read_request_id = mcp
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
path: absolute_path(nested_file.clone()),
})
.await?;
let read_response: FsReadFileResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
)
.await??,
)?;
assert_eq!(
read_response,
FsReadFileResponse {
data_base64: STANDARD.encode("hello from app-server"),
}
);
let copy_file_request_id = mcp
.send_fs_copy_request(FsCopyParams {
source_path: absolute_path(nested_file.clone()),
destination_path: absolute_path(copy_file_path.clone()),
recursive: false,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(copy_file_request_id)),
)
.await??;
assert_eq!(
std::fs::read_to_string(&copy_file_path)?,
"hello from app-server"
);
let copy_dir_request_id = mcp
.send_fs_copy_request(FsCopyParams {
source_path: absolute_path(source_dir.clone()),
destination_path: absolute_path(copied_dir.clone()),
recursive: true,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(copy_dir_request_id)),
)
.await??;
assert_eq!(
std::fs::read_to_string(copied_dir.join("nested").join("note.txt"))?,
"hello from app-server"
);
let read_directory_request_id = mcp
.send_fs_read_directory_request(codex_app_server_protocol::FsReadDirectoryParams {
path: absolute_path(source_dir.clone()),
})
.await?;
let readdir_response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_directory_request_id)),
)
.await??;
let mut entries =
to_response::<codex_app_server_protocol::FsReadDirectoryResponse>(readdir_response)?
.entries;
entries.sort_by(|left, right| left.file_name.cmp(&right.file_name));
assert_eq!(
entries,
vec![
FsReadDirectoryEntry {
file_name: "nested".to_string(),
is_directory: true,
is_file: false,
},
FsReadDirectoryEntry {
file_name: "root.txt".to_string(),
is_directory: false,
is_file: true,
},
]
);
let remove_request_id = mcp
.send_fs_remove_request(codex_app_server_protocol::FsRemoveParams {
path: absolute_path(copied_dir.clone()),
recursive: None,
force: None,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(remove_request_id)),
)
.await??;
assert!(
!copied_dir.exists(),
"fs/remove should default to recursive+force for directory trees"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_write_file_accepts_base64_bytes() -> Result<()> {
let codex_home = TempDir::new()?;
let file_path = codex_home.path().join("blob.bin");
let bytes = [0_u8, 1, 2, 255];
let mut mcp = initialized_mcp(&codex_home).await?;
let write_request_id = mcp
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(file_path.clone()),
data_base64: STANDARD.encode(bytes),
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_request_id)),
)
.await??;
assert_eq!(std::fs::read(&file_path)?, bytes);
let read_request_id = mcp
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
path: absolute_path(file_path),
})
.await?;
let read_response: FsReadFileResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
)
.await??,
)?;
assert_eq!(
read_response,
FsReadFileResponse {
data_base64: STANDARD.encode(bytes),
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_write_file_rejects_invalid_base64() -> Result<()> {
let codex_home = TempDir::new()?;
let file_path = codex_home.path().join("blob.bin");
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_write_file_request(FsWriteFileParams {
path: absolute_path(file_path),
data_base64: "%%%".to_string(),
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert!(
error
.error
.message
.starts_with("fs/writeFile requires valid base64 dataBase64:"),
"unexpected error message: {}",
error.error.message
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_methods_reject_relative_paths() -> Result<()> {
let codex_home = TempDir::new()?;
let absolute_file = codex_home.path().join("absolute.txt");
std::fs::write(&absolute_file, "hello")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let read_id = mcp
.send_raw_request("fs/readFile", Some(json!({ "path": "relative.txt" })))
.await?;
expect_error_message(
&mut mcp,
read_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
let write_id = mcp
.send_raw_request(
"fs/writeFile",
Some(json!({
"path": "relative.txt",
"dataBase64": STANDARD.encode("hello"),
})),
)
.await?;
expect_error_message(
&mut mcp,
write_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
let create_directory_id = mcp
.send_raw_request(
"fs/createDirectory",
Some(json!({
"path": "relative-dir",
"recursive": null,
})),
)
.await?;
expect_error_message(
&mut mcp,
create_directory_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
let get_metadata_id = mcp
.send_raw_request("fs/getMetadata", Some(json!({ "path": "relative.txt" })))
.await?;
expect_error_message(
&mut mcp,
get_metadata_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
let read_directory_id = mcp
.send_raw_request("fs/readDirectory", Some(json!({ "path": "relative-dir" })))
.await?;
expect_error_message(
&mut mcp,
read_directory_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
let remove_id = mcp
.send_raw_request(
"fs/remove",
Some(json!({
"path": "relative.txt",
"recursive": null,
"force": null,
})),
)
.await?;
expect_error_message(
&mut mcp,
remove_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
let copy_source_id = mcp
.send_raw_request(
"fs/copy",
Some(json!({
"sourcePath": "relative.txt",
"destinationPath": absolute_file.clone(),
"recursive": false,
})),
)
.await?;
expect_error_message(
&mut mcp,
copy_source_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
let copy_destination_id = mcp
.send_raw_request(
"fs/copy",
Some(json!({
"sourcePath": absolute_file,
"destinationPath": "relative-copy.txt",
"recursive": false,
})),
)
.await?;
expect_error_message(
&mut mcp,
copy_destination_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_copy_rejects_directory_without_recursive() -> Result<()> {
let codex_home = TempDir::new()?;
let source_dir = codex_home.path().join("source");
std::fs::create_dir_all(&source_dir)?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_copy_request(FsCopyParams {
source_path: absolute_path(source_dir),
destination_path: absolute_path(codex_home.path().join("dest")),
recursive: false,
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"fs/copy requires recursive: true when sourcePath is a directory"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_copy_rejects_copying_directory_into_descendant() -> Result<()> {
let codex_home = TempDir::new()?;
let source_dir = codex_home.path().join("source");
std::fs::create_dir_all(source_dir.join("nested"))?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_copy_request(FsCopyParams {
source_path: absolute_path(source_dir.clone()),
destination_path: absolute_path(source_dir.join("nested").join("copy")),
recursive: true,
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
error.error.message,
"fs/copy cannot copy a directory to itself or one of its descendants"
);
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_copy_preserves_symlinks_in_recursive_copy() -> Result<()> {
let codex_home = TempDir::new()?;
let source_dir = codex_home.path().join("source");
let nested_dir = source_dir.join("nested");
let copied_dir = codex_home.path().join("copied");
std::fs::create_dir_all(&nested_dir)?;
symlink("nested", source_dir.join("nested-link"))?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_copy_request(FsCopyParams {
source_path: absolute_path(source_dir),
destination_path: absolute_path(copied_dir.clone()),
recursive: true,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let copied_link = copied_dir.join("nested-link");
let metadata = std::fs::symlink_metadata(&copied_link)?;
assert!(metadata.file_type().is_symlink());
assert_eq!(std::fs::read_link(copied_link)?, PathBuf::from("nested"));
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_copy_ignores_unknown_special_files_in_recursive_copy() -> Result<()> {
let codex_home = TempDir::new()?;
let source_dir = codex_home.path().join("source");
let copied_dir = codex_home.path().join("copied");
std::fs::create_dir_all(&source_dir)?;
std::fs::write(source_dir.join("note.txt"), "hello")?;
let fifo_path = source_dir.join("named-pipe");
let output = Command::new("mkfifo").arg(&fifo_path).output()?;
if !output.status.success() {
anyhow::bail!(
"mkfifo failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_copy_request(FsCopyParams {
source_path: absolute_path(source_dir),
destination_path: absolute_path(copied_dir.clone()),
recursive: true,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
std::fs::read_to_string(copied_dir.join("note.txt"))?,
"hello"
);
assert!(!copied_dir.join("named-pipe").exists());
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> {
let codex_home = TempDir::new()?;
let fifo_path = codex_home.path().join("named-pipe");
let output = Command::new("mkfifo").arg(&fifo_path).output()?;
if !output.status.success() {
anyhow::bail!(
"mkfifo failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_copy_request(FsCopyParams {
source_path: absolute_path(fifo_path),
destination_path: absolute_path(codex_home.path().join("copied")),
recursive: false,
})
.await?;
expect_error_message(
&mut mcp,
request_id,
"fs/copy only supports regular files, directories, and symlinks",
)
.await?;
Ok(())
}
@@ -12,6 +12,7 @@ mod connection_handling_websocket_unix;
mod dynamic_tools;
mod experimental_api;
mod experimental_feature_list;
mod fs;
mod initialize;
mod mcp_server_elicitation;
mod model_list;