feat: integrate authorized Worker Drive operations

Merge approved T-723 source e94af40d into develop3a2994cd. Resolve overlapping configuration WIP test migration by retaining source typed result assertions and target live Tree/Inspect publication checks. Runtime implementation merged without conflicts. Root check; full Manifest, Tools, Worker935+110, Runtime349+34, Server710+11, FS and Workdir suites; Workdir HTTP; API83/TypeScript91; WIP0.2.0 resolution and fmt/diff checks passed.
This commit is contained in:
2026-10-09 16:11:07 +09:00
35 changed files with 5886 additions and 116 deletions
+4 -2
View File
@@ -21,7 +21,7 @@ mod grep;
mod read;
mod routing;
mod search_target;
mod view_image;
pub mod view_image;
mod web;
mod write;
@@ -33,7 +33,9 @@ pub use glob::glob_tool;
pub use grep::grep_tool;
pub use read::read_tool;
pub use tracker::{ChangeStat, Tracker};
pub use view_image::{routed_view_image_tool, view_image_tool};
pub use view_image::{
detect_image_mime, read_workdir_bytes, routed_view_image_tool, view_image_tool,
};
pub use web::{web_fetch_tool, web_search_tool};
pub use write::write_tool;
+205 -8
View File
@@ -7,7 +7,10 @@ use agen::tool::{
};
use async_trait::async_trait;
use serde::Deserialize;
use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
use sha2::{Digest, Sha256};
use workdir::{
ContentHash, ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter,
};
use crate::error::ToolsError;
@@ -49,7 +52,7 @@ impl Tool for ViewImageTool {
workdir::WorkdirSessionCapability::Read,
)?;
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
let bytes = read_image_bytes(selected.session.as_ref(), path.clone())
let bytes = read_workdir_bytes(selected.session.as_ref(), path.clone(), MAX_IMAGE_BYTES)
.await
.map_err(ToolsError::from)?;
let mime_type = detect_image_mime(&bytes).ok_or_else(|| {
@@ -70,9 +73,13 @@ impl Tool for ViewImageTool {
}
}
async fn read_image_bytes(
/// Read arbitrary bytes through the scoped provider, bounded by `max_total_bytes`.
/// Checks every chunk's identity, range, size and hash, then verifies the assembled
/// SHA256 against the provider's pinned [`ContentHash`]. No MIME restriction applies.
pub async fn read_workdir_bytes(
session: &dyn workdir::WorkdirSession,
path: WorkdirPath,
max_total_bytes: usize,
) -> Result<Vec<u8>, workdir::WorkdirError> {
let mut bytes = Vec::new();
let mut offset = 0_u64;
@@ -116,16 +123,16 @@ async fn read_image_bytes(
"bounded binary read returned a truncated or invalid byte range".to_string(),
));
}
if result.total_bytes > MAX_IMAGE_BYTES as u64 {
if result.total_bytes > max_total_bytes as u64 {
return Err(workdir::WorkdirError::InvalidArgument(format!(
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
"file exceeds the {max_total_bytes}-byte limit"
)));
}
if expected_total.is_some_and(|total| total != result.total_bytes)
|| expected_hash.is_some_and(|hash| hash != result.content_hash)
{
return Err(workdir::WorkdirError::Conflict(
"image changed while it was being read; retry ViewImage".to_string(),
"file changed while it was being read; retry the read".to_string(),
));
}
if expected_total.is_none() {
@@ -146,7 +153,13 @@ async fn read_image_bytes(
if result.eof {
if bytes.len() as u64 != result.total_bytes {
return Err(workdir::WorkdirError::Transport(
"bounded binary read returned a partial image".to_string(),
"bounded binary read returned partial file bytes".to_string(),
));
}
let assembled_hash: ContentHash = Sha256::digest(&bytes).into();
if assembled_hash != result.content_hash {
return Err(workdir::WorkdirError::Transport(
"bounded binary read assembled bytes do not match the content hash".to_string(),
));
}
return Ok(bytes);
@@ -172,7 +185,8 @@ pub fn routed_view_image_tool(router: Arc<WorkdirSessionRouter>) -> ToolDefiniti
})
}
fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
/// Detect PNG, JPEG, GIF or WebP signatures without trusting a file extension.
pub fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("image/png")
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
@@ -189,6 +203,189 @@ fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
#[cfg(test)]
mod tests {
use super::*;
use manifest::Scope;
use std::collections::VecDeque;
use std::sync::Mutex;
use tempfile::TempDir;
use workdir::*;
#[derive(Debug)]
struct BinarySession {
local: LocalWorkdirSession,
responses: Mutex<VecDeque<ReadBytesResult>>,
}
#[async_trait]
impl WorkdirSession for BinarySession {
fn workdir(&self) -> &Workdir {
self.local.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.local.capabilities()
}
async fn read_bytes(
&self,
request: ReadBytesRequest,
) -> Result<ReadBytesResult, WorkdirError> {
let response = self.responses.lock().unwrap().pop_front().unwrap();
if request.offset > 0 {
assert!(request.expected_hash.is_some());
}
assert!(request.max_bytes <= IMAGE_READ_CHUNK_BYTES);
Ok(response)
}
async fn stat(&self, r: StatRequest) -> Result<StatResult, WorkdirError> {
self.local.stat(r).await
}
async fn read(&self, r: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.local.read(r).await
}
async fn write(&self, r: WriteRequest) -> Result<WriteResult, WorkdirError> {
self.local.write(r).await
}
async fn edit(&self, r: EditRequest) -> Result<EditResult, WorkdirError> {
self.local.edit(r).await
}
async fn list(&self, r: ListRequest) -> Result<ListResult, WorkdirError> {
self.local.list(r).await
}
async fn glob(&self, r: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.local.glob(r).await
}
async fn grep(&self, r: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.local.grep(r).await
}
async fn start_command(&self, r: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.local.start_command(r).await
}
async fn command_status(&self, r: CommandHandle) -> Result<CommandStatus, WorkdirError> {
self.local.command_status(r).await
}
async fn command_output(
&self,
r: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
self.local.command_output(r).await
}
async fn cancel_command(&self, r: CommandHandle) -> Result<(), WorkdirError> {
self.local.cancel_command(r).await
}
async fn close(&self) -> Result<(), WorkdirError> {
self.local.close().await
}
}
fn binary_session(dir: &TempDir, responses: Vec<ReadBytesResult>) -> BinarySession {
BinarySession {
local: LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
dir.path().into(),
),
responses: Mutex::new(responses.into()),
}
}
fn chunk(bytes: &[u8], offset: u64, total: u64, hash: ContentHash) -> ReadBytesResult {
ReadBytesResult {
path: WorkdirPath::new("artifact.bin").unwrap(),
bytes: bytes.to_vec(),
offset,
total_bytes: total,
content_hash: hash,
eof: offset + bytes.len() as u64 == total,
}
}
#[tokio::test]
async fn bounded_binary_read_rejects_bytes_with_forged_consistent_hash() {
let dir = TempDir::new().unwrap();
let hash = Sha256::digest(b"abcd").into();
let session = binary_session(
&dir,
vec![chunk(b"ab", 0, 4, hash), chunk(b"cx", 2, 4, hash)],
);
let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 4)
.await
.unwrap_err();
assert!(
matches!(error, WorkdirError::Transport(message) if message.contains("content hash"))
);
}
#[tokio::test]
async fn bounded_binary_read_rejects_invalid_provider_chunk_metadata() {
let dir = TempDir::new().unwrap();
let hash = Sha256::digest(b"abcd").into();
let valid = chunk(b"abcd", 0, 4, hash);
let mut cases = Vec::new();
let mut wrong_path = valid.clone();
wrong_path.path = WorkdirPath::new("other.bin").unwrap();
cases.push(wrong_path);
let mut wrong_offset = valid.clone();
wrong_offset.offset = 1;
cases.push(wrong_offset);
let mut wrong_eof = valid.clone();
wrong_eof.eof = false;
cases.push(wrong_eof);
let mut wrong_total = valid.clone();
wrong_total.total_bytes = 3;
cases.push(wrong_total);
cases.push(chunk(b"", 0, 4, hash));
cases.push(chunk(
&vec![0; IMAGE_READ_CHUNK_BYTES + 1],
0,
(IMAGE_READ_CHUNK_BYTES + 1) as u64,
hash,
));
for response in cases {
let session = binary_session(&dir, vec![response]);
let error = read_workdir_bytes(&session, valid.path.clone(), 16 * 1024 * 1024)
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::Transport(_)), "{error}");
}
}
#[tokio::test]
async fn bounded_binary_read_rejects_hash_or_size_changes_between_chunks() {
let dir = TempDir::new().unwrap();
let hash = Sha256::digest(b"abcd").into();
for second in [chunk(b"cd", 2, 4, [0; 32]), chunk(b"cd", 2, 5, hash)] {
let session = binary_session(&dir, vec![chunk(b"ab", 0, 4, hash), second]);
let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 5)
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::Conflict(_)), "{error}");
}
}
#[tokio::test]
async fn bounded_binary_read_accepts_arbitrary_bytes_at_caller_limit_and_rejects_larger_files()
{
let dir = TempDir::new().unwrap();
let bytes = vec![0xff; 16 * 1024 * 1024];
std::fs::write(dir.path().join("artifact.bin"), &bytes).unwrap();
let session =
LocalWorkdirSession::new(Scope::writable(dir.path()).unwrap(), dir.path().into());
let path = WorkdirPath::new("artifact.bin").unwrap();
assert_eq!(
read_workdir_bytes(&session, path.clone(), bytes.len())
.await
.unwrap(),
bytes
);
assert!(matches!(
read_workdir_bytes(&session, path.clone(), bytes.len() - 1).await,
Err(WorkdirError::InvalidArgument(_))
));
std::fs::write(dir.path().join("artifact.bin"), []).unwrap();
assert!(
read_workdir_bytes(&session, path, 0)
.await
.unwrap()
.is_empty()
);
}
#[test]
fn detects_supported_image_signatures_without_trusting_extensions() {