mirror of
https://github.com/openai/codex.git
synced 2026-06-03 03:41:58 +00:00
Compare commits
3 Commits
cooper/cod
...
codex/mcp-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72901ac18c | ||
|
|
bdba10d68e | ||
|
|
9151f707c1 |
@@ -837,6 +837,24 @@
|
||||
"FsReadFileParams": {
|
||||
"description": "Read a file from the host filesystem.",
|
||||
"properties": {
|
||||
"length": {
|
||||
"description": "Optional maximum byte length for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"offset": {
|
||||
"description": "Optional byte offset for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -9052,6 +9052,15 @@
|
||||
"description": "File modification time in Unix milliseconds when available, otherwise `0`.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"sizeBytes": {
|
||||
"description": "File size in bytes when available.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -9128,6 +9137,24 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Read a file from the host filesystem.",
|
||||
"properties": {
|
||||
"length": {
|
||||
"description": "Optional maximum byte length for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"offset": {
|
||||
"description": "Optional byte offset for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -5421,6 +5421,15 @@
|
||||
"description": "File modification time in Unix milliseconds when available, otherwise `0`.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"sizeBytes": {
|
||||
"description": "File size in bytes when available.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -5497,6 +5506,24 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Read a file from the host filesystem.",
|
||||
"properties": {
|
||||
"length": {
|
||||
"description": "Optional maximum byte length for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"offset": {
|
||||
"description": "Optional byte offset for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
"description": "File modification time in Unix milliseconds when available, otherwise `0`.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"sizeBytes": {
|
||||
"description": "File size in bytes when available.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
},
|
||||
"description": "Read a file from the host filesystem.",
|
||||
"properties": {
|
||||
"length": {
|
||||
"description": "Optional maximum byte length for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"offset": {
|
||||
"description": "Optional byte offset for a bounded read.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -18,6 +18,10 @@ isFile: boolean,
|
||||
* Whether the path itself is a symbolic link.
|
||||
*/
|
||||
isSymlink: boolean,
|
||||
/**
|
||||
* File size in bytes when available.
|
||||
*/
|
||||
sizeBytes: number | null,
|
||||
/**
|
||||
* File creation time in Unix milliseconds when available, otherwise `0`.
|
||||
*/
|
||||
|
||||
@@ -10,4 +10,12 @@ export type FsReadFileParams = {
|
||||
/**
|
||||
* Absolute path to read.
|
||||
*/
|
||||
path: AbsolutePathBuf, };
|
||||
path: AbsolutePathBuf,
|
||||
/**
|
||||
* Optional byte offset for a bounded read.
|
||||
*/
|
||||
offset?: number,
|
||||
/**
|
||||
* Optional maximum byte length for a bounded read.
|
||||
*/
|
||||
length?: number, };
|
||||
|
||||
@@ -1924,6 +1924,8 @@ mod tests {
|
||||
request_id: request_id(),
|
||||
params: v2::FsReadFileParams {
|
||||
path: absolute_path("/tmp/file.txt"),
|
||||
offset: None,
|
||||
length: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(fs_read.serialization_scope(), None);
|
||||
|
||||
@@ -11,6 +11,12 @@ use ts_rs::TS;
|
||||
pub struct FsReadFileParams {
|
||||
/// Absolute path to read.
|
||||
pub path: AbsolutePathBuf,
|
||||
/// Optional byte offset for a bounded read.
|
||||
#[ts(optional = nullable, type = "number")]
|
||||
pub offset: Option<u64>,
|
||||
/// Optional maximum byte length for a bounded read.
|
||||
#[ts(optional = nullable, type = "number")]
|
||||
pub length: Option<u64>,
|
||||
}
|
||||
|
||||
/// Base64-encoded file contents returned by `fs/readFile`.
|
||||
@@ -77,6 +83,9 @@ pub struct FsGetMetadataResponse {
|
||||
pub is_file: bool,
|
||||
/// Whether the path itself is a symbolic link.
|
||||
pub is_symlink: bool,
|
||||
/// File size in bytes when available.
|
||||
#[ts(type = "number | null")]
|
||||
pub size_bytes: Option<u64>,
|
||||
/// File creation time in Unix milliseconds when available, otherwise `0`.
|
||||
#[ts(type = "number")]
|
||||
pub created_at_ms: i64,
|
||||
|
||||
@@ -694,6 +694,7 @@ fn fs_get_metadata_response_round_trips_minimal_fields() {
|
||||
is_directory: false,
|
||||
is_file: true,
|
||||
is_symlink: false,
|
||||
size_bytes: Some(42),
|
||||
created_at_ms: 123,
|
||||
modified_at_ms: 456,
|
||||
};
|
||||
@@ -705,6 +706,7 @@ fn fs_get_metadata_response_round_trips_minimal_fields() {
|
||||
"isDirectory": false,
|
||||
"isFile": true,
|
||||
"isSymlink": false,
|
||||
"sizeBytes": 42,
|
||||
"createdAtMs": 123,
|
||||
"modifiedAtMs": 456,
|
||||
})
|
||||
@@ -715,6 +717,41 @@ fn fs_get_metadata_response_round_trips_minimal_fields() {
|
||||
assert_eq!(decoded, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_get_metadata_response_serializes_unavailable_size_as_null() {
|
||||
let response = FsGetMetadataResponse {
|
||||
is_directory: false,
|
||||
is_file: true,
|
||||
is_symlink: false,
|
||||
size_bytes: None,
|
||||
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,
|
||||
"isSymlink": false,
|
||||
"sizeBytes": null,
|
||||
"createdAtMs": 123,
|
||||
"modifiedAtMs": 456,
|
||||
})
|
||||
);
|
||||
|
||||
let decoded = serde_json::from_value::<FsGetMetadataResponse>(json!({
|
||||
"isDirectory": false,
|
||||
"isFile": true,
|
||||
"isSymlink": false,
|
||||
"createdAtMs": 123,
|
||||
"modifiedAtMs": 456,
|
||||
}))
|
||||
.expect("deserialize fs/getMetadata response without sizeBytes");
|
||||
assert_eq!(decoded, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_read_file_response_round_trips_base64_data() {
|
||||
let response = FsReadFileResponse {
|
||||
@@ -738,6 +775,8 @@ fn fs_read_file_response_round_trips_base64_data() {
|
||||
fn fs_read_file_params_round_trip() {
|
||||
let params = FsReadFileParams {
|
||||
path: absolute_path("tmp/example.txt"),
|
||||
offset: Some(4),
|
||||
length: Some(8),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params");
|
||||
@@ -745,6 +784,8 @@ fn fs_read_file_params_round_trip() {
|
||||
value,
|
||||
json!({
|
||||
"path": absolute_path_string("tmp/example.txt"),
|
||||
"offset": 4,
|
||||
"length": 8,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -753,6 +794,31 @@ fn fs_read_file_params_round_trip() {
|
||||
assert_eq!(decoded, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_read_file_params_serializes_unbounded_read_with_null_range() {
|
||||
let params = FsReadFileParams {
|
||||
path: absolute_path("tmp/example.txt"),
|
||||
offset: None,
|
||||
length: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"path": absolute_path_string("tmp/example.txt"),
|
||||
"offset": null,
|
||||
"length": null,
|
||||
})
|
||||
);
|
||||
|
||||
let decoded = serde_json::from_value::<FsReadFileParams>(json!({
|
||||
"path": absolute_path_string("tmp/example.txt"),
|
||||
}))
|
||||
.expect("deserialize fs/readFile params without range");
|
||||
assert_eq!(decoded, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fs_create_directory_params_round_trip_with_default_recursive() {
|
||||
let params = FsCreateDirectoryParams {
|
||||
|
||||
@@ -178,10 +178,10 @@ Example with notification opt-out:
|
||||
- `process/kill` — experimental; terminate a running `process/spawn` session by `processHandle`; returns `{}`.
|
||||
- `process/outputDelta` — experimental; notification emitted for base64-encoded stdout/stderr chunks from a streaming `process/spawn` session.
|
||||
- `process/exited` — experimental; notification emitted when a `process/spawn` session exits.
|
||||
- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`.
|
||||
- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`; provide both `offset` and `length` for a bounded byte range.
|
||||
- `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`, `isSymlink`, `createdAtMs`, and `modifiedAtMs`.
|
||||
- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `isSymlink`, `sizeBytes`, `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`.
|
||||
@@ -1116,6 +1116,7 @@ All filesystem paths in this section must be absolute.
|
||||
"isDirectory": false,
|
||||
"isFile": true,
|
||||
"isSymlink": false,
|
||||
"sizeBytes": 5,
|
||||
"createdAtMs": 1730910000000,
|
||||
"modifiedAtMs": 1730910000000
|
||||
} }
|
||||
@@ -1125,12 +1126,20 @@ All filesystem paths in this section must be absolute.
|
||||
{ "id": 43, "result": {
|
||||
"dataBase64": "aGVsbG8="
|
||||
} }
|
||||
{ "method": "fs/readFile", "id": 44, "params": {
|
||||
"path": "/tmp/example/nested/note.txt",
|
||||
"offset": 1,
|
||||
"length": 3
|
||||
} }
|
||||
{ "id": 44, "result": {
|
||||
"dataBase64": "ZWxs"
|
||||
} }
|
||||
```
|
||||
|
||||
- `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/getMetadata` returns whether the path resolves to a directory or regular file, whether the path itself is a symlink, `sizeBytes` when the current platform can report it, plus `createdAtMs` and `modifiedAtMs` in Unix milliseconds. If a size is unavailable, `sizeBytes` is `null`; if a timestamp is unavailable, that timestamp 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/readFile` always returns base64 bytes via `dataBase64`. Omit both `offset` and `length` to read the whole file, or provide both to read at most `length` bytes starting at `offset`; providing only one is invalid. `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.
|
||||
|
||||
### Example: Filesystem watch
|
||||
@@ -1138,11 +1147,11 @@ All filesystem paths in this section must be absolute.
|
||||
`fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations.
|
||||
|
||||
```json
|
||||
{ "method": "fs/watch", "id": 44, "params": {
|
||||
{ "method": "fs/watch", "id": 45, "params": {
|
||||
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
|
||||
"path": "/Users/me/project/.git/HEAD"
|
||||
} }
|
||||
{ "id": 44, "result": {
|
||||
{ "id": 45, "result": {
|
||||
"path": "/Users/me/project/.git/HEAD"
|
||||
} }
|
||||
{ "method": "fs/changed", "params": {
|
||||
|
||||
@@ -64,11 +64,27 @@ impl FsRequestProcessor {
|
||||
&self,
|
||||
params: FsReadFileParams,
|
||||
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
|
||||
let bytes = self
|
||||
.file_system()?
|
||||
.read_file(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
let file_system = self.file_system()?;
|
||||
let bytes = match (params.offset, params.length) {
|
||||
(None, None) => file_system
|
||||
.read_file(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?,
|
||||
(Some(offset), Some(length)) => file_system
|
||||
.read_file_range(
|
||||
¶ms.path,
|
||||
/*offset*/ offset,
|
||||
/*length*/ length,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?,
|
||||
_ => {
|
||||
return Err(invalid_request(
|
||||
"fs/readFile requires offset and length together",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(FsReadFileResponse {
|
||||
data_base64: STANDARD.encode(bytes),
|
||||
})
|
||||
@@ -120,6 +136,7 @@ impl FsRequestProcessor {
|
||||
is_directory: metadata.is_directory,
|
||||
is_file: metadata.is_file,
|
||||
is_symlink: metadata.is_symlink,
|
||||
size_bytes: metadata.size_bytes,
|
||||
created_at_ms: metadata.created_at_ms,
|
||||
modified_at_ms: metadata.modified_at_ms,
|
||||
})
|
||||
|
||||
@@ -98,6 +98,7 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
|
||||
"isFile".to_string(),
|
||||
"isSymlink".to_string(),
|
||||
"modifiedAtMs".to_string(),
|
||||
"sizeBytes".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -108,6 +109,7 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
|
||||
is_directory: false,
|
||||
is_file: true,
|
||||
is_symlink: false,
|
||||
size_bytes: Some(5),
|
||||
created_at_ms: stat.created_at_ms,
|
||||
modified_at_ms: stat.modified_at_ms,
|
||||
}
|
||||
@@ -135,6 +137,8 @@ async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result<
|
||||
let read_id = mcp
|
||||
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
|
||||
path: absolute_path(absolute_file),
|
||||
offset: None,
|
||||
length: None,
|
||||
})
|
||||
.await?;
|
||||
expect_error_message(&mut mcp, read_id, "local filesystem is not configured").await?;
|
||||
@@ -222,6 +226,8 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
|
||||
let read_request_id = mcp
|
||||
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
|
||||
path: absolute_path(nested_file.clone()),
|
||||
offset: None,
|
||||
length: None,
|
||||
})
|
||||
.await?;
|
||||
let read_response: FsReadFileResponse = to_response(
|
||||
@@ -238,6 +244,27 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
|
||||
}
|
||||
);
|
||||
|
||||
let ranged_read_request_id = mcp
|
||||
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
|
||||
path: absolute_path(nested_file.clone()),
|
||||
offset: Some(6),
|
||||
length: Some(4),
|
||||
})
|
||||
.await?;
|
||||
let ranged_read_response: FsReadFileResponse = to_response(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(ranged_read_request_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
assert_eq!(
|
||||
ranged_read_response,
|
||||
FsReadFileResponse {
|
||||
data_base64: STANDARD.encode("from"),
|
||||
}
|
||||
);
|
||||
|
||||
let copy_file_request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(nested_file.clone()),
|
||||
@@ -345,6 +372,8 @@ async fn fs_write_file_accepts_base64_bytes() -> Result<()> {
|
||||
let read_request_id = mcp
|
||||
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
|
||||
path: absolute_path(file_path),
|
||||
offset: None,
|
||||
length: None,
|
||||
})
|
||||
.await?;
|
||||
let read_response: FsReadFileResponse = to_response(
|
||||
@@ -364,6 +393,30 @@ async fn fs_write_file_accepts_base64_bytes() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_read_file_rejects_partial_range_params() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let file_path = codex_home.path().join("blob.bin");
|
||||
std::fs::write(&file_path, "hello")?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let read_request_id = mcp
|
||||
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
|
||||
path: absolute_path(file_path),
|
||||
offset: Some(0),
|
||||
length: None,
|
||||
})
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
read_request_id,
|
||||
"fs/readFile requires offset and length together",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_write_file_rejects_invalid_base64() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -171,10 +171,26 @@ pub(crate) async fn run_direct_request(
|
||||
let file_system = DirectFileSystem;
|
||||
match request {
|
||||
FsHelperRequest::ReadFile(params) => {
|
||||
let data = file_system
|
||||
.read_file(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
let data = match (params.offset, params.length) {
|
||||
(None, None) => file_system
|
||||
.read_file(¶ms.path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(map_fs_error)?,
|
||||
(Some(offset), Some(length)) => file_system
|
||||
.read_file_range(
|
||||
¶ms.path,
|
||||
/*offset*/ offset,
|
||||
/*length*/ length,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?,
|
||||
_ => {
|
||||
return Err(invalid_request(
|
||||
"fs/readFile requires offset and length together".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(FsHelperPayload::ReadFile(FsReadFileResponse {
|
||||
data_base64: STANDARD.encode(data),
|
||||
}))
|
||||
@@ -215,6 +231,7 @@ pub(crate) async fn run_direct_request(
|
||||
is_directory: metadata.is_directory,
|
||||
is_file: metadata.is_file,
|
||||
is_symlink: metadata.is_symlink,
|
||||
size_bytes: metadata.size_bytes,
|
||||
created_at_ms: metadata.created_at_ms,
|
||||
modified_at_ms: metadata.modified_at_ms,
|
||||
}))
|
||||
|
||||
@@ -7,6 +7,8 @@ use std::sync::LazyLock;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tokio::io;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
|
||||
use crate::CopyOptions;
|
||||
use crate::CreateDirectoryOptions;
|
||||
@@ -88,6 +90,21 @@ impl ExecutorFileSystem for LocalFileSystem {
|
||||
file_system.read_file(path, sandbox).await
|
||||
}
|
||||
|
||||
async fn read_file_range(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
let (file_system, sandbox) = self.file_system_for(sandbox)?;
|
||||
file_system
|
||||
.read_file_range(
|
||||
path, /*offset*/ offset, /*length*/ length, sandbox,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
@@ -161,6 +178,21 @@ impl ExecutorFileSystem for UnsandboxedFileSystem {
|
||||
self.file_system.read_file(path, /*sandbox*/ None).await
|
||||
}
|
||||
|
||||
async fn read_file_range(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
reject_platform_sandbox_context(sandbox)?;
|
||||
self.file_system
|
||||
.read_file_range(
|
||||
path, /*offset*/ offset, /*length*/ length, /*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
@@ -254,6 +286,27 @@ impl ExecutorFileSystem for DirectFileSystem {
|
||||
tokio::fs::read(path.as_path()).await
|
||||
}
|
||||
|
||||
async fn read_file_range(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
reject_sandbox_context(sandbox)?;
|
||||
if length > MAX_READ_FILE_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("file range is too large to read: limit is {MAX_READ_FILE_BYTES} bytes"),
|
||||
));
|
||||
}
|
||||
let mut file = tokio::fs::File::open(path.as_path()).await?;
|
||||
file.seek(io::SeekFrom::Start(offset)).await?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(length).read_to_end(&mut bytes).await?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
@@ -291,6 +344,7 @@ impl ExecutorFileSystem for DirectFileSystem {
|
||||
is_directory: metadata.is_dir(),
|
||||
is_file: metadata.is_file(),
|
||||
is_symlink: symlink_metadata.file_type().is_symlink(),
|
||||
size_bytes: Some(metadata.len()),
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -161,6 +161,10 @@ pub struct TerminateResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FsReadFileParams {
|
||||
pub path: AbsolutePathBuf,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub length: Option<u64>,
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
@@ -207,6 +211,8 @@ pub struct FsGetMetadataResponse {
|
||||
pub is_directory: bool,
|
||||
pub is_file: bool,
|
||||
pub is_symlink: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size_bytes: Option<u64>,
|
||||
pub created_at_ms: i64,
|
||||
pub modified_at_ms: i64,
|
||||
}
|
||||
|
||||
@@ -50,6 +50,34 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
let response = client
|
||||
.fs_read_file(FsReadFileParams {
|
||||
path: path.clone(),
|
||||
offset: None,
|
||||
length: None,
|
||||
sandbox: remote_sandbox_context(sandbox),
|
||||
})
|
||||
.await
|
||||
.map_err(map_remote_error)?;
|
||||
STANDARD.decode(response.data_base64).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("remote fs/readFile returned invalid base64 dataBase64: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_file_range(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
trace!("remote fs read_file_range");
|
||||
let client = self.client.get().await.map_err(map_remote_error)?;
|
||||
let response = client
|
||||
.fs_read_file(FsReadFileParams {
|
||||
path: path.clone(),
|
||||
offset: Some(offset),
|
||||
length: Some(length),
|
||||
sandbox: remote_sandbox_context(sandbox),
|
||||
})
|
||||
.await
|
||||
@@ -118,6 +146,7 @@ impl ExecutorFileSystem for RemoteFileSystem {
|
||||
is_directory: response.is_directory,
|
||||
is_file: response.is_file,
|
||||
is_symlink: response.is_symlink,
|
||||
size_bytes: response.size_bytes,
|
||||
created_at_ms: response.created_at_ms,
|
||||
modified_at_ms: response.modified_at_ms,
|
||||
})
|
||||
|
||||
@@ -62,6 +62,37 @@ impl ExecutorFileSystem for SandboxedFileSystem {
|
||||
sandbox,
|
||||
FsHelperRequest::ReadFile(FsReadFileParams {
|
||||
path: path.clone(),
|
||||
offset: None,
|
||||
length: None,
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
.await?
|
||||
.expect_read_file()
|
||||
.map_err(map_sandbox_error)?;
|
||||
STANDARD.decode(response.data_base64).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("fs/readFile returned invalid base64 dataBase64: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_file_range(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
let response = self
|
||||
.run_sandboxed(
|
||||
sandbox,
|
||||
FsHelperRequest::ReadFile(FsReadFileParams {
|
||||
path: path.clone(),
|
||||
offset: Some(offset),
|
||||
length: Some(length),
|
||||
sandbox: None,
|
||||
}),
|
||||
)
|
||||
@@ -139,6 +170,7 @@ impl ExecutorFileSystem for SandboxedFileSystem {
|
||||
is_directory: response.is_directory,
|
||||
is_file: response.is_file,
|
||||
is_symlink: response.is_symlink,
|
||||
size_bytes: response.size_bytes,
|
||||
created_at_ms: response.created_at_ms,
|
||||
modified_at_ms: response.modified_at_ms,
|
||||
})
|
||||
|
||||
@@ -46,11 +46,28 @@ impl FileSystemHandler {
|
||||
&self,
|
||||
params: FsReadFileParams,
|
||||
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
|
||||
let bytes = self
|
||||
.file_system
|
||||
.read_file(¶ms.path, params.sandbox.as_ref())
|
||||
.await
|
||||
.map_err(map_fs_error)?;
|
||||
let bytes = match (params.offset, params.length) {
|
||||
(None, None) => self
|
||||
.file_system
|
||||
.read_file(¶ms.path, params.sandbox.as_ref())
|
||||
.await
|
||||
.map_err(map_fs_error)?,
|
||||
(Some(offset), Some(length)) => self
|
||||
.file_system
|
||||
.read_file_range(
|
||||
¶ms.path,
|
||||
/*offset*/ offset,
|
||||
/*length*/ length,
|
||||
params.sandbox.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_fs_error)?,
|
||||
_ => {
|
||||
return Err(invalid_request(
|
||||
"fs/readFile requires offset and length together".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(FsReadFileResponse {
|
||||
data_base64: STANDARD.encode(bytes),
|
||||
})
|
||||
@@ -101,6 +118,7 @@ impl FileSystemHandler {
|
||||
is_directory: metadata.is_directory,
|
||||
is_file: metadata.is_file,
|
||||
is_symlink: metadata.is_symlink,
|
||||
size_bytes: metadata.size_bytes,
|
||||
created_at_ms: metadata.created_at_ms,
|
||||
modified_at_ms: metadata.modified_at_ms,
|
||||
})
|
||||
@@ -223,6 +241,8 @@ mod tests {
|
||||
let response = handler
|
||||
.read_file(FsReadFileParams {
|
||||
path,
|
||||
offset: None,
|
||||
length: None,
|
||||
sandbox: Some(FileSystemSandboxContext::from_legacy_sandbox_policy(
|
||||
sandbox_policy,
|
||||
sandbox_cwd.clone(),
|
||||
|
||||
@@ -370,6 +370,16 @@ async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()>
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
assert_eq!(nested_file_contents, b"hello from trait");
|
||||
let nested_file_range = file_system
|
||||
.read_file_range(
|
||||
&absolute_path(nested_file.clone()),
|
||||
/*offset*/ 6,
|
||||
/*length*/ 4,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={use_remote}"))?;
|
||||
assert_eq!(nested_file_range, b"from");
|
||||
|
||||
let nested_file_text = file_system
|
||||
.read_file_text(&absolute_path(nested_file.clone()), /*sandbox*/ None)
|
||||
|
||||
@@ -33,6 +33,7 @@ pub struct FileMetadata {
|
||||
pub is_directory: bool,
|
||||
pub is_file: bool,
|
||||
pub is_symlink: bool,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub created_at_ms: i64,
|
||||
pub modified_at_ms: i64,
|
||||
}
|
||||
@@ -139,6 +140,34 @@ pub trait ExecutorFileSystem: Send + Sync {
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>>;
|
||||
|
||||
/// Reads at most `length` bytes starting at `offset`.
|
||||
async fn read_file_range(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
let bytes = self.read_file(path, sandbox).await?;
|
||||
let start = usize::try_from(offset).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("file read range offset is too large: {err}"),
|
||||
)
|
||||
})?;
|
||||
let length = usize::try_from(length).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("file read range length is too large: {err}"),
|
||||
)
|
||||
})?;
|
||||
if start >= bytes.len() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let end = start.saturating_add(length).min(bytes.len());
|
||||
Ok(bytes[start..end].to_vec())
|
||||
}
|
||||
|
||||
/// Reads a file and decodes it as UTF-8 text.
|
||||
async fn read_file_text(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user