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:
2026-10-09 03:01:14 +09:00
parent f9d4c97f1e
commit 8affa46bce
7 changed files with 1052 additions and 48 deletions
+289 -4
View File
@@ -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());
+34 -4
View File
@@ -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