diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 45038abf0..52b1152c4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7713,11 +7713,15 @@ "type": "integer" }, "isDirectory": { - "description": "Whether the path currently resolves to a directory.", + "description": "Whether the path resolves to a directory.", "type": "boolean" }, "isFile": { - "description": "Whether the path currently resolves to a regular file.", + "description": "Whether the path resolves to a regular file.", + "type": "boolean" + }, + "isSymlink": { + "description": "Whether the path itself is a symbolic link.", "type": "boolean" }, "modifiedAtMs": { @@ -7730,6 +7734,7 @@ "createdAtMs", "isDirectory", "isFile", + "isSymlink", "modifiedAtMs" ], "title": "FsGetMetadataResponse", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a2f40d982..44bb50822 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4354,11 +4354,15 @@ "type": "integer" }, "isDirectory": { - "description": "Whether the path currently resolves to a directory.", + "description": "Whether the path resolves to a directory.", "type": "boolean" }, "isFile": { - "description": "Whether the path currently resolves to a regular file.", + "description": "Whether the path resolves to a regular file.", + "type": "boolean" + }, + "isSymlink": { + "description": "Whether the path itself is a symbolic link.", "type": "boolean" }, "modifiedAtMs": { @@ -4371,6 +4375,7 @@ "createdAtMs", "isDirectory", "isFile", + "isSymlink", "modifiedAtMs" ], "title": "FsGetMetadataResponse", diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsGetMetadataResponse.json b/codex-rs/app-server-protocol/schema/json/v2/FsGetMetadataResponse.json index 95eeb6392..82481f579 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FsGetMetadataResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FsGetMetadataResponse.json @@ -8,11 +8,15 @@ "type": "integer" }, "isDirectory": { - "description": "Whether the path currently resolves to a directory.", + "description": "Whether the path resolves to a directory.", "type": "boolean" }, "isFile": { - "description": "Whether the path currently resolves to a regular file.", + "description": "Whether the path resolves to a regular file.", + "type": "boolean" + }, + "isSymlink": { + "description": "Whether the path itself is a symbolic link.", "type": "boolean" }, "modifiedAtMs": { @@ -25,6 +29,7 @@ "createdAtMs", "isDirectory", "isFile", + "isSymlink", "modifiedAtMs" ], "title": "FsGetMetadataResponse", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts index 14b4db7e3..a1a127e19 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsGetMetadataResponse.ts @@ -7,13 +7,17 @@ */ export type FsGetMetadataResponse = { /** - * Whether the path currently resolves to a directory. + * Whether the path resolves to a directory. */ isDirectory: boolean, /** - * Whether the path currently resolves to a regular file. + * Whether the path resolves to a regular file. */ isFile: boolean, +/** + * Whether the path itself is a symbolic link. + */ +isSymlink: boolean, /** * File creation time in Unix milliseconds when available, otherwise `0`. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1e42fae12..2e92061e8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2320,10 +2320,12 @@ pub struct FsGetMetadataParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct FsGetMetadataResponse { - /// Whether the path currently resolves to a directory. + /// Whether the path resolves to a directory. pub is_directory: bool, - /// Whether the path currently resolves to a regular file. + /// Whether the path resolves to a regular file. pub is_file: bool, + /// Whether the path itself is a symbolic link. + pub is_symlink: bool, /// File creation time in Unix milliseconds when available, otherwise `0`. #[ts(type = "number")] pub created_at_ms: i64, @@ -6765,6 +6767,7 @@ mod tests { let response = FsGetMetadataResponse { is_directory: false, is_file: true, + is_symlink: false, created_at_ms: 123, modified_at_ms: 456, }; @@ -6775,6 +6778,7 @@ mod tests { json!({ "isDirectory": false, "isFile": true, + "isSymlink": false, "createdAtMs": 123, "modifiedAtMs": 456, }) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7083c7911..97220f0d4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -167,7 +167,7 @@ Example with notification opt-out: - `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/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `isSymlink`, `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`. @@ -878,6 +878,7 @@ All filesystem paths in this section must be absolute. { "id": 42, "result": { "isDirectory": false, "isFile": true, + "isSymlink": false, "createdAtMs": 1730910000000, "modifiedAtMs": 1730910000000 } } @@ -889,7 +890,7 @@ All filesystem paths in this section must be absolute. } } ``` -- `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/getMetadata` returns whether the path resolves to a directory or regular file, whether the path itself is a symlink, 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`. diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 8540b9210..a2c71871d 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -99,6 +99,7 @@ impl FsApi { Ok(FsGetMetadataResponse { is_directory: metadata.is_directory, is_file: metadata.is_file, + is_symlink: metadata.is_symlink, created_at_ms: metadata.created_at_ms, modified_at_ms: metadata.modified_at_ms, }) diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index 3fd5d62c8..c7f28f09f 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -89,6 +89,7 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { "createdAtMs".to_string(), "isDirectory".to_string(), "isFile".to_string(), + "isSymlink".to_string(), "modifiedAtMs".to_string(), ] ); @@ -99,6 +100,7 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { FsGetMetadataResponse { is_directory: false, is_file: true, + is_symlink: false, created_at_ms: stat.created_at_ms, modified_at_ms: stat.modified_at_ms, } @@ -111,6 +113,35 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_get_metadata_reports_symlink() -> Result<()> { + let codex_home = TempDir::new()?; + let file_path = codex_home.path().join("note.txt"); + let symlink_path = codex_home.path().join("note-link.txt"); + std::fs::write(&file_path, "hello")?; + symlink(&file_path, &symlink_path)?; + + 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(symlink_path), + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let stat: FsGetMetadataResponse = to_response(response)?; + assert_eq!(stat.is_directory, false); + assert_eq!(stat.is_file, true); + assert_eq!(stat.is_symlink, true); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/exec-server/src/file_system.rs b/codex-rs/exec-server/src/file_system.rs index 5786082e4..b09347686 100644 --- a/codex-rs/exec-server/src/file_system.rs +++ b/codex-rs/exec-server/src/file_system.rs @@ -25,6 +25,7 @@ pub struct CopyOptions { pub struct FileMetadata { pub is_directory: bool, pub is_file: bool, + pub is_symlink: bool, pub created_at_ms: i64, pub modified_at_ms: i64, } diff --git a/codex-rs/exec-server/src/fs_helper.rs b/codex-rs/exec-server/src/fs_helper.rs index b4f50c75a..8d9122480 100644 --- a/codex-rs/exec-server/src/fs_helper.rs +++ b/codex-rs/exec-server/src/fs_helper.rs @@ -214,6 +214,7 @@ pub(crate) async fn run_direct_request( Ok(FsHelperPayload::GetMetadata(FsGetMetadataResponse { is_directory: metadata.is_directory, is_file: metadata.is_file, + is_symlink: metadata.is_symlink, created_at_ms: metadata.created_at_ms, modified_at_ms: metadata.modified_at_ms, })) diff --git a/codex-rs/exec-server/src/local_file_system.rs b/codex-rs/exec-server/src/local_file_system.rs index 1c2b0f79e..fe9a4a84f 100644 --- a/codex-rs/exec-server/src/local_file_system.rs +++ b/codex-rs/exec-server/src/local_file_system.rs @@ -286,9 +286,11 @@ impl ExecutorFileSystem for DirectFileSystem { ) -> FileSystemResult { reject_sandbox_context(sandbox)?; let metadata = tokio::fs::metadata(path.as_path()).await?; + let symlink_metadata = tokio::fs::symlink_metadata(path.as_path()).await?; Ok(FileMetadata { is_directory: metadata.is_dir(), is_file: metadata.is_file(), + is_symlink: symlink_metadata.file_type().is_symlink(), 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), }) diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 0ccb9794a..5d2934889 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -199,6 +199,7 @@ pub struct FsGetMetadataParams { pub struct FsGetMetadataResponse { pub is_directory: bool, pub is_file: bool, + pub is_symlink: bool, pub created_at_ms: i64, pub modified_at_ms: i64, } diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index ff4d8a4ce..111e8d603 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -115,6 +115,7 @@ impl ExecutorFileSystem for RemoteFileSystem { Ok(FileMetadata { is_directory: response.is_directory, is_file: response.is_file, + is_symlink: response.is_symlink, created_at_ms: response.created_at_ms, modified_at_ms: response.modified_at_ms, }) diff --git a/codex-rs/exec-server/src/sandboxed_file_system.rs b/codex-rs/exec-server/src/sandboxed_file_system.rs index 1079b22b4..133a3b732 100644 --- a/codex-rs/exec-server/src/sandboxed_file_system.rs +++ b/codex-rs/exec-server/src/sandboxed_file_system.rs @@ -138,6 +138,7 @@ impl ExecutorFileSystem for SandboxedFileSystem { Ok(FileMetadata { is_directory: response.is_directory, is_file: response.is_file, + is_symlink: response.is_symlink, created_at_ms: response.created_at_ms, modified_at_ms: response.modified_at_ms, }) diff --git a/codex-rs/exec-server/src/server/file_system_handler.rs b/codex-rs/exec-server/src/server/file_system_handler.rs index d254ef624..14d6b8f7b 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -100,6 +100,7 @@ impl FileSystemHandler { Ok(FsGetMetadataResponse { is_directory: metadata.is_directory, is_file: metadata.is_file, + is_symlink: metadata.is_symlink, created_at_ms: metadata.created_at_ms, modified_at_ms: metadata.modified_at_ms, }) diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs index f49931dd6..6b4a05866 100644 --- a/codex-rs/exec-server/tests/file_system.rs +++ b/codex-rs/exec-server/tests/file_system.rs @@ -141,13 +141,37 @@ async fn file_system_get_metadata_returns_expected_fields(use_remote: bool) -> R std::fs::write(&file_path, "hello")?; let metadata = file_system - .get_metadata(&absolute_path(file_path), /*sandbox*/ None) + .get_metadata(&absolute_path(file_path.clone()), /*sandbox*/ None) .await .with_context(|| format!("mode={use_remote}"))?; assert_eq!(metadata.is_directory, false); assert_eq!(metadata.is_file, true); + assert_eq!(metadata.is_symlink, false); assert!(metadata.modified_at_ms > 0); + let symlink_path = tmp.path().join("note-link.txt"); + symlink(&file_path, &symlink_path)?; + let symlink_metadata = file_system + .get_metadata(&absolute_path(symlink_path.clone()), /*sandbox*/ None) + .await + .with_context(|| format!("mode={use_remote}"))?; + assert_eq!(symlink_metadata.is_directory, false); + assert_eq!(symlink_metadata.is_file, true); + assert_eq!(symlink_metadata.is_symlink, true); + assert!(symlink_metadata.modified_at_ms > 0); + + let dir_path = tmp.path().join("notes"); + std::fs::create_dir(&dir_path)?; + let dir_symlink_path = tmp.path().join("notes-link"); + symlink(&dir_path, &dir_symlink_path)?; + let dir_symlink_metadata = file_system + .get_metadata(&absolute_path(dir_symlink_path), /*sandbox*/ None) + .await + .with_context(|| format!("mode={use_remote}"))?; + assert_eq!(dir_symlink_metadata.is_directory, true); + assert_eq!(dir_symlink_metadata.is_file, false); + assert_eq!(dir_symlink_metadata.is_symlink, true); + Ok(()) }