mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
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:
committed by
GitHub
parent
36dfb84427
commit
f8f82bfc2b
@@ -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 {
|
||||
|
||||
@@ -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(¶ms).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(¶ms).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(¶ms).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(¶ms).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!({
|
||||
|
||||
Reference in New Issue
Block a user