Compare commits

...

3 Commits

Author SHA1 Message Date
Chris Hayduk
72901ac18c Fix bounded read argument comments 2026-05-28 11:02:22 -04:00
Chris Hayduk
bdba10d68e Polish bounded file read API 2026-05-28 10:35:42 -04:00
Chris Hayduk
9151f707c1 Add bounded app-server file reads 2026-05-28 10:35:42 -04:00
21 changed files with 485 additions and 21 deletions

View File

@@ -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": [
{

View File

@@ -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": [
{

View File

@@ -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": [
{

View File

@@ -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": [

View File

@@ -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": [
{

View File

@@ -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`.
*/

View File

@@ -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, };

View File

@@ -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);

View File

@@ -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,

View File

@@ -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(&params).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(&params).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 {

View File

@@ -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": {

View File

@@ -64,11 +64,27 @@ impl FsRequestProcessor {
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system()?
.read_file(&params.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(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?,
(Some(offset), Some(length)) => file_system
.read_file_range(
&params.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,
})

View File

@@ -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()?;

View File

@@ -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(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
let data = match (params.offset, params.length) {
(None, None) => file_system
.read_file(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?,
(Some(offset), Some(length)) => file_system
.read_file_range(
&params.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,
}))

View File

@@ -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),
})

View File

@@ -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,
}

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -46,11 +46,28 @@ impl FileSystemHandler {
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file(&params.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(&params.path, params.sandbox.as_ref())
.await
.map_err(map_fs_error)?,
(Some(offset), Some(length)) => self
.file_system
.read_file_range(
&params.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(),

View File

@@ -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)

View File

@@ -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,