feat: add bounded Workspace binary transport and native WIP attachments
Keep JSON and raw-byte authority paths distinct. Runtime signs exact method/query/worker/body; binary requests are bounded, reject target normalization and redirects, and sanitize transport errors. Native attachments accompany the validated WIP value and exact validator through existing ToolOutput capture/replay. Reuse scoped Workdir binary reads with complete SHA256 verification. T-723 validation: binary transport/reviewer denial, native Invoke image capture and history replay, and bounded Workdir image helpers pass. Complete changed-crate suites and root cargo check passed on the combined Ticket implementation.
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() {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||
use crate::workspace_request::{
|
||||
RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient, RuntimeWorkspaceRequestError,
|
||||
};
|
||||
use worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
||||
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation,
|
||||
WorkspaceBinaryRequest, WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError,
|
||||
WorkspacePromptCatalogResolution, WorkspacePromptProjection, WorkspaceRequest,
|
||||
WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -381,6 +383,64 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
self
|
||||
}
|
||||
|
||||
fn execute_binary_with_optional_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
let method = match request.method {
|
||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
||||
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
|
||||
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
||||
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||
};
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if request.body.is_some() {
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
}
|
||||
let response = self
|
||||
.request_client
|
||||
.execute_binary_blocking(RuntimeWorkspaceRequest {
|
||||
method,
|
||||
path_and_query: request.path,
|
||||
body: request.body.unwrap_or_default(),
|
||||
headers,
|
||||
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||
worker_id: Some(self.worker_id.clone()),
|
||||
timeout,
|
||||
max_response_bytes: request.max_response_bytes,
|
||||
})
|
||||
.map_err(|error| {
|
||||
// Never expose request URLs, signed proofs, or binary bodies in transport errors.
|
||||
let message = match error {
|
||||
RuntimeWorkspaceRequestError::InvalidRequest(_) => {
|
||||
"invalid Workspace binary request"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Sign(_) => {
|
||||
"Workspace binary request authorization failed"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::ResponseTooLarge { .. } => {
|
||||
"Workspace binary response exceeded limit"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Transport { timeout: true, .. } => {
|
||||
"Workspace binary request timed out"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Transport { .. } => {
|
||||
"Workspace binary request failed"
|
||||
}
|
||||
};
|
||||
WorkspaceClientError::Request(message.to_string())
|
||||
})?;
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: response.status.as_u16(),
|
||||
body: response.body,
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_with_optional_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -481,6 +541,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
self.execute_with_optional_timeout(request, self.request_timeout)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary_with_optional_timeout(request, self.request_timeout)
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary_with_optional_timeout(request, Some(timeout))
|
||||
}
|
||||
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -789,6 +864,216 @@ mod tests {
|
||||
request_body_digest, verify_worker_mutation_source_proof,
|
||||
};
|
||||
|
||||
fn binary_loopback(response: Vec<u8>) -> (String, std::thread::JoinHandle<(String, Vec<u8>)>) {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
let mut byte = [0];
|
||||
stream.read_exact(&mut byte).unwrap();
|
||||
head.push(byte[0]);
|
||||
assert!(head.len() < 16 * 1024, "request headers too large");
|
||||
}
|
||||
let head = String::from_utf8(head).unwrap();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut body = vec![0; length];
|
||||
stream.read_exact(&mut body).unwrap();
|
||||
// Oversize-response tests may close the connection before all bytes are sent.
|
||||
let _ = stream.write_all(&response);
|
||||
(head, body)
|
||||
});
|
||||
(base_url, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() {
|
||||
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
for (method, wire_method, body, reply) in [
|
||||
(
|
||||
WorkspaceRequestMethod::Get,
|
||||
"GET",
|
||||
None,
|
||||
vec![0, 255, 128, 13],
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"POST",
|
||||
Some(vec![0, 255, 128, 13, 10]),
|
||||
vec![255, 0],
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Put,
|
||||
"PUT",
|
||||
Some(vec![255; 16 * 1024 * 1024]),
|
||||
vec![0],
|
||||
),
|
||||
] {
|
||||
let path = "/api/w/workspace-a/drive/entries/a/content?offset=0&limit=65536&name=a%2Fb";
|
||||
let mut response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
reply.len()
|
||||
)
|
||||
.into_bytes();
|
||||
response.extend_from_slice(&reply);
|
||||
let (base_url, server) = binary_loopback(response);
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
|
||||
.with_runtime_request_source(&identity, "server-a");
|
||||
let result = client
|
||||
.execute_binary_with_timeout(
|
||||
WorkspaceBinaryRequest {
|
||||
method,
|
||||
path: path.into(),
|
||||
body: body.clone(),
|
||||
max_response_bytes: reply.len(),
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
assert_eq!(result.body, reply);
|
||||
let (head, received) = server.join().unwrap();
|
||||
assert_eq!(received, body.unwrap_or_default());
|
||||
assert!(head.starts_with(&format!("{wire_method} {path} HTTP/1.1\r\n")));
|
||||
let header = |name: &str| {
|
||||
head.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
key.eq_ignore_ascii_case(name).then(|| value.trim())
|
||||
})
|
||||
};
|
||||
assert_eq!(header("x-yoi-runtime-id"), Some("runtime-a"));
|
||||
assert_eq!(header("x-yoi-worker-id"), Some("worker-a"));
|
||||
assert_eq!(header("authorization"), None);
|
||||
if method != WorkspaceRequestMethod::Get {
|
||||
assert_eq!(header("content-type"), Some("application/octet-stream"));
|
||||
}
|
||||
let digest = request_body_digest(&received);
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: "runtime-a",
|
||||
audience: "server-a",
|
||||
workspace_id: "workspace-a",
|
||||
worker_id: Some("worker-a"),
|
||||
permission: WORKSPACE_REQUEST_PERMISSION,
|
||||
method: wire_method,
|
||||
path,
|
||||
body_digest: &digest,
|
||||
now_unix: unix_now_seconds() as i64,
|
||||
};
|
||||
let token = header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER).unwrap();
|
||||
verify_runtime_request_source(token, &identity.public_key, &expected).unwrap();
|
||||
let altered = RuntimeRequestSourceExpectation {
|
||||
body_digest: &request_body_digest(b"altered"),
|
||||
..expected.clone()
|
||||
};
|
||||
assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err());
|
||||
let altered = RuntimeRequestSourceExpectation {
|
||||
path: "/api/w/workspace-a/drive/entries/a/content?offset=1",
|
||||
..expected
|
||||
};
|
||||
assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_responses_enforce_declared_and_streamed_limits_with_fixed_errors() {
|
||||
for response in [
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nabcde".to_vec(),
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nab\r\n3\r\ncde\r\n0\r\n\r\n".to_vec(),
|
||||
b"HTTP/1.1 500 Error\r\nConnection: close\r\n\r\nabcde".to_vec(),
|
||||
] {
|
||||
let (base_url, server) = binary_loopback(response);
|
||||
let client = RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
|
||||
let error = client.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: "/api/w/workspace-a/drive/entries/a/content?private=secret".into(),
|
||||
body: None, max_response_bytes: 4,
|
||||
}).unwrap_err();
|
||||
server.join().unwrap();
|
||||
assert_eq!(error.to_string(), "workspace request failed: Workspace binary response exceeded limit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_redirect_returns_status_without_forwarding_authentication() {
|
||||
let (base_url, server) = binary_loopback(b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:1/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec());
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
|
||||
let result = client
|
||||
.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Post,
|
||||
path: "/api/w/workspace-a/drive/upload".into(),
|
||||
body: Some(vec![0, 255]),
|
||||
max_response_bytes: 0,
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
assert_eq!(result.status, 307);
|
||||
assert!(result.body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_invalid_or_normalized_targets_fail_with_fixed_errors_before_http() {
|
||||
let client = RuntimeOwnedWorkspaceClient::new(
|
||||
"workspace-a",
|
||||
"http://127.0.0.1:1",
|
||||
"runtime-a",
|
||||
"worker-a",
|
||||
);
|
||||
for path in [
|
||||
"https://example.com/secret",
|
||||
"//example.com/secret",
|
||||
"/a/../secret",
|
||||
"/a#secret",
|
||||
"/a\\secret",
|
||||
"/a b",
|
||||
] {
|
||||
let error = client
|
||||
.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: path.into(),
|
||||
body: None,
|
||||
max_response_bytes: 4,
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workspace request failed: invalid Workspace binary request"
|
||||
);
|
||||
}
|
||||
let error = client
|
||||
.execute_binary_with_timeout(
|
||||
WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Post,
|
||||
path: "/secret?private=value".into(),
|
||||
body: Some(vec![255]),
|
||||
max_response_bytes: 4,
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workspace request failed: Workspace binary request failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_prompt_projection_uses_the_shared_runtime_cache_without_http() {
|
||||
let cache = Arc::new(WorkspacePromptProjectionCache::default());
|
||||
|
||||
@@ -255,7 +255,7 @@ impl RuntimeWorkspaceRequestClient {
|
||||
request: RuntimeWorkspaceRequest,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let client = self.clone();
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request))
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request, true))
|
||||
.join()
|
||||
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
|
||||
message: "Workspace request thread panicked".to_string(),
|
||||
@@ -263,15 +263,45 @@ impl RuntimeWorkspaceRequestClient {
|
||||
})?
|
||||
}
|
||||
|
||||
fn execute_blocking_inner(
|
||||
/// Binary requests must retain their exact target and never follow redirects.
|
||||
pub(crate) fn execute_binary_blocking(
|
||||
&self,
|
||||
request: RuntimeWorkspaceRequest,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let client = self.clone();
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request, false))
|
||||
.join()
|
||||
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
|
||||
message: "Workspace binary request thread panicked".to_string(),
|
||||
timeout: false,
|
||||
})?
|
||||
}
|
||||
|
||||
fn execute_blocking_inner(
|
||||
&self,
|
||||
request: RuntimeWorkspaceRequest,
|
||||
follow_redirects: bool,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let prepared = self.prepare(&request)?;
|
||||
if !follow_redirects {
|
||||
let mut target = prepared.url.path().to_string();
|
||||
if let Some(query) = prepared.url.query() {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
if prepared.url.fragment().is_some() || target != request.path_and_query {
|
||||
return Err(RuntimeWorkspaceRequestError::InvalidRequest(
|
||||
"binary request target must be exact".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The blocking client defaults to 30 seconds. Apply None explicitly so
|
||||
// provider-owned command waits are not cut short by an HTTP deadline.
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(request.timeout)
|
||||
let mut client_builder = reqwest::blocking::Client::builder().timeout(request.timeout);
|
||||
if !follow_redirects {
|
||||
client_builder = client_builder.redirect(reqwest::redirect::Policy::none());
|
||||
}
|
||||
let client = client_builder
|
||||
.build()
|
||||
.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||
let mut builder = client
|
||||
|
||||
@@ -65,9 +65,9 @@ pub use session_history::{
|
||||
pub use shared_state::WorkerSharedState;
|
||||
pub use worker::{
|
||||
LocalWorkingDirectory, SubjektivSessionAttributionLifecycle, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution,
|
||||
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation,
|
||||
WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, marker_workspace_client,
|
||||
unavailable_workspace_client,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest,
|
||||
WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
|
||||
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest,
|
||||
marker_workspace_client, unavailable_workspace_client,
|
||||
};
|
||||
|
||||
+327
-18
@@ -14,7 +14,9 @@ use std::sync::{Arc, Mutex};
|
||||
use agen::Engine;
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use agen::state::Mutable;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use agen::tool::{
|
||||
Attachment, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
@@ -99,8 +101,8 @@ pub struct WipCallContext {
|
||||
pub security_context: String,
|
||||
}
|
||||
|
||||
/// Successful operation value. Compatibility projections can retain an ordinary
|
||||
/// ToolOutput so images and pruning semantics remain on the existing Engine path.
|
||||
/// Successful operation value. Native attachments and compatibility ToolOutputs
|
||||
/// use the existing Engine side channel, separate from the validated WIP value.
|
||||
pub struct WipOperationOutput {
|
||||
pub value: Value,
|
||||
tool_output: Option<ToolOutput>,
|
||||
@@ -125,6 +127,20 @@ impl WipOperationOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach model-visible binary content without replacing the native result
|
||||
/// or its exact post-operation validator. Invoke returns these attachments
|
||||
/// through ToolOutput so Engine capture/history can persist their bytes.
|
||||
pub fn with_attachments(mut self, attachments: Vec<Attachment>) -> Self {
|
||||
self.tool_output
|
||||
.get_or_insert_with(|| ToolOutput {
|
||||
summary: String::new(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
.attachments = attachments;
|
||||
self
|
||||
}
|
||||
|
||||
fn compatibility(value: Value, output: ToolOutput) -> Self {
|
||||
Self {
|
||||
value,
|
||||
@@ -134,6 +150,13 @@ impl WipOperationOutput {
|
||||
}
|
||||
}
|
||||
|
||||
enum WipToolOutput {
|
||||
/// Only binary attachments accompany the validated native completion.
|
||||
Native(ToolOutput),
|
||||
/// Existing compatibility/error output retains its summary and pruning policy.
|
||||
Passthrough(Result<ToolOutput, ToolError>),
|
||||
}
|
||||
|
||||
pub enum WipOperationError {
|
||||
Protocol(ProtocolError),
|
||||
Cancelled(ToolOutput),
|
||||
@@ -2227,7 +2250,10 @@ impl WipRuntime {
|
||||
guard.disarm();
|
||||
let completion = completion.map_err(client_tool_error)?;
|
||||
self.push_audit(prepared.id, path, operation, audit_outcome);
|
||||
if let Some(result) = passthrough {
|
||||
let mut attachments = Vec::new();
|
||||
if let Some(passthrough) = passthrough {
|
||||
match passthrough {
|
||||
WipToolOutput::Passthrough(result) => {
|
||||
if result.is_ok() {
|
||||
self.metrics
|
||||
.operation_successes
|
||||
@@ -2235,7 +2261,11 @@ impl WipRuntime {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
render_call_completion(completion).map(|output| {
|
||||
WipToolOutput::Native(output) => attachments = output.attachments,
|
||||
}
|
||||
}
|
||||
render_call_completion(completion).map(|mut output| {
|
||||
output.attachments = attachments;
|
||||
self.metrics
|
||||
.operation_successes
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -2338,14 +2368,7 @@ impl WipRuntime {
|
||||
&self,
|
||||
request: &Request<Vec<u8>>,
|
||||
execution: ToolExecutionContext,
|
||||
) -> Result<
|
||||
(
|
||||
Response<Vec<u8>>,
|
||||
Option<Result<ToolOutput, ToolError>>,
|
||||
WipAuditOutcome,
|
||||
),
|
||||
ToolError,
|
||||
> {
|
||||
) -> Result<(Response<Vec<u8>>, Option<WipToolOutput>, WipAuditOutcome), ToolError> {
|
||||
let rejected = |error: ProtocolError| -> Result<_, ToolError> {
|
||||
Ok((
|
||||
encode_protocol_error_response(
|
||||
@@ -2385,6 +2408,7 @@ impl WipRuntime {
|
||||
return rejected(error);
|
||||
}
|
||||
let projection = projection.projection;
|
||||
let kind = projection.kind;
|
||||
let descriptor = projection.descriptor.clone();
|
||||
let target_validator = projection.object.validator.clone();
|
||||
let request_value =
|
||||
@@ -2443,8 +2467,10 @@ impl WipRuntime {
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||
return Ok((
|
||||
response,
|
||||
Some(Err(ToolError::ExecutionFailed(format!(
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::ExecutionFailed(
|
||||
format!(
|
||||
"WIP operation outcome unknown; do not retry automatically: {message}"
|
||||
),
|
||||
)))),
|
||||
WipAuditOutcome::OutcomeUnknown,
|
||||
));
|
||||
@@ -2452,7 +2478,10 @@ impl WipRuntime {
|
||||
};
|
||||
Ok((
|
||||
response,
|
||||
output.tool_output.map(Ok),
|
||||
output.tool_output.map(|output| match kind {
|
||||
WipProjectionKind::Native => WipToolOutput::Native(output),
|
||||
WipProjectionKind::Compatibility => WipToolOutput::Passthrough(Ok(output)),
|
||||
}),
|
||||
WipAuditOutcome::Success,
|
||||
))
|
||||
}
|
||||
@@ -2477,7 +2506,9 @@ impl WipRuntime {
|
||||
.map_err(|encode| ToolError::Internal(encode.to_string()))?;
|
||||
Ok((
|
||||
response,
|
||||
Some(Err(ToolError::Cancelled(output))),
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::Cancelled(
|
||||
output,
|
||||
)))),
|
||||
WipAuditOutcome::Cancelled,
|
||||
))
|
||||
}
|
||||
@@ -2493,7 +2524,9 @@ impl WipRuntime {
|
||||
.map_err(|encode| ToolError::Internal(encode.to_string()))?;
|
||||
Ok((
|
||||
response,
|
||||
Some(Err(ToolError::Interrupted(output))),
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::Interrupted(
|
||||
output,
|
||||
)))),
|
||||
WipAuditOutcome::Interrupted,
|
||||
))
|
||||
}
|
||||
@@ -2506,8 +2539,10 @@ impl WipRuntime {
|
||||
.map_err(|encode| ToolError::Internal(encode.to_string()))?;
|
||||
Ok((
|
||||
response,
|
||||
Some(Err(ToolError::ExecutionFailed(format!(
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::ExecutionFailed(
|
||||
format!(
|
||||
"WIP operation outcome unknown; do not retry automatically: {message}"
|
||||
),
|
||||
)))),
|
||||
WipAuditOutcome::OutcomeUnknown,
|
||||
))
|
||||
@@ -5072,6 +5107,280 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeImage {
|
||||
invalid_result: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for NativeImage {
|
||||
async fn call(
|
||||
&self,
|
||||
_operation: &str,
|
||||
_arguments: &BTreeMap<String, Value>,
|
||||
_context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let value = if self.invalid_result {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::String("Attached native image".into())
|
||||
};
|
||||
Ok(
|
||||
WipOperationOutput::native_with_validator(value, vec![7]).with_attachments(vec![
|
||||
Attachment::Image(agen::tool::ImageAttachment::new(
|
||||
"image/png",
|
||||
include_bytes!("../../tools/tests/fixtures/view-image/valid.png").to_vec(),
|
||||
)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn native_image_registry(invalid_result: bool) -> WipMountRegistry {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("native", "native").unwrap();
|
||||
registry
|
||||
.mount(WipProjection {
|
||||
route: "/native/image".into(),
|
||||
capability: "native:image".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "image".into(),
|
||||
description: None,
|
||||
interfaces: vec![root_reference("yoi.native/image/v1")],
|
||||
r#ref: Some("native:image".into()),
|
||||
validator: Some(vec![1]),
|
||||
},
|
||||
interface: root_reference("yoi.native/image/v1"),
|
||||
descriptor: InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: Vec::new(),
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "view".into(),
|
||||
documentation: None,
|
||||
parameters: Vec::new(),
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
}],
|
||||
},
|
||||
interface_validator: Some(vec![1]),
|
||||
handler: Arc::new(NativeImage { invalid_result }),
|
||||
})
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn native_image_invoke() -> String {
|
||||
json!({
|
||||
"path":"/native/image",
|
||||
"interface":{"scope":"/","name":"yoi.native/image/v1"},
|
||||
"operation":"view",
|
||||
"arguments":{}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_invoke_returns_image_bytes_and_preserves_result_and_validator() {
|
||||
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
let runtime = install_wip_mode_with_mounts(
|
||||
&mut engine,
|
||||
None,
|
||||
"worker-a".into(),
|
||||
native_image_registry(false),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = engine.tool_server_handle();
|
||||
tools.flush_pending();
|
||||
tools
|
||||
.call_tool(
|
||||
"Inspect",
|
||||
r#"{"path":"/native/image"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let output = tools
|
||||
.call_tool(
|
||||
"Invoke",
|
||||
&native_image_invoke(),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.summary, "WIP operation completed");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Json>(output.content.as_deref().unwrap()).unwrap(),
|
||||
json!("Attached native image")
|
||||
);
|
||||
let [Attachment::Image(image)] = output.attachments.as_slice() else {
|
||||
panic!("Invoke must return an image, not a URL")
|
||||
};
|
||||
assert_eq!(image.mime_type(), "image/png");
|
||||
assert_eq!(
|
||||
image.data(),
|
||||
include_bytes!("../../tools/tests/fixtures/view-image/valid.png")
|
||||
);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let calls = state.client.call_history(&state.session).unwrap();
|
||||
assert!(
|
||||
matches!(&calls.back().unwrap().outcome, CallOutcome::Success(response) if response.result == Value::String("Attached native image".into()) && response.validator == Some(vec![7]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_attachments_do_not_bypass_return_value_validation() {
|
||||
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
install_wip_mode_with_mounts(
|
||||
&mut engine,
|
||||
None,
|
||||
"worker-a".into(),
|
||||
native_image_registry(true),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = engine.tool_server_handle();
|
||||
tools.flush_pending();
|
||||
tools
|
||||
.call_tool(
|
||||
"Inspect",
|
||||
r#"{"path":"/native/image"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = tools
|
||||
.call_tool(
|
||||
"Invoke",
|
||||
&native_image_invoke(),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("outcome unknown"), "{error}");
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NativeImageClient {
|
||||
events: Arc<Mutex<VecDeque<Vec<agen::llm_client::event::Event>>>>,
|
||||
requests: Arc<Mutex<Vec<LlmRequest>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for NativeImageClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(&self, request: LlmRequest) -> Result<ResponseStream, ClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let events = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected model request");
|
||||
Ok(Box::pin(stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_invoke_image_survives_engine_capture_and_durable_history_replay() {
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
let completed = || {
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})
|
||||
};
|
||||
let client = NativeImageClient {
|
||||
events: Arc::new(Mutex::new(VecDeque::from([
|
||||
vec![
|
||||
Event::tool_use_start(0, "native-image-call", "Invoke"),
|
||||
Event::tool_input_delta(0, &native_image_invoke()),
|
||||
Event::tool_use_stop(0),
|
||||
completed(),
|
||||
],
|
||||
vec![completed()],
|
||||
]))),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let mut engine = Engine::new(client.clone());
|
||||
install_wip_mode_with_mounts(
|
||||
&mut engine,
|
||||
None,
|
||||
"worker-a".into(),
|
||||
native_image_registry(false),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = engine.tool_server_handle();
|
||||
tools.flush_pending();
|
||||
tools
|
||||
.call_tool(
|
||||
"Inspect",
|
||||
r#"{"path":"/native/image"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&captured);
|
||||
engine.on_history_append(move |item| {
|
||||
sink.lock().unwrap().push(item.clone());
|
||||
Ok(())
|
||||
});
|
||||
let mut history = agen::History::new();
|
||||
assert!(matches!(
|
||||
engine
|
||||
.run(&mut history, "view the native image")
|
||||
.await
|
||||
.result,
|
||||
agen::EngineRunExit::Finished
|
||||
));
|
||||
let saved = history.items().find(|item| matches!(item, agen::Item::ToolResult { call_id, .. } if call_id == "native-image-call")).unwrap();
|
||||
let agen::Item::ToolResult {
|
||||
attachments,
|
||||
is_error,
|
||||
content,
|
||||
..
|
||||
} = saved
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(!is_error);
|
||||
let [Attachment::Image(image)] = attachments.as_slice() else {
|
||||
panic!("durable Invoke result lost image")
|
||||
};
|
||||
assert_eq!(
|
||||
image.data(),
|
||||
include_bytes!("../../tools/tests/fixtures/view-image/valid.png")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Json>(content.as_deref().unwrap()).unwrap(),
|
||||
json!("Attached native image")
|
||||
);
|
||||
assert!(captured.lock().unwrap().iter().any(|item| item == saved));
|
||||
assert!(
|
||||
client.requests.lock().unwrap()[1]
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item == saved)
|
||||
);
|
||||
let log = session_store::LogEntry::AnnotatedToolResult {
|
||||
ts: 1,
|
||||
entry: session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::from(saved),
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
},
|
||||
};
|
||||
let serialized = serde_json::to_string(&log).unwrap();
|
||||
let restored: session_store::LogEntry = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(&session_store::collect_state(&[restored]).history[0], saved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end() {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
|
||||
@@ -639,6 +639,27 @@ impl WorkspaceRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw-byte request through a host-bound Workspace client, separate from JSON requests.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceBinaryRequest {
|
||||
pub method: WorkspaceRequestMethod,
|
||||
pub path: String,
|
||||
pub body: Option<Vec<u8>>,
|
||||
pub max_response_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceBinaryResponse {
|
||||
pub status: u16,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WorkspaceBinaryResponse {
|
||||
pub fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkspaceRequestMethod {
|
||||
Get,
|
||||
@@ -859,6 +880,24 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
self.execute(request)
|
||||
}
|
||||
|
||||
/// Binary transport is unavailable unless a host-bound implementation opts in.
|
||||
fn execute_binary(
|
||||
&self,
|
||||
_request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"Workspace binary transport is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
_timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary(request)
|
||||
}
|
||||
|
||||
fn execute_server_operation(
|
||||
&self,
|
||||
operation: WorkspaceServerOperation,
|
||||
@@ -961,6 +1000,31 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
self.inner.current_prompt_projection(minimum_revision)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
if request.method != WorkspaceRequestMethod::Get {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"Reviewer child binary Workspace authority is read-only".to_string(),
|
||||
));
|
||||
}
|
||||
self.inner.execute_binary(request)
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
if request.method != WorkspaceRequestMethod::Get {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"Reviewer child binary Workspace authority is read-only".to_string(),
|
||||
));
|
||||
}
|
||||
self.inner.execute_binary_with_timeout(request, timeout)
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
mut request: WorkspaceRequest,
|
||||
@@ -1157,6 +1221,123 @@ mod reviewer_client_tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_transport_defaults_to_denial_without_json_fallback() {
|
||||
let client = RecordingWorkspaceClient::default();
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: "/api/w/ws/drive/entries/a/content".into(),
|
||||
body: None,
|
||||
max_response_bytes: 64 * 1024,
|
||||
};
|
||||
assert!(matches!(
|
||||
client.execute_binary(request.clone()),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
client.execute_binary_with_timeout(request, Duration::from_secs(1)),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct BinaryRecordingClient {
|
||||
requests: Mutex<Vec<(WorkspaceBinaryRequest, Option<Duration>)>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for BinaryRecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("ws")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"binary-recording"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(&self, _: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
panic!("binary request must not use JSON fallback")
|
||||
}
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push((request, None));
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: 200,
|
||||
body: vec![0, 255],
|
||||
})
|
||||
}
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push((request, Some(timeout)));
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: 200,
|
||||
body: vec![0, 255],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_binary_get_preserves_request_limit_and_timeout() {
|
||||
let inner = Arc::new(BinaryRecordingClient::default());
|
||||
let client = reviewer_client(inner.clone());
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: "/api/w/ws/drive/entries/a/content?offset=2&limit=4".into(),
|
||||
body: None,
|
||||
max_response_bytes: 4,
|
||||
};
|
||||
assert_eq!(
|
||||
client.execute_binary(request.clone()).unwrap().body,
|
||||
vec![0, 255]
|
||||
);
|
||||
let timeout = Duration::from_secs(2);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_binary_with_timeout(request.clone(), timeout)
|
||||
.unwrap()
|
||||
.body,
|
||||
vec![0, 255]
|
||||
);
|
||||
assert_eq!(
|
||||
*inner.requests.lock().unwrap(),
|
||||
vec![(request.clone(), None), (request, Some(timeout))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_binary_mutations_are_denied_even_for_attested_review_path() {
|
||||
let inner = Arc::new(BinaryRecordingClient::default());
|
||||
let client = reviewer_client(inner.clone());
|
||||
for method in [
|
||||
WorkspaceRequestMethod::Post,
|
||||
WorkspaceRequestMethod::Put,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
] {
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method,
|
||||
path: "/api/w/ws/merge-requests/MR1/reviews".into(),
|
||||
body: Some(vec![0, 255]),
|
||||
max_response_bytes: 4,
|
||||
};
|
||||
assert!(matches!(
|
||||
client.execute_binary(request.clone()),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
client.execute_binary_with_timeout(request, Duration::from_secs(1)),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
}
|
||||
assert!(inner.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_child_client_allows_typed_ticket_reads() {
|
||||
let inner = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
Reference in New Issue
Block a user