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

View File

@@ -312,6 +312,34 @@ client_request_definitions! {
params: v2::AppsListParams,
response: v2::AppsListResponse,
},
FsReadFile => "fs/readFile" {
params: v2::FsReadFileParams,
response: v2::FsReadFileResponse,
},
FsWriteFile => "fs/writeFile" {
params: v2::FsWriteFileParams,
response: v2::FsWriteFileResponse,
},
FsCreateDirectory => "fs/createDirectory" {
params: v2::FsCreateDirectoryParams,
response: v2::FsCreateDirectoryResponse,
},
FsGetMetadata => "fs/getMetadata" {
params: v2::FsGetMetadataParams,
response: v2::FsGetMetadataResponse,
},
FsReadDirectory => "fs/readDirectory" {
params: v2::FsReadDirectoryParams,
response: v2::FsReadDirectoryResponse,
},
FsRemove => "fs/remove" {
params: v2::FsRemoveParams,
response: v2::FsRemoveResponse,
},
FsCopy => "fs/copy" {
params: v2::FsCopyParams,
response: v2::FsCopyResponse,
},
SkillsConfigWrite => "skills/config/write" {
params: v2::SkillsConfigWriteParams,
response: v2::SkillsConfigWriteResponse,
@@ -921,8 +949,17 @@ mod tests {
use serde_json::json;
use std::path::PathBuf;
fn absolute_path_string(path: &str) -> String {
let trimmed = path.trim_start_matches('/');
if cfg!(windows) {
format!(r"C:\{}", trimmed.replace('/', "\\"))
} else {
format!("/{trimmed}")
}
}
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
AbsolutePathBuf::from_absolute_path(absolute_path_string(path)).expect("absolute path")
}
#[test]
@@ -1419,6 +1456,27 @@ mod tests {
Ok(())
}
#[test]
fn serialize_fs_get_metadata() -> Result<()> {
let request = ClientRequest::FsGetMetadata {
request_id: RequestId::Integer(9),
params: v2::FsGetMetadataParams {
path: absolute_path("tmp/example"),
},
};
assert_eq!(
json!({
"method": "fs/getMetadata",
"id": 9,
"params": {
"path": absolute_path_string("tmp/example")
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_list_experimental_features() -> Result<()> {
let request = ClientRequest::ExperimentalFeatureList {

View File

@@ -2068,6 +2068,157 @@ pub struct FeedbackUploadResponse {
pub thread_id: String,
}
/// Read a file from the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsReadFileParams {
/// Absolute path to read.
pub path: AbsolutePathBuf,
}
/// Base64-encoded file contents returned by `fs/readFile`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsReadFileResponse {
/// File contents encoded as base64.
pub data_base64: String,
}
/// Write a file on the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsWriteFileParams {
/// Absolute path to write.
pub path: AbsolutePathBuf,
/// File contents encoded as base64.
pub data_base64: String,
}
/// Successful response for `fs/writeFile`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsWriteFileResponse {}
/// Create a directory on the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsCreateDirectoryParams {
/// Absolute directory path to create.
pub path: AbsolutePathBuf,
/// Whether parent directories should also be created. Defaults to `true`.
#[ts(optional = nullable)]
pub recursive: Option<bool>,
}
/// Successful response for `fs/createDirectory`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsCreateDirectoryResponse {}
/// Request metadata for an absolute path.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsGetMetadataParams {
/// Absolute path to inspect.
pub path: AbsolutePathBuf,
}
/// Metadata returned by `fs/getMetadata`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsGetMetadataResponse {
/// Whether the path currently resolves to a directory.
pub is_directory: bool,
/// Whether the path currently resolves to a regular file.
pub is_file: bool,
/// File creation time in Unix milliseconds when available, otherwise `0`.
#[ts(type = "number")]
pub created_at_ms: i64,
/// File modification time in Unix milliseconds when available, otherwise `0`.
#[ts(type = "number")]
pub modified_at_ms: i64,
}
/// List direct child names for a directory.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsReadDirectoryParams {
/// Absolute directory path to read.
pub path: AbsolutePathBuf,
}
/// A directory entry returned by `fs/readDirectory`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsReadDirectoryEntry {
/// Direct child entry name only, not an absolute or relative path.
pub file_name: String,
/// Whether this entry resolves to a directory.
pub is_directory: bool,
/// Whether this entry resolves to a regular file.
pub is_file: bool,
}
/// Directory entries returned by `fs/readDirectory`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsReadDirectoryResponse {
/// Direct child entries in the requested directory.
pub entries: Vec<FsReadDirectoryEntry>,
}
/// Remove a file or directory tree from the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsRemoveParams {
/// Absolute path to remove.
pub path: AbsolutePathBuf,
/// Whether directory removal should recurse. Defaults to `true`.
#[ts(optional = nullable)]
pub recursive: Option<bool>,
/// Whether missing paths should be ignored. Defaults to `true`.
#[ts(optional = nullable)]
pub force: Option<bool>,
}
/// Successful response for `fs/remove`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsRemoveResponse {}
/// Copy a file or directory tree on the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsCopyParams {
/// Absolute source path.
pub source_path: AbsolutePathBuf,
/// Absolute destination path.
pub destination_path: AbsolutePathBuf,
/// Required for directory copies; ignored for file copies.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub recursive: bool,
}
/// Successful response for `fs/copy`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsCopyResponse {}
/// PTY size in character cells for `command/exec` PTY sessions.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -5535,13 +5686,22 @@ mod tests {
use serde_json::json;
use std::path::PathBuf;
fn test_absolute_path() -> AbsolutePathBuf {
let path = if cfg!(windows) {
r"C:\readable"
fn absolute_path_string(path: &str) -> String {
let trimmed = path.trim_start_matches('/');
if cfg!(windows) {
format!(r"C:\{}", trimmed.replace('/', "\\"))
} else {
"/readable"
};
AbsolutePathBuf::from_absolute_path(path).expect("path must be absolute")
format!("/{trimmed}")
}
}
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(absolute_path_string(path))
.expect("path must be absolute")
}
fn test_absolute_path() -> AbsolutePathBuf {
absolute_path("readable")
}
#[test]
@@ -5891,6 +6051,134 @@ mod tests {
assert_eq!(response.scope, PermissionGrantScope::Turn);
}
#[test]
fn fs_get_metadata_response_round_trips_minimal_fields() {
let response = FsGetMetadataResponse {
is_directory: false,
is_file: true,
created_at_ms: 123,
modified_at_ms: 456,
};
let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response");
assert_eq!(
value,
json!({
"isDirectory": false,
"isFile": true,
"createdAtMs": 123,
"modifiedAtMs": 456,
})
);
let decoded = serde_json::from_value::<FsGetMetadataResponse>(value)
.expect("deserialize fs/getMetadata response");
assert_eq!(decoded, response);
}
#[test]
fn fs_read_file_response_round_trips_base64_data() {
let response = FsReadFileResponse {
data_base64: "aGVsbG8=".to_string(),
};
let value = serde_json::to_value(&response).expect("serialize fs/readFile response");
assert_eq!(
value,
json!({
"dataBase64": "aGVsbG8=",
})
);
let decoded = serde_json::from_value::<FsReadFileResponse>(value)
.expect("deserialize fs/readFile response");
assert_eq!(decoded, response);
}
#[test]
fn fs_read_file_params_round_trip() {
let params = FsReadFileParams {
path: absolute_path("tmp/example.txt"),
};
let value = serde_json::to_value(&params).expect("serialize fs/readFile params");
assert_eq!(
value,
json!({
"path": absolute_path_string("tmp/example.txt"),
})
);
let decoded = serde_json::from_value::<FsReadFileParams>(value)
.expect("deserialize fs/readFile params");
assert_eq!(decoded, params);
}
#[test]
fn fs_create_directory_params_round_trip_with_default_recursive() {
let params = FsCreateDirectoryParams {
path: absolute_path("tmp/example"),
recursive: None,
};
let value = serde_json::to_value(&params).expect("serialize fs/createDirectory params");
assert_eq!(
value,
json!({
"path": absolute_path_string("tmp/example"),
"recursive": null,
})
);
let decoded = serde_json::from_value::<FsCreateDirectoryParams>(value)
.expect("deserialize fs/createDirectory params");
assert_eq!(decoded, params);
}
#[test]
fn fs_write_file_params_round_trip_with_base64_data() {
let params = FsWriteFileParams {
path: absolute_path("tmp/example.bin"),
data_base64: "AAE=".to_string(),
};
let value = serde_json::to_value(&params).expect("serialize fs/writeFile params");
assert_eq!(
value,
json!({
"path": absolute_path_string("tmp/example.bin"),
"dataBase64": "AAE=",
})
);
let decoded = serde_json::from_value::<FsWriteFileParams>(value)
.expect("deserialize fs/writeFile params");
assert_eq!(decoded, params);
}
#[test]
fn fs_copy_params_round_trip_with_recursive_directory_copy() {
let params = FsCopyParams {
source_path: absolute_path("tmp/source"),
destination_path: absolute_path("tmp/destination"),
recursive: true,
};
let value = serde_json::to_value(&params).expect("serialize fs/copy params");
assert_eq!(
value,
json!({
"sourcePath": absolute_path_string("tmp/source"),
"destinationPath": absolute_path_string("tmp/destination"),
"recursive": true,
})
);
let decoded =
serde_json::from_value::<FsCopyParams>(value).expect("deserialize fs/copy params");
assert_eq!(decoded, params);
}
#[test]
fn command_exec_params_default_optional_streaming_flags() {
let params = serde_json::from_value::<CommandExecParams>(json!({