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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user