Merge remote-tracking branch 'origin/develop' into work/T-659-console-history-pagination
This commit is contained in:
Generated
+3
@@ -6383,6 +6383,7 @@ dependencies = [
|
||||
name = "yoi-workspace-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -6410,6 +6411,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tools",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -6421,6 +6423,7 @@ dependencies = [
|
||||
"workdir",
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
"yoi",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ use thiserror::Error;
|
||||
|
||||
pub use glob::run_glob;
|
||||
pub use local::{
|
||||
resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_stat, run_write,
|
||||
resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_read_bytes,
|
||||
run_read_bytes_bounded, run_stat, run_write,
|
||||
};
|
||||
pub use operation::*;
|
||||
pub use search::run_grep;
|
||||
@@ -569,6 +570,103 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_byte_reads_preserve_binary_ranges_and_fence_versions() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let original = b"\x89PNG\r\n\x1a\n\0line\n\xfftail";
|
||||
std::fs::write(root.join("image.bin"), original).unwrap();
|
||||
let access = RootAccess(root.clone());
|
||||
let limits = BoundedReadLimits::new(1024, 8).unwrap();
|
||||
|
||||
let first = run_read_bytes_bounded(
|
||||
&root,
|
||||
ReadBytesRequest {
|
||||
path: FsPath::new("image.bin").unwrap(),
|
||||
offset: 0,
|
||||
max_bytes: 8,
|
||||
expected_hash: None,
|
||||
},
|
||||
&access,
|
||||
limits,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first.bytes, original[..8]);
|
||||
assert_eq!(first.total_bytes, original.len() as u64);
|
||||
assert!(!first.eof);
|
||||
|
||||
let second = run_read_bytes_bounded(
|
||||
&root,
|
||||
ReadBytesRequest {
|
||||
path: FsPath::new("image.bin").unwrap(),
|
||||
offset: 8,
|
||||
max_bytes: 8,
|
||||
expected_hash: Some(first.content_hash),
|
||||
},
|
||||
&access,
|
||||
limits,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(second.bytes, original[8..16]);
|
||||
assert_eq!(second.content_hash, first.content_hash);
|
||||
|
||||
std::fs::write(root.join("image.bin"), b"different bytes long!").unwrap();
|
||||
let error = run_read_bytes_bounded(
|
||||
&root,
|
||||
ReadBytesRequest {
|
||||
path: FsPath::new("image.bin").unwrap(),
|
||||
offset: 16,
|
||||
max_bytes: 8,
|
||||
expected_hash: Some(first.content_hash),
|
||||
},
|
||||
&access,
|
||||
limits,
|
||||
)
|
||||
.expect_err("a later chunk must not cross file versions");
|
||||
assert!(matches!(error, FsError::Conflict(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_byte_reads_reject_request_and_source_limit_overflow() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
std::fs::write(root.join("image.bin"), [0_u8; 17]).unwrap();
|
||||
let access = RootAccess(root.clone());
|
||||
let limits = BoundedReadLimits::new(16, 8).unwrap();
|
||||
|
||||
let response_error = run_read_bytes_bounded(
|
||||
&root,
|
||||
ReadBytesRequest {
|
||||
path: FsPath::new("image.bin").unwrap(),
|
||||
offset: 0,
|
||||
max_bytes: 9,
|
||||
expected_hash: None,
|
||||
},
|
||||
&access,
|
||||
limits,
|
||||
)
|
||||
.expect_err("requests above the response ceiling must be rejected");
|
||||
assert!(
|
||||
matches!(response_error, FsError::InvalidArgument(message) if message.contains("8 bytes"))
|
||||
);
|
||||
|
||||
let source_error = run_read_bytes_bounded(
|
||||
&root,
|
||||
ReadBytesRequest {
|
||||
path: FsPath::new("image.bin").unwrap(),
|
||||
offset: 0,
|
||||
max_bytes: 8,
|
||||
expected_hash: None,
|
||||
},
|
||||
&access,
|
||||
limits,
|
||||
)
|
||||
.expect_err("sources above the provider ceiling must be rejected");
|
||||
assert!(
|
||||
matches!(source_error, FsError::InvalidArgument(message) if message.contains("provider limit 16"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_stops_a_read_already_in_progress() {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
@@ -7,8 +7,9 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
|
||||
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult,
|
||||
StatRequest, StatResult, WriteRequest, WriteResult, direct_symlink,
|
||||
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
|
||||
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
direct_symlink,
|
||||
};
|
||||
|
||||
const READ_BUFFER_BYTES: usize = 16 * 1024;
|
||||
@@ -195,6 +196,137 @@ fn run_read_with_limits(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_read_bytes(
|
||||
root: &Path,
|
||||
request: ReadBytesRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<ReadBytesResult, FsError> {
|
||||
run_read_bytes_with_limits(root, request, access, None)
|
||||
}
|
||||
|
||||
pub fn run_read_bytes_bounded(
|
||||
root: &Path,
|
||||
request: ReadBytesRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
limits: BoundedReadLimits,
|
||||
) -> Result<ReadBytesResult, FsError> {
|
||||
run_read_bytes_with_limits(root, request, access, Some(limits))
|
||||
}
|
||||
|
||||
fn run_read_bytes_with_limits(
|
||||
root: &Path,
|
||||
request: ReadBytesRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
limits: Option<BoundedReadLimits>,
|
||||
) -> Result<ReadBytesResult, FsError> {
|
||||
let logical = request.path;
|
||||
if request.max_bytes == 0 {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"byte read max_bytes must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(limits) = limits
|
||||
&& request.max_bytes > limits.max_response_bytes
|
||||
{
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"byte read response limit is {} bytes",
|
||||
limits.max_response_bytes
|
||||
)));
|
||||
}
|
||||
|
||||
access
|
||||
.check_cancelled()
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
let path = resolve(root, &logical)?;
|
||||
let target = require_access(&path, &logical, access, false, false)?;
|
||||
let file = access
|
||||
.open_read_file(&path, &target)
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
let metadata = file.metadata().map_err(|error| map_io(&logical, error))?;
|
||||
if metadata.is_dir() {
|
||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"byte read source must be a regular file".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(limits) = limits
|
||||
&& metadata.len() > limits.max_source_bytes
|
||||
{
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"byte read source exceeds provider limit {}",
|
||||
limits.max_source_bytes
|
||||
)));
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(request.max_bytes.min(READ_BUFFER_BYTES));
|
||||
let mut source_bytes = 0_u64;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file);
|
||||
let mut buffer = [0_u8; READ_BUFFER_BYTES];
|
||||
loop {
|
||||
access
|
||||
.check_cancelled()
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
let read_limit = limits
|
||||
.map(|limits| {
|
||||
limits
|
||||
.max_source_bytes
|
||||
.saturating_sub(source_bytes)
|
||||
.saturating_add(1)
|
||||
.min(READ_BUFFER_BYTES as u64) as usize
|
||||
})
|
||||
.unwrap_or(READ_BUFFER_BYTES);
|
||||
let read = reader
|
||||
.read(&mut buffer[..read_limit])
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk_start = source_bytes;
|
||||
source_bytes = source_bytes.saturating_add(read as u64);
|
||||
if let Some(limits) = limits
|
||||
&& source_bytes > limits.max_source_bytes
|
||||
{
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"byte read source exceeds provider limit {}",
|
||||
limits.max_source_bytes
|
||||
)));
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
|
||||
if bytes.len() < request.max_bytes && source_bytes > request.offset {
|
||||
let start = request.offset.saturating_sub(chunk_start).min(read as u64) as usize;
|
||||
let retained = (request.max_bytes - bytes.len()).min(read - start);
|
||||
bytes.extend_from_slice(&buffer[start..start + retained]);
|
||||
}
|
||||
}
|
||||
|
||||
let content_hash: ContentHash = hasher.finalize().into();
|
||||
if request
|
||||
.expected_hash
|
||||
.is_some_and(|expected| expected != content_hash)
|
||||
{
|
||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||
}
|
||||
if request.offset > source_bytes {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"byte offset {} exceeds file size {source_bytes}",
|
||||
request.offset
|
||||
)));
|
||||
}
|
||||
let eof = request.offset.saturating_add(bytes.len() as u64) == source_bytes;
|
||||
Ok(ReadBytesResult {
|
||||
path: logical,
|
||||
bytes,
|
||||
offset: request.offset,
|
||||
total_bytes: source_bytes,
|
||||
content_hash,
|
||||
eof,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_write(
|
||||
root: &Path,
|
||||
request: WriteRequest,
|
||||
|
||||
@@ -207,6 +207,36 @@ pub struct ReadResult {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// Binary-safe byte range read with an optional whole-file version fence.
|
||||
///
|
||||
/// Unlike [`ReadRequest`], offsets and limits are bytes rather than lines. The
|
||||
/// provider still hashes the complete bounded source so callers can reject
|
||||
/// chunks from different file versions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReadBytesRequest {
|
||||
pub path: FsPath,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub offset: u64,
|
||||
#[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))]
|
||||
pub max_bytes: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_hash: Option<ContentHash>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReadBytesResult {
|
||||
pub path: FsPath,
|
||||
pub bytes: Vec<u8>,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub offset: u64,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub total_bytes: u64,
|
||||
pub content_hash: ContentHash,
|
||||
pub eof: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WriteRequest {
|
||||
|
||||
@@ -7,12 +7,14 @@ use agen::tool::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
/// Maximum image body accepted for one model request.
|
||||
pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
|
||||
/// One binary response remains within the External Workdir transport bound.
|
||||
const IMAGE_READ_CHUNK_BYTES: usize = 1024 * 1024;
|
||||
|
||||
const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \
|
||||
The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP. \
|
||||
@@ -47,42 +49,111 @@ impl Tool for ViewImageTool {
|
||||
workdir::WorkdirSessionCapability::Read,
|
||||
)?;
|
||||
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
|
||||
let result = selected
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: path.clone(),
|
||||
offset: 0,
|
||||
limit: usize::MAX,
|
||||
// The scoped provider enforces this cap while reading, rather
|
||||
// than allocating an unbounded binary body first.
|
||||
max_bytes: MAX_IMAGE_BYTES + 1,
|
||||
})
|
||||
let bytes = read_image_bytes(selected.session.as_ref(), path.clone())
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
if result.truncated || result.bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
|
||||
)));
|
||||
}
|
||||
let mime_type = detect_image_mime(&result.bytes).ok_or_else(|| {
|
||||
let mime_type = detect_image_mime(&bytes).ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"unsupported image; expected PNG, JPEG, GIF, or WebP bytes".to_string(),
|
||||
)
|
||||
})?;
|
||||
let bytes = result.bytes.len();
|
||||
let byte_count = bytes.len();
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Attached image {path} ({mime_type}, {bytes} bytes)"),
|
||||
summary: format!("Attached image {path} ({mime_type}, {byte_count} bytes)"),
|
||||
content: None,
|
||||
attachments: vec![Attachment::Image(ImageAttachment::new(
|
||||
mime_type,
|
||||
Arc::<[u8]>::from(result.bytes),
|
||||
Arc::<[u8]>::from(bytes),
|
||||
))],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_image_bytes(
|
||||
session: &dyn workdir::WorkdirSession,
|
||||
path: WorkdirPath,
|
||||
) -> Result<Vec<u8>, workdir::WorkdirError> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut offset = 0_u64;
|
||||
let mut expected_hash = None;
|
||||
let mut expected_total = None;
|
||||
|
||||
loop {
|
||||
let remaining = expected_total
|
||||
.map(|total: u64| total.saturating_sub(offset) as usize)
|
||||
.unwrap_or(IMAGE_READ_CHUNK_BYTES);
|
||||
let request_bytes = remaining.min(IMAGE_READ_CHUNK_BYTES).max(1);
|
||||
let result = session
|
||||
.read_bytes(ReadBytesRequest {
|
||||
path: path.clone(),
|
||||
offset,
|
||||
max_bytes: request_bytes,
|
||||
expected_hash,
|
||||
})
|
||||
.await?;
|
||||
|
||||
if result.path != path || result.offset != offset {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read returned a mismatched path or offset".to_string(),
|
||||
));
|
||||
}
|
||||
if result.bytes.len() > request_bytes {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read exceeded the requested response size".to_string(),
|
||||
));
|
||||
}
|
||||
let end = result
|
||||
.offset
|
||||
.checked_add(result.bytes.len() as u64)
|
||||
.ok_or_else(|| {
|
||||
workdir::WorkdirError::Transport(
|
||||
"bounded binary read returned an invalid byte range".to_string(),
|
||||
)
|
||||
})?;
|
||||
if end > result.total_bytes || result.eof != (end == result.total_bytes) {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read returned a truncated or invalid byte range".to_string(),
|
||||
));
|
||||
}
|
||||
if result.total_bytes > MAX_IMAGE_BYTES as u64 {
|
||||
return Err(workdir::WorkdirError::InvalidArgument(format!(
|
||||
"image exceeds the {MAX_IMAGE_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(),
|
||||
));
|
||||
}
|
||||
if expected_total.is_none() {
|
||||
bytes
|
||||
.try_reserve_exact(result.total_bytes as usize)
|
||||
.map_err(|_| workdir::WorkdirError::OperationFailed)?;
|
||||
expected_total = Some(result.total_bytes);
|
||||
expected_hash = Some(result.content_hash);
|
||||
}
|
||||
|
||||
if result.bytes.is_empty() && !result.eof {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read made no progress".to_string(),
|
||||
));
|
||||
}
|
||||
bytes.extend_from_slice(&result.bytes);
|
||||
offset = end;
|
||||
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(),
|
||||
));
|
||||
}
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view_image_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
routed_view_image_tool(crate::routing::singleton_router(session))
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 828 B |
Binary file not shown.
|
After Width: | Height: | Size: 222 B |
Binary file not shown.
|
After Width: | Height: | Size: 94 B |
Binary file not shown.
|
After Width: | Height: | Size: 64 B |
@@ -18,7 +18,9 @@ use tools::{
|
||||
Tracker, core_builtin_tools, routed_builtin_tools, routed_view_image_tool, view_image_tool,
|
||||
};
|
||||
use workdir::{
|
||||
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirPath, WorkdirSessionHandle,
|
||||
WorkdirSessionRouter, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
|
||||
WorkdirToolScopeRule,
|
||||
};
|
||||
|
||||
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||
@@ -94,6 +96,33 @@ fn setup_routed() -> (
|
||||
(left, right, spill, router, Registry::new(definitions))
|
||||
}
|
||||
|
||||
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
|
||||
let fixture = include_bytes!("fixtures/view-image/valid.gif");
|
||||
assert_eq!(
|
||||
fixture.last(),
|
||||
Some(&0x3b),
|
||||
"GIF fixture must end in trailer"
|
||||
);
|
||||
let mut bytes = fixture[..fixture.len() - 1].to_vec();
|
||||
bytes.extend_from_slice(&[0x21, 0xfe]);
|
||||
let mut remaining = target_len
|
||||
.checked_sub(bytes.len() + 2)
|
||||
.expect("target must leave room for comment terminator and GIF trailer");
|
||||
while remaining > 0 {
|
||||
let mut block = remaining.min(256);
|
||||
if remaining - block == 1 {
|
||||
block -= 1;
|
||||
}
|
||||
assert!(block >= 2);
|
||||
bytes.push((block - 1) as u8);
|
||||
bytes.extend(std::iter::repeat_n(b'x', block - 1));
|
||||
remaining -= block;
|
||||
}
|
||||
bytes.extend_from_slice(&[0, 0x3b]);
|
||||
assert_eq!(bytes.len(), target_len);
|
||||
bytes
|
||||
}
|
||||
|
||||
async fn call(tool: &Arc<dyn Tool>, input: serde_json::Value) -> agen::tool::ToolOutput {
|
||||
tool.execute(&input.to_string(), Default::default())
|
||||
.await
|
||||
@@ -399,6 +428,301 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||
router.close_all().await.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession,
|
||||
truncate_first_binary_response: bool,
|
||||
mutate_after_first_binary_response: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl workdir::WorkdirSession for ExternalValidatingSession {
|
||||
fn workdir(&self) -> &workdir::Workdir {
|
||||
self.inner.workdir()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> workdir::WorkdirSessionCapabilities {
|
||||
self.inner.capabilities()
|
||||
}
|
||||
|
||||
async fn stat(
|
||||
&self,
|
||||
request: workdir::StatRequest,
|
||||
) -> Result<workdir::StatResult, workdir::WorkdirError> {
|
||||
self.inner.stat(request).await
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
request: workdir::ReadRequest,
|
||||
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||
let operation = workdir::http::WorkdirSessionOperation::Read(request);
|
||||
let operation = workdir::external::ExternalWorkdirOperation::try_from(operation)
|
||||
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
|
||||
let workdir::http::WorkdirSessionOperation::Read(request) = operation.into_inner() else {
|
||||
unreachable!("validated Read changed operation kind")
|
||||
};
|
||||
self.inner.read(request).await
|
||||
}
|
||||
|
||||
async fn read_bytes(
|
||||
&self,
|
||||
request: workdir::ReadBytesRequest,
|
||||
) -> Result<workdir::ReadBytesResult, workdir::WorkdirError> {
|
||||
let operation = workdir::http::WorkdirSessionOperation::ReadBytes(request);
|
||||
let operation = workdir::external::ExternalWorkdirOperation::try_from(operation)
|
||||
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
|
||||
let workdir::http::WorkdirSessionOperation::ReadBytes(request) = operation.into_inner()
|
||||
else {
|
||||
unreachable!("validated ReadBytes changed operation kind")
|
||||
};
|
||||
let result = self.inner.read_bytes(request).await?;
|
||||
let result = workdir::external::ExternalWorkdirOperationResult::try_from(
|
||||
workdir::http::WorkdirSessionOperationResult::ReadBytes(result),
|
||||
)
|
||||
.map_err(workdir::WorkdirError::UnsupportedOperation)?;
|
||||
let workdir::http::WorkdirSessionOperationResult::ReadBytes(mut result) =
|
||||
result.into_inner()
|
||||
else {
|
||||
unreachable!("validated ReadBytes result changed operation kind")
|
||||
};
|
||||
if result.offset == 0 {
|
||||
if let Some(path) = &self.mutate_after_first_binary_response {
|
||||
std::fs::write(path, vec![b'z'; result.total_bytes as usize]).unwrap();
|
||||
}
|
||||
if self.truncate_first_binary_response {
|
||||
result.bytes.pop();
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn write(
|
||||
&self,
|
||||
request: workdir::WriteRequest,
|
||||
) -> Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||
self.inner.write(request).await
|
||||
}
|
||||
|
||||
async fn edit(
|
||||
&self,
|
||||
request: workdir::EditRequest,
|
||||
) -> Result<workdir::EditResult, workdir::WorkdirError> {
|
||||
self.inner.edit(request).await
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
request: workdir::ListRequest,
|
||||
) -> Result<workdir::ListResult, workdir::WorkdirError> {
|
||||
self.inner.list(request).await
|
||||
}
|
||||
|
||||
async fn glob(
|
||||
&self,
|
||||
request: workdir::GlobRequest,
|
||||
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||
self.inner.glob(request).await
|
||||
}
|
||||
|
||||
async fn grep(
|
||||
&self,
|
||||
request: workdir::GrepRequest,
|
||||
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||
self.inner.grep(request).await
|
||||
}
|
||||
|
||||
async fn start_command(
|
||||
&self,
|
||||
request: workdir::CommandRequest,
|
||||
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
self.inner.start_command(request).await
|
||||
}
|
||||
|
||||
async fn command_status(
|
||||
&self,
|
||||
handle: workdir::CommandHandle,
|
||||
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||
self.inner.command_status(handle).await
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
&self,
|
||||
request: workdir::CommandOutputRequest,
|
||||
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
self.inner.command_output(request).await
|
||||
}
|
||||
|
||||
async fn cancel_command(
|
||||
&self,
|
||||
handle: workdir::CommandHandle,
|
||||
) -> Result<(), workdir::WorkdirError> {
|
||||
self.inner.cancel_command(handle).await
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), workdir::WorkdirError> {
|
||||
self.inner.close().await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_view_image_uses_a_protocol_bounded_request() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
||||
truncate_first_binary_response: false,
|
||||
mutate_after_first_binary_response: None,
|
||||
});
|
||||
let png = include_bytes!("fixtures/view-image/valid.png");
|
||||
assert!(png.contains(&0));
|
||||
assert!(png.iter().any(|byte| *byte >= 0x80));
|
||||
std::fs::write(dir.path().join("image.png"), png).unwrap();
|
||||
let (_meta, tool) = view_image_tool(session)();
|
||||
|
||||
let output = call(&tool, json!({ "path": "image.png" })).await;
|
||||
let agen::tool::Attachment::Image(image) = &output.attachments[0];
|
||||
assert_eq!(image.mime_type(), "image/png");
|
||||
assert_eq!(image.data(), png);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let image_path = dir.path().join("image.png");
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap();
|
||||
let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()),
|
||||
truncate_first_binary_response: true,
|
||||
mutate_after_first_binary_response: None,
|
||||
});
|
||||
let (_meta, tool) = view_image_tool(truncated)();
|
||||
let error = call_err(&tool, json!({ "path": "image.png" }))
|
||||
.await
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("truncated") || error.contains("partial"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
let large = valid_gif_with_len(1024 * 1024 + 32);
|
||||
std::fs::write(&image_path, large).unwrap();
|
||||
let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
||||
truncate_first_binary_response: false,
|
||||
mutate_after_first_binary_response: Some(image_path),
|
||||
});
|
||||
let (_meta, tool) = view_image_tool(changing)();
|
||||
let error = call_err(&tool, json!({ "path": "image.png" }))
|
||||
.await
|
||||
.to_string();
|
||||
assert!(error.contains("changed"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() {
|
||||
const LIMIT: usize = 10 * 1024 * 1024;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let session: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let (_meta, tool) = view_image_tool(session)();
|
||||
let mut image = valid_gif_with_len(LIMIT);
|
||||
std::fs::write(dir.path().join("boundary.gif"), &image).unwrap();
|
||||
|
||||
let output = call(&tool, json!({ "path": "boundary.gif" })).await;
|
||||
let agen::tool::Attachment::Image(attachment) = &output.attachments[0];
|
||||
assert_eq!(attachment.mime_type(), "image/gif");
|
||||
assert_eq!(attachment.data(), image);
|
||||
|
||||
image.push(0);
|
||||
std::fs::write(dir.path().join("boundary.gif"), image).unwrap();
|
||||
let error = call_err(&tool, json!({ "path": "boundary.gif" }))
|
||||
.await
|
||||
.to_string();
|
||||
assert!(error.contains("10485760-byte limit"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn view_image_attaches_valid_supported_image_fixtures() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let session: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let (_meta, tool) = view_image_tool(session)();
|
||||
let fixtures: [(&str, &[u8], &str); 4] = [
|
||||
(
|
||||
"valid.png",
|
||||
include_bytes!("fixtures/view-image/valid.png"),
|
||||
"image/png",
|
||||
),
|
||||
(
|
||||
"valid.jpg",
|
||||
include_bytes!("fixtures/view-image/valid.jpg"),
|
||||
"image/jpeg",
|
||||
),
|
||||
(
|
||||
"valid.gif",
|
||||
include_bytes!("fixtures/view-image/valid.gif"),
|
||||
"image/gif",
|
||||
),
|
||||
(
|
||||
"valid.webp",
|
||||
include_bytes!("fixtures/view-image/valid.webp"),
|
||||
"image/webp",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, fixture, mime) in fixtures {
|
||||
std::fs::write(dir.path().join(name), fixture).unwrap();
|
||||
let output = call(&tool, json!({ "path": name })).await;
|
||||
let agen::tool::Attachment::Image(image) = &output.attachments[0];
|
||||
assert_eq!(image.mime_type(), mime);
|
||||
assert_eq!(image.data(), fixture);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegated_view_image_preserves_caller_relative_path_identity() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("docs")).unwrap();
|
||||
let fixture = include_bytes!("fixtures/view-image/valid.png");
|
||||
std::fs::write(dir.path().join("docs/image.png"), fixture).unwrap();
|
||||
let source: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
));
|
||||
let broker = WorkdirToolBroker::new(source);
|
||||
let lease = broker
|
||||
.scope(WorkdirToolScope {
|
||||
rules: vec![WorkdirToolScopeRule {
|
||||
target: WorkdirPath::new("docs").unwrap(),
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: WorkdirPath::new("docs").unwrap(),
|
||||
command: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let (_meta, tool) = view_image_tool(lease.tool_session())();
|
||||
|
||||
let output = call(&tool, json!({ "path": "image.png" })).await;
|
||||
assert!(output.summary.contains("image.png"));
|
||||
assert!(!output.summary.contains("docs/image.png"));
|
||||
let agen::tool::Attachment::Image(image) = &output.attachments[0];
|
||||
assert_eq!(image.data(), fixture);
|
||||
|
||||
lease.close().await.unwrap();
|
||||
broker.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -406,7 +730,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let session: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let png = b"\x89PNG\r\n\x1a\nprivate-image-body";
|
||||
let png = include_bytes!("fixtures/view-image/valid.png");
|
||||
std::fs::write(dir.path().join("image.png"), png).unwrap();
|
||||
let definition = view_image_tool(session);
|
||||
let (_meta, tool) = definition();
|
||||
@@ -417,7 +741,6 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||
assert_eq!(image.mime_type(), "image/png");
|
||||
assert_eq!(image.data(), png);
|
||||
let serialized = serde_json::to_string(&output).unwrap();
|
||||
assert!(!serialized.contains("private-image-body"));
|
||||
assert!(serialized.contains("attachments"));
|
||||
let restored: agen::tool::ToolOutput = serde_json::from_str(&serialized).unwrap();
|
||||
let agen::tool::Attachment::Image(restored_image) = &restored.attachments[0];
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
|
||||
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities};
|
||||
|
||||
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 1;
|
||||
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2;
|
||||
/// Maximum serialized Backend-to-provider command frame size.
|
||||
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
|
||||
/// Maximum serialized provider-to-Backend result frame size. Read bytes are
|
||||
@@ -155,6 +155,12 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
&& request.limit <= 1_000_000
|
||||
&& request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
}
|
||||
WorkdirSessionOperation::ReadBytes(request) => {
|
||||
is_root_relative(&request.path)
|
||||
&& request.offset <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes
|
||||
&& (1..=BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes)
|
||||
.contains(&request.max_bytes)
|
||||
}
|
||||
WorkdirSessionOperation::List(request) => {
|
||||
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
@@ -275,6 +281,15 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
||||
is_root_relative(&result.path)
|
||||
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
}
|
||||
WorkdirSessionOperationResult::ReadBytes(result) => {
|
||||
let returned_end = result.offset.checked_add(result.bytes.len() as u64);
|
||||
is_root_relative(&result.path)
|
||||
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
&& result.total_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes
|
||||
&& returned_end.is_some_and(|end| {
|
||||
end <= result.total_bytes && result.eof == (end == result.total_bytes)
|
||||
})
|
||||
}
|
||||
WorkdirSessionOperationResult::List(result) => {
|
||||
result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result
|
||||
@@ -482,8 +497,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
CommandRequest, EditRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath,
|
||||
WriteRequest,
|
||||
CommandRequest, EditRequest, GrepResult, ListRequest, ReadBytesRequest, ReadBytesResult,
|
||||
ReadRequest, Workdir, WorkdirPath, WriteRequest,
|
||||
};
|
||||
|
||||
fn assert_no_provider_authority(value: &Value) {
|
||||
@@ -614,7 +629,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
||||
let future = r#"{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
|
||||
@@ -726,10 +741,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_bounds_binary_read_requests_and_rejects_partial_results() {
|
||||
let path = WorkdirPath::new("image.png").unwrap();
|
||||
let bounded = WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
|
||||
path: path.clone(),
|
||||
offset: 0,
|
||||
max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes,
|
||||
expected_hash: None,
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(bounded).is_ok());
|
||||
|
||||
let oversized = WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
|
||||
path: path.clone(),
|
||||
offset: 0,
|
||||
max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1,
|
||||
expected_hash: None,
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(oversized).is_err());
|
||||
|
||||
let hash = [7; 32];
|
||||
let complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult {
|
||||
path: path.clone(),
|
||||
bytes: vec![1, 2, 3],
|
||||
offset: 0,
|
||||
total_bytes: 3,
|
||||
content_hash: hash,
|
||||
eof: true,
|
||||
});
|
||||
assert!(ExternalWorkdirOperationResult::try_from(complete).is_ok());
|
||||
|
||||
let partial_marked_complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult {
|
||||
path,
|
||||
bytes: vec![1, 2],
|
||||
offset: 0,
|
||||
total_bytes: 3,
|
||||
content_hash: hash,
|
||||
eof: true,
|
||||
});
|
||||
assert!(ExternalWorkdirOperationResult::try_from(partial_marked_complete).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
|
||||
let unknown = r#"{
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"message": {
|
||||
"kind": "heartbeat",
|
||||
"generation": 1,
|
||||
|
||||
@@ -11,9 +11,9 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
|
||||
WorkdirError, WorkdirId, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
|
||||
WorkdirSession, WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||
@@ -66,6 +66,7 @@ pub enum WorkdirSessionOperation {
|
||||
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||
Stat(StatRequest),
|
||||
Read(ReadRequest),
|
||||
ReadBytes(ReadBytesRequest),
|
||||
Write(WriteRequest),
|
||||
Edit(EditRequest),
|
||||
List(ListRequest),
|
||||
@@ -97,6 +98,7 @@ pub enum WorkdirSessionOperationResult {
|
||||
ScopeRulesOverlap { overlaps: bool },
|
||||
Stat(StatResult),
|
||||
Read(ReadResult),
|
||||
ReadBytes(ReadBytesResult),
|
||||
Write(WriteResult),
|
||||
Edit(EditResult),
|
||||
List(ListResult),
|
||||
@@ -135,6 +137,10 @@ pub async fn dispatch_workdir_session_operation(
|
||||
.read(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Read),
|
||||
WorkdirSessionOperation::ReadBytes(request) => session
|
||||
.read_bytes(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::ReadBytes),
|
||||
WorkdirSessionOperation::Write(request) => session
|
||||
.write(request)
|
||||
.await
|
||||
@@ -572,6 +578,19 @@ mod client {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_bytes(
|
||||
&self,
|
||||
request: ReadBytesRequest,
|
||||
) -> Result<ReadBytesResult, WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::ReadBytes(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::ReadBytes(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("read_bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::Write(request))
|
||||
|
||||
@@ -27,7 +27,8 @@ use tokio::sync::broadcast;
|
||||
pub use fs_operation::{
|
||||
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
||||
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
|
||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
|
||||
StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
pub use http::dispatch_workdir_session_operation;
|
||||
pub use local::{
|
||||
@@ -220,6 +221,14 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||
async fn read_bytes(
|
||||
&self,
|
||||
_request: ReadBytesRequest,
|
||||
) -> Result<ReadBytesResult, WorkdirError> {
|
||||
Err(WorkdirError::UnsupportedOperation(
|
||||
"bounded binary reads are not supported by this provider".to_string(),
|
||||
))
|
||||
}
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError>;
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError>;
|
||||
|
||||
@@ -30,10 +30,10 @@ use crate::{
|
||||
BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||
CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
||||
WriteResult,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
|
||||
WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest,
|
||||
WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
||||
WorkdirToolScopePermission, WriteRequest, WriteResult,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::{EntryKind, WriteOutcome};
|
||||
@@ -1261,6 +1261,21 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
let logical = request.path.clone();
|
||||
self.validate_operation_path(&request.path)?;
|
||||
let access = self.scope_access();
|
||||
match self.inner.read_limits {
|
||||
Some(limits) => {
|
||||
fs_operation::run_read_bytes_bounded(&self.inner.root, request, &access, limits)
|
||||
}
|
||||
None => fs_operation::run_read_bytes(&self.inner.root, request, &access),
|
||||
}
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Write)?;
|
||||
let logical = request.path.clone();
|
||||
|
||||
@@ -16,9 +16,10 @@ use tokio::sync::broadcast;
|
||||
use crate::{
|
||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||
CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
||||
GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
|
||||
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
GrepResult, ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirScopeAuthorizationRequest,
|
||||
WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle,
|
||||
WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Stable Worker-local routing key for one attached Workdir.
|
||||
@@ -257,6 +258,11 @@ impl WorkdirSession for RoutedWorkdirSession {
|
||||
self.state.session.read(request).await
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.read_bytes(request).await
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.write(request).await
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex, Weak};
|
||||
use async_trait::async_trait;
|
||||
use fs_operation::{
|
||||
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
|
||||
StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
use manifest::SymlinkPolicy;
|
||||
use schemars::JsonSchema;
|
||||
@@ -1011,6 +1012,21 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
self.source.read(request).await
|
||||
}
|
||||
|
||||
async fn read_bytes(
|
||||
&self,
|
||||
mut request: ReadBytesRequest,
|
||||
) -> Result<ReadBytesResult, WorkdirError> {
|
||||
let caller_path = request.path.clone();
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
request.path = path;
|
||||
let mut result = self.source.read_bytes(request).await?;
|
||||
result.path = caller_path;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
let _scope_guard = self.scope_lock.lock().await;
|
||||
let path = self
|
||||
@@ -1276,6 +1292,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
self.inner.read(request).await
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||
self.inner.read_bytes(request).await
|
||||
}
|
||||
|
||||
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
Err(WorkdirError::Denied("read-only workdir session".into()))
|
||||
}
|
||||
@@ -1656,6 +1676,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bytes(path: &str) -> ReadBytesRequest {
|
||||
ReadBytesRequest {
|
||||
path: fs_path(path),
|
||||
offset: 0,
|
||||
max_bytes: 1024,
|
||||
expected_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(path: &str, content: &str) -> WriteRequest {
|
||||
WriteRequest {
|
||||
path: fs_path(path),
|
||||
@@ -1887,6 +1916,9 @@ mod tests {
|
||||
child.read(read("readme.md")).await.unwrap().bytes,
|
||||
b"visible"
|
||||
);
|
||||
let binary = child.read_bytes(read_bytes("readme.md")).await.unwrap();
|
||||
assert_eq!(binary.path, fs_path("readme.md"));
|
||||
assert_eq!(binary.bytes, b"visible");
|
||||
assert!(matches!(
|
||||
child.write(write("new.md", "no")).await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
|
||||
@@ -18,8 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
|
||||
use workdir::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
|
||||
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
@@ -357,6 +357,13 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::ReadBytes(request))? {
|
||||
WorkdirSessionOperationResult::ReadBytes(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("read_bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Write(request))? {
|
||||
WorkdirSessionOperationResult::Write(result) => Ok(result),
|
||||
|
||||
@@ -50,7 +50,10 @@ zeroize.workspace = true
|
||||
webauthn-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
agen.workspace = true
|
||||
tempfile.workspace = true
|
||||
tools.workspace = true
|
||||
yoi = { path = "../yoi" }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock, Weak};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::ws::{CloseFrame, Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State};
|
||||
#[cfg(test)]
|
||||
use axum::http::header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH};
|
||||
@@ -1074,6 +1074,7 @@ fn external_workdir_operation_kind(operation: &WorkdirSessionOperation) -> &'sta
|
||||
WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap",
|
||||
WorkdirSessionOperation::Stat(_) => "stat",
|
||||
WorkdirSessionOperation::Read(_) => "read",
|
||||
WorkdirSessionOperation::ReadBytes(_) => "read_bytes",
|
||||
WorkdirSessionOperation::Write(_) => "write",
|
||||
WorkdirSessionOperation::Edit(_) => "edit",
|
||||
WorkdirSessionOperation::List(_) => "list",
|
||||
@@ -1141,6 +1142,19 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
_ => Err(Self::mismatch("read")),
|
||||
}
|
||||
}
|
||||
async fn read_bytes(
|
||||
&self,
|
||||
request: workdir::ReadBytesRequest,
|
||||
) -> std::result::Result<workdir::ReadBytesResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::ReadBytes(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::ReadBytes(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("read_bytes")),
|
||||
}
|
||||
}
|
||||
async fn write(
|
||||
&self,
|
||||
request: workdir::WriteRequest,
|
||||
@@ -14871,6 +14885,7 @@ async fn scoped_execute_current_worker_workdir_operation(
|
||||
| WorkdirSessionOperation::ScopeRulesOverlap(_)
|
||||
| WorkdirSessionOperation::Stat(_)
|
||||
| WorkdirSessionOperation::Read(_)
|
||||
| WorkdirSessionOperation::ReadBytes(_)
|
||||
| WorkdirSessionOperation::Write(_)
|
||||
| WorkdirSessionOperation::Edit(_)
|
||||
| WorkdirSessionOperation::List(_)
|
||||
@@ -14937,6 +14952,10 @@ async fn execute_workdir_session_operation(
|
||||
.read(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::Read),
|
||||
WorkdirSessionOperation::ReadBytes(request) => session
|
||||
.read_bytes(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::ReadBytes),
|
||||
WorkdirSessionOperation::Write(request) => session
|
||||
.write(request)
|
||||
.await
|
||||
@@ -17500,41 +17519,116 @@ async fn send_external_workdir_server_frame(
|
||||
)
|
||||
}
|
||||
|
||||
async fn reject_external_workdir_provider(socket: &mut WebSocket, code: u16, mut reason: String) {
|
||||
const MAX_CLOSE_REASON_BYTES: usize = 123;
|
||||
if reason.len() > MAX_CLOSE_REASON_BYTES {
|
||||
reason.truncate(MAX_CLOSE_REASON_BYTES);
|
||||
}
|
||||
let _ = socket
|
||||
.send(WsMessage::Close(Some(CloseFrame {
|
||||
code,
|
||||
reason: reason.into(),
|
||||
})))
|
||||
.await;
|
||||
}
|
||||
|
||||
fn external_workdir_registration_parse_error(text: &str) -> String {
|
||||
let actual_version = serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|value| value.get("version")?.as_u64());
|
||||
match actual_version {
|
||||
Some(actual)
|
||||
if actual != workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION as u64 =>
|
||||
{
|
||||
format!(
|
||||
"unsupported External Workdir protocol version {actual}; Backend requires {}",
|
||||
workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION
|
||||
)
|
||||
}
|
||||
_ => "invalid External Workdir provider registration".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_external_workdir_provider(
|
||||
api: WorkspaceApi,
|
||||
initial_grant: ExternalWorkdirGrantRecord,
|
||||
mut socket: WebSocket,
|
||||
) {
|
||||
let registration =
|
||||
let registration_text =
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), socket.next()).await {
|
||||
Ok(Some(Ok(WsMessage::Text(text)))) if text.len() <= 64 * 1024 => {
|
||||
serde_json::from_str::<ExternalWorkdirProviderFrame>(&text).ok()
|
||||
Ok(Some(Ok(WsMessage::Text(text)))) if text.len() <= 64 * 1024 => text,
|
||||
_ => {
|
||||
reject_external_workdir_provider(
|
||||
&mut socket,
|
||||
1008,
|
||||
"External Workdir provider registration was missing or oversized".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(ExternalWorkdirProviderFrame {
|
||||
let registration_frame =
|
||||
match serde_json::from_str::<ExternalWorkdirProviderFrame>(®istration_text) {
|
||||
Ok(frame) => frame,
|
||||
Err(_) => {
|
||||
let reason = external_workdir_registration_parse_error(®istration_text);
|
||||
reject_external_workdir_provider(&mut socket, 1002, reason).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ExternalWorkdirProviderFrame {
|
||||
message: ExternalWorkdirProviderMessage::Register { registration },
|
||||
..
|
||||
}) = registration
|
||||
} = registration_frame
|
||||
else {
|
||||
let _ = socket.close().await;
|
||||
reject_external_workdir_provider(
|
||||
&mut socket,
|
||||
1008,
|
||||
"expected External Workdir provider registration".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
let expected_capabilities = match external_workdir_capabilities(&initial_grant.permissions) {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(_) => {
|
||||
let _ = socket.close().await;
|
||||
reject_external_workdir_provider(
|
||||
&mut socket,
|
||||
1011,
|
||||
"Backend could not validate External Workdir capabilities".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if registration.read_limits != workdir::BoundedReadLimits::EXTERNAL_DEFAULT {
|
||||
let required = workdir::BoundedReadLimits::EXTERNAL_DEFAULT;
|
||||
reject_external_workdir_provider(
|
||||
&mut socket,
|
||||
1008,
|
||||
format!(
|
||||
"provider read limits source={} response={}; Backend requires source={} response={}",
|
||||
registration.read_limits.max_source_bytes,
|
||||
registration.read_limits.max_response_bytes,
|
||||
required.max_source_bytes,
|
||||
required.max_response_bytes,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if registration.grant_id.as_str() != initial_grant.grant_id
|
||||
|| registration.workdir_id.as_str() != initial_grant.workdir_id
|
||||
|| registration.provider_instance_id.as_str() != initial_grant.provider_instance_id
|
||||
|| registration.capabilities != expected_capabilities
|
||||
|| registration.read_limits != workdir::BoundedReadLimits::EXTERNAL_DEFAULT
|
||||
|| registration.generation == 0
|
||||
{
|
||||
let _ = socket.close().await;
|
||||
reject_external_workdir_provider(
|
||||
&mut socket,
|
||||
1008,
|
||||
"External Workdir provider registration does not match the grant".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
@@ -17689,7 +17783,7 @@ async fn serve_external_workdir_provider(
|
||||
Ok(operation) => operation,
|
||||
Err(message) => {
|
||||
let _ = response.send(Err(WorkdirTransportError {
|
||||
code: workdir::http::WorkdirTransportErrorCode::Unsupported,
|
||||
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
||||
message,
|
||||
}));
|
||||
continue;
|
||||
@@ -26501,6 +26595,26 @@ mod tests {
|
||||
WorkspaceRuntimeBinding,
|
||||
};
|
||||
|
||||
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
|
||||
let fixture = include_bytes!("../../tools/tests/fixtures/view-image/valid.gif");
|
||||
assert_eq!(fixture.last(), Some(&0x3b));
|
||||
let mut bytes = fixture[..fixture.len() - 1].to_vec();
|
||||
bytes.extend_from_slice(&[0x21, 0xfe]);
|
||||
let mut remaining = target_len.checked_sub(bytes.len() + 2).unwrap();
|
||||
while remaining > 0 {
|
||||
let mut block = remaining.min(256);
|
||||
if remaining - block == 1 {
|
||||
block -= 1;
|
||||
}
|
||||
bytes.push((block - 1) as u8);
|
||||
bytes.extend(std::iter::repeat_n(b'x', block - 1));
|
||||
remaining -= block;
|
||||
}
|
||||
bytes.extend_from_slice(&[0, 0x3b]);
|
||||
assert_eq!(bytes.len(), target_len);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_permission_levels_map_to_existing_bits_and_reject_legacy_partial_grants() {
|
||||
let bits = |capabilities| {
|
||||
@@ -27977,7 +28091,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_provider_websocket_routes_files_and_commands_then_reconnects_and_revokes() {
|
||||
async fn external_provider_websocket_runs_production_cli_pump_for_images_and_revoke() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
@@ -28046,6 +28160,50 @@ mod tests {
|
||||
})
|
||||
};
|
||||
|
||||
let (mut old_provider, _) = connect_async(&url).await.unwrap();
|
||||
let old_registration = serde_json::to_string(®ister(1)).unwrap().replacen(
|
||||
r#""version":2"#,
|
||||
r#""version":1"#,
|
||||
1,
|
||||
);
|
||||
old_provider
|
||||
.send(Message::Text(old_registration.into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let rejected = old_provider.next().await.unwrap().unwrap();
|
||||
let Message::Close(Some(frame)) = rejected else {
|
||||
panic!("old provider must receive a bounded compatibility close reason");
|
||||
};
|
||||
assert!(
|
||||
frame
|
||||
.reason
|
||||
.contains("unsupported External Workdir protocol version 1")
|
||||
);
|
||||
assert!(frame.reason.contains("requires 2"));
|
||||
|
||||
let (mut smaller_provider, _) = connect_async(&url).await.unwrap();
|
||||
let mut smaller_registration = serde_json::to_value(register(1)).unwrap();
|
||||
smaller_registration["message"]["registration"]["read_limits"] = serde_json::json!({
|
||||
"max_source_bytes": 32 * 1024 * 1024,
|
||||
"max_response_bytes": 512 * 1024,
|
||||
});
|
||||
smaller_provider
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&smaller_registration).unwrap().into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let rejected = smaller_provider.next().await.unwrap().unwrap();
|
||||
let Message::Close(Some(frame)) = rejected else {
|
||||
panic!("smaller provider limits must receive the required effective limits");
|
||||
};
|
||||
assert!(frame.reason.contains("response=524288"), "{}", frame.reason);
|
||||
assert!(
|
||||
frame.reason.contains("response=1048576"),
|
||||
"{}",
|
||||
frame.reason
|
||||
);
|
||||
|
||||
let (mut first, _) = connect_async(&url).await.unwrap();
|
||||
first
|
||||
.send(Message::Text(
|
||||
@@ -28073,20 +28231,49 @@ mod tests {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
let (mut second, _) = connect_async(&url).await.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(®ister(2)).unwrap().into(),
|
||||
))
|
||||
let provider_root = tempfile::tempdir().unwrap();
|
||||
fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap();
|
||||
let image_bytes = valid_gif_with_len(1024 * 1024 + 257);
|
||||
fs::write(provider_root.path().join("image.gif"), &image_bytes).unwrap();
|
||||
let pinned_provider_root = workdir::ExternalWorkdirRoot::pin(provider_root.path()).unwrap();
|
||||
let local_session = workdir::LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
workdir::Workdir::new(&grant.working_directory_id),
|
||||
pinned_provider_root,
|
||||
workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
workdir::WorkdirSessionCapabilities::ALL,
|
||||
)
|
||||
.unwrap();
|
||||
let (second, _) = connect_async(&url).await.unwrap();
|
||||
let registration = register(2);
|
||||
let provider = tokio::spawn(async move {
|
||||
yoi::workdir_share::serve_external_workdir_provider_socket(
|
||||
second,
|
||||
registration,
|
||||
&local_session,
|
||||
2,
|
||||
std::future::pending::<std::result::Result<(), String>>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let registered = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ExternalWorkdirServerFrame>(®istered)
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if api
|
||||
.store
|
||||
.get_external_workdir_grant(TEST_WORKSPACE_ID, &grant.grant_id)
|
||||
.unwrap()
|
||||
.message,
|
||||
ExternalWorkdirServerMessage::Registered { generation: 2, .. }
|
||||
));
|
||||
.is_some_and(|stored| stored.status == "online")
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_external_workdir_grant(TEST_WORKSPACE_ID, &grant.grant_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.status,
|
||||
"online"
|
||||
);
|
||||
|
||||
api.store
|
||||
.upsert_workdir_registry(&WorkdirRegistryRecord {
|
||||
@@ -28154,16 +28341,6 @@ mod tests {
|
||||
external_capabilities,
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
let provider_root = tempfile::tempdir().unwrap();
|
||||
fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap();
|
||||
let pinned_provider_root = workdir::ExternalWorkdirRoot::pin(provider_root.path()).unwrap();
|
||||
let local_session = workdir::LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
workdir::Workdir::new(&grant.working_directory_id),
|
||||
pinned_provider_root,
|
||||
workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
workdir::WorkdirSessionCapabilities::ALL,
|
||||
)
|
||||
.unwrap();
|
||||
let session_lock = current_worker_session_lock(&api, &worker);
|
||||
let session_guard = session_lock.lock().await;
|
||||
let broker_session =
|
||||
@@ -28175,162 +28352,60 @@ mod tests {
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
drop(session_guard);
|
||||
let read = {
|
||||
let broker_session = broker_session.clone();
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.read(workdir::ReadRequest {
|
||||
path: workdir::WorkdirPath::new("events.jsonl").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.await
|
||||
let read_result = broker_session
|
||||
.read(workdir::ReadRequest {
|
||||
path: workdir::WorkdirPath::new("events.jsonl").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let read_result = read.await.unwrap().unwrap();
|
||||
assert_eq!(read_result.bytes, b"one\ntwo\n");
|
||||
|
||||
let edit = {
|
||||
let broker_session = broker_session.clone();
|
||||
let expected_hash = read_result.content_hash;
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.edit(workdir::EditRequest {
|
||||
path: workdir::WorkdirPath::new("events.jsonl").unwrap(),
|
||||
old_string: "two".to_string(),
|
||||
new_string: "changed".to_string(),
|
||||
replace_all: false,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::Edit(_)
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
let (_meta, view_image) = tools::view_image_tool(broker_session.clone())();
|
||||
let output = view_image
|
||||
.execute(
|
||||
r#"{"path":"image.gif"}"#,
|
||||
agen::tool::ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(edit.await.unwrap().unwrap().replacements, 1);
|
||||
let agen::tool::Attachment::Image(image) = &output.attachments[0];
|
||||
assert_eq!(image.mime_type(), "image/gif");
|
||||
assert_eq!(image.data(), image_bytes);
|
||||
|
||||
let write = {
|
||||
let broker_session = broker_session.clone();
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.write(workdir::WriteRequest {
|
||||
path: workdir::WorkdirPath::new("created.txt").unwrap(),
|
||||
content: b"created".to_vec(),
|
||||
expected_hash: None,
|
||||
})
|
||||
.await
|
||||
let error = view_image
|
||||
.execute(
|
||||
r#"{"path":"missing.gif"}"#,
|
||||
agen::tool::ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("not found"), "{error}");
|
||||
|
||||
let edit = broker_session
|
||||
.edit(workdir::EditRequest {
|
||||
path: workdir::WorkdirPath::new("events.jsonl").unwrap(),
|
||||
old_string: "two".to_string(),
|
||||
new_string: "changed".to_string(),
|
||||
replace_all: false,
|
||||
expected_hash: read_result.content_hash,
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::Write(_)
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(write.await.unwrap().unwrap().created);
|
||||
assert_eq!(edit.replacements, 1);
|
||||
|
||||
let write = broker_session
|
||||
.write(workdir::WriteRequest {
|
||||
path: workdir::WorkdirPath::new("created.txt").unwrap(),
|
||||
content: b"created".to_vec(),
|
||||
expected_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(write.created);
|
||||
assert_eq!(
|
||||
fs::read_to_string(provider_root.path().join("events.jsonl")).unwrap(),
|
||||
"one\nchanged\n"
|
||||
@@ -28339,64 +28414,17 @@ mod tests {
|
||||
fs::read_to_string(provider_root.path().join("created.txt")).unwrap(),
|
||||
"created"
|
||||
);
|
||||
let command_start = {
|
||||
let broker_session = broker_session.clone();
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "sleep 30 & child=$!; echo $child > child.pid; wait $child"
|
||||
.to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker/private/spill".into()),
|
||||
tool_call_id: Some("call-e2e".to_string()),
|
||||
})
|
||||
.await
|
||||
let command_handle = broker_session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "sleep 30 & child=$!; echo $child > child.pid; wait $child".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker/private/spill".into()),
|
||||
tool_call_id: Some("call-e2e".to_string()),
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandStart(workdir::CommandRequest {
|
||||
spill_dir: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let command_handle = command_start.await.unwrap().unwrap();
|
||||
|
||||
for _ in 0..100 {
|
||||
if provider_root.path().join("child.pid").is_file() {
|
||||
@@ -28406,162 +28434,25 @@ mod tests {
|
||||
}
|
||||
assert!(provider_root.path().join("child.pid").is_file());
|
||||
|
||||
let command_output = {
|
||||
let broker_session = broker_session.clone();
|
||||
let handle = command_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: false,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandOutput(workdir::CommandOutputRequest {
|
||||
let command_output = broker_session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: command_handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: false,
|
||||
..
|
||||
})
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let command_output = command_output.await.unwrap().unwrap();
|
||||
assert_eq!(command_output.status, workdir::CommandStatus::Running);
|
||||
assert_eq!(command_output.output_path, None);
|
||||
|
||||
let revoker = {
|
||||
let api = api.clone();
|
||||
let grant_id = grant.grant_id.clone();
|
||||
tokio::spawn(async move {
|
||||
scoped_revoke_external_workdir_grant(
|
||||
State(api),
|
||||
Extension(test_browser_request_actor()),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), grant_id)),
|
||||
)
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandCancel(_)
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandOutput(workdir::CommandOutputRequest {
|
||||
wait: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
let provider_result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
let Json(revoked) = scoped_revoke_external_workdir_grant(
|
||||
State(api.clone()),
|
||||
Extension(test_browser_request_actor()),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), grant.grant_id.clone())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
provider_result,
|
||||
WorkdirSessionOperationResult::CommandOutput(ref output)
|
||||
if output.status != workdir::CommandStatus::Running
|
||||
));
|
||||
let result = provider_result.try_into().unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let Json(revoked) = revoker.await.unwrap().unwrap();
|
||||
assert_eq!(revoked.status, "revoked");
|
||||
let active_links = api
|
||||
.store
|
||||
@@ -28573,27 +28464,12 @@ mod tests {
|
||||
assert_eq!(active_links.len(), 1);
|
||||
assert_eq!(active_links[0].alias, "repository");
|
||||
assert_eq!(active_links[0].workdir_id, "analysis-repository");
|
||||
let revoke = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if matches!(frame.message, ExternalWorkdirServerMessage::Revoke { .. }) {
|
||||
break frame;
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
revoke.message,
|
||||
ExternalWorkdirServerMessage::Revoke { generation: 2, .. }
|
||||
));
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::RevokeAcknowledged { generation: 2 },
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
let provider_end = tokio::time::timeout(std::time::Duration::from_secs(2), provider)
|
||||
.await
|
||||
.expect("production CLI provider pump did not acknowledge revocation")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(provider_end, yoi::workdir_share::ProviderEnd::Revoked);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod workdir_share;
|
||||
@@ -4,10 +4,11 @@ mod objective_cli;
|
||||
mod plugin_cli;
|
||||
mod session_cli;
|
||||
mod ticket_cli;
|
||||
mod workdir_share;
|
||||
mod worker_cleanup_cli;
|
||||
mod workspace_bootstrap;
|
||||
|
||||
use yoi::workdir_share;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
|
||||
@@ -25,7 +25,7 @@ use workdir::{
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WorkdirShareOptions {
|
||||
pub struct WorkdirShareOptions {
|
||||
pub path: PathBuf,
|
||||
pub workspace_id: Option<String>,
|
||||
pub backend_url: String,
|
||||
@@ -37,6 +37,39 @@ pub(crate) struct WorkdirShareOptions {
|
||||
|
||||
const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn validate_provider_registration(message: Message, generation: u64) -> Result<(), String> {
|
||||
let registered = match message {
|
||||
Message::Text(text) => text,
|
||||
Message::Close(Some(frame)) => {
|
||||
let reason = frame.reason.trim();
|
||||
return Err(if reason.is_empty() {
|
||||
"Backend rejected the External Workdir provider registration".to_string()
|
||||
} else {
|
||||
format!("Backend rejected the External Workdir provider registration: {reason}")
|
||||
});
|
||||
}
|
||||
Message::Close(None) => {
|
||||
return Err("Backend rejected the External Workdir provider registration".to_string());
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"Backend returned an invalid External Workdir registration frame".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(®istered)
|
||||
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
|
||||
if matches!(
|
||||
frame.message,
|
||||
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
|
||||
if registered_generation == generation
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Backend rejected the External Workdir provider registration".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation) -> Duration {
|
||||
if matches!(
|
||||
operation,
|
||||
@@ -49,7 +82,7 @@ fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProviderEnd {
|
||||
pub enum ProviderEnd {
|
||||
Disconnected,
|
||||
Revoked,
|
||||
Interrupted,
|
||||
@@ -188,7 +221,7 @@ struct ResolvedWorkdirShareOptions {
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_ttl(value: &str) -> Result<Duration, String> {
|
||||
pub fn parse_ttl(value: &str) -> Result<Duration, String> {
|
||||
let (number, multiplier) = if let Some(value) = value.strip_suffix('h') {
|
||||
(value, 60 * 60)
|
||||
} else if let Some(value) = value.strip_suffix('m') {
|
||||
@@ -424,7 +457,7 @@ async fn resolve_share_options(
|
||||
configure_interactively(options, &workspaces, &mut input, &mut output)
|
||||
}
|
||||
|
||||
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
pub async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
let Some(options) = resolve_share_options(options).await? else {
|
||||
eprintln!("External Workdir share cancelled; no grant was created.");
|
||||
return Ok(());
|
||||
@@ -636,7 +669,7 @@ async fn serve_provider_connection(
|
||||
.max_message_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES))
|
||||
.max_frame_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES));
|
||||
let connection = connect_async_with_config(request, Some(websocket_config), false);
|
||||
let (mut socket, _) = tokio::select! {
|
||||
let (socket, _) = tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
@@ -658,11 +691,40 @@ async fn serve_provider_connection(
|
||||
read_limits: BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
},
|
||||
});
|
||||
let end =
|
||||
serve_external_workdir_provider_socket(socket, registration, session, generation, async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))
|
||||
})
|
||||
.await?;
|
||||
if end == ProviderEnd::Interrupted {
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
}
|
||||
Ok(end)
|
||||
}
|
||||
|
||||
/// Run the production External Workdir provider protocol over an established WebSocket.
|
||||
///
|
||||
/// The CLI uses this pump after opening its authenticated Backend connection. Keeping the
|
||||
/// protocol pump separate lets composed tests exercise the same registration, operation,
|
||||
/// cancellation, and result-forwarding implementation without spawning a second CLI process.
|
||||
pub async fn serve_external_workdir_provider_socket<S, I>(
|
||||
mut socket: tokio_tungstenite::WebSocketStream<S>,
|
||||
registration: ExternalWorkdirProviderFrame,
|
||||
session: &LocalWorkdirSession,
|
||||
generation: u64,
|
||||
interrupt: I,
|
||||
) -> Result<ProviderEnd, String>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
I: std::future::Future<Output = Result<(), String>>,
|
||||
{
|
||||
tokio::pin!(interrupt);
|
||||
send_provider_frame(&mut socket, ®istration).await?;
|
||||
let registered = tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
signal = &mut interrupt => {
|
||||
signal?;
|
||||
return Ok(ProviderEnd::Interrupted);
|
||||
}
|
||||
registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => {
|
||||
@@ -672,18 +734,7 @@ async fn serve_provider_connection(
|
||||
.map_err(|error| format!("External Workdir provider registration failed: {error}"))?
|
||||
}
|
||||
};
|
||||
let Message::Text(registered) = registered else {
|
||||
return Err("Backend returned an invalid External Workdir registration frame".to_string());
|
||||
};
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(®istered)
|
||||
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
|
||||
if !matches!(
|
||||
frame.message,
|
||||
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
|
||||
if registered_generation == generation
|
||||
) {
|
||||
return Err("Backend rejected the External Workdir provider registration".to_string());
|
||||
}
|
||||
validate_provider_registration(registered, generation)?;
|
||||
println!("Connection: online (generation {generation})");
|
||||
|
||||
let (completion_sender, mut completions) =
|
||||
@@ -691,10 +742,9 @@ async fn serve_provider_connection(
|
||||
let mut operations = HashMap::<String, OperationTask>::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
signal = &mut interrupt => {
|
||||
signal?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
return Ok(ProviderEnd::Interrupted);
|
||||
}
|
||||
completion = completions.recv() => {
|
||||
@@ -903,7 +953,7 @@ async fn execute_operation(
|
||||
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
|
||||
Err(message) => ExternalWorkdirOperationOutcome::Failed {
|
||||
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
code: workdir::http::WorkdirTransportErrorCode::Unsupported,
|
||||
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
|
||||
message,
|
||||
}),
|
||||
},
|
||||
@@ -1047,6 +1097,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_registration_surfaces_backend_close_reason() {
|
||||
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
||||
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
||||
|
||||
let error = validate_provider_registration(
|
||||
Message::Close(Some(CloseFrame {
|
||||
code: CloseCode::Policy,
|
||||
reason: "provider read limits source=1024 response=512; Backend requires source=2048 response=1024".into(),
|
||||
})),
|
||||
1,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains("provider read limits"), "{error}");
|
||||
assert!(error.contains("Backend requires"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_share_defaults_to_first_workspace_read_only_and_unlimited() {
|
||||
let workspaces = [workspace("workspace-a", "Alpha")];
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||
|
||||
const port = Number(Deno.args[0]);
|
||||
const buildRoot = Deno.args[1];
|
||||
const sectionCount = Deno.args[2] === undefined ? 90 : Number(Deno.args[2]);
|
||||
if (
|
||||
!Number.isInteger(port) || !buildRoot || !Number.isInteger(sectionCount) || sectionCount < 3 ||
|
||||
sectionCount > 100
|
||||
) {
|
||||
throw new Error("usage: server.ts <port> <build-root> [section-count: 3..100]");
|
||||
}
|
||||
|
||||
const workspaceId = "memory-review";
|
||||
const paragraphs = Array.from(
|
||||
{ length: sectionCount },
|
||||
(_, index) =>
|
||||
`## Section ${
|
||||
index + 1
|
||||
}\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${
|
||||
index + 1
|
||||
}\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${
|
||||
index + 1
|
||||
}.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`,
|
||||
).join("\n\n");
|
||||
const bodyMd =
|
||||
`# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${
|
||||
"wide-value-".repeat(16)
|
||||
} | stable | keep scrolling local |\n\n##### Deep document heading\n\n###### Deepest document heading\n\n\`\`\`rust\nfn example() { println!("${
|
||||
"wide-code-".repeat(18)
|
||||
}"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n<img src=x onerror=alert(1)>\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`;
|
||||
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
async function staticResponse(pathname: string): Promise<Response> {
|
||||
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
||||
const normalized = normalize(relative);
|
||||
if (normalized.startsWith("..")) return new Response("not found", { status: 404 });
|
||||
let filePath = join(buildRoot, normalized);
|
||||
try {
|
||||
const stat = await Deno.stat(filePath);
|
||||
if (stat.isDirectory) filePath = join(filePath, "index.html");
|
||||
return new Response(await Deno.readFile(filePath), {
|
||||
headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" },
|
||||
});
|
||||
} catch {
|
||||
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
if (url.pathname === "/api/workspaces") {
|
||||
return json([{
|
||||
workspace_id: workspaceId,
|
||||
owner_account_id: "owner",
|
||||
display_name: "Memory Review",
|
||||
state: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
}]);
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/workspace`) {
|
||||
return json({
|
||||
workspace_id: workspaceId,
|
||||
display_name: "Memory Review",
|
||||
record_authority: "fixture",
|
||||
schema_version: 1,
|
||||
auth: {
|
||||
Passkey: {
|
||||
rp_id: "127.0.0.1",
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
public_base_url: `http://127.0.0.1:${port}`,
|
||||
cookie_name: "fixture",
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
manage_repositories: false,
|
||||
manage_secrets: false,
|
||||
manage_runtimes: false,
|
||||
delete_workspace: false,
|
||||
},
|
||||
extension_points: {
|
||||
store: "fixture",
|
||||
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/repositories`) {
|
||||
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/working-directories`) {
|
||||
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||
}
|
||||
if (url.pathname === `/api/w/${workspaceId}/memory`) {
|
||||
return json({
|
||||
body_md: bodyMd,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: new TextEncoder().encode(bodyMd).byteLength,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
});
|
||||
}
|
||||
if (
|
||||
url.pathname === `/api/w/${workspaceId}/protocol/ws` &&
|
||||
request.headers.get("upgrade") === "websocket"
|
||||
) {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
socket.onmessage = (event) => {
|
||||
const frame = JSON.parse(String(event.data));
|
||||
if (frame?.message?.method !== "subscribe_events") return;
|
||||
const requestId = frame.message.params.request_id;
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 1,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
payload: {
|
||||
request_id: requestId,
|
||||
subscription_id: "fixture-workers",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot_revision: 1,
|
||||
snapshot: { topic: "workers", data: { workers: [] } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
return response;
|
||||
}
|
||||
return await staticResponse(url.pathname);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
|
||||
const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||
const workspaceRoot = join(repositoryRoot, "web/workspace");
|
||||
const fixtureServer = join(
|
||||
repositoryRoot,
|
||||
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
|
||||
);
|
||||
const workspaceId = "memory-review";
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
async function waitForServer(url: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
try {
|
||||
if ((await fetch(url)).ok) return;
|
||||
} catch {
|
||||
// Retry until the bounded deadline while the owned server starts.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`fixture server did not become ready: ${url}`);
|
||||
}
|
||||
|
||||
async function mainScrollState(page: Page) {
|
||||
return await page.locator("main").evaluate((main) => ({
|
||||
scrollTop: main.scrollTop,
|
||||
scrollHeight: main.scrollHeight,
|
||||
clientHeight: main.clientHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
async function markerIsInsideMain(page: Page): Promise<boolean> {
|
||||
return await page.evaluate(() => {
|
||||
const document = (globalThis as any).document;
|
||||
const main = document.querySelector("main");
|
||||
const marker = [...document.querySelectorAll("h2")].find((heading) =>
|
||||
heading.textContent === "T-661 UNIQUE END MARKER"
|
||||
);
|
||||
if (!main || !marker) return false;
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
return markerRect.top >= mainRect.top && markerRect.bottom <= mainRect.bottom;
|
||||
});
|
||||
}
|
||||
|
||||
async function openMemoryPage(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
mobile = false,
|
||||
): Promise<void> {
|
||||
await page.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
const heading = page.locator("h1", { hasText: "Workspace Memory" });
|
||||
await heading.waitFor({ state: "attached" });
|
||||
if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click();
|
||||
await heading.waitFor();
|
||||
}
|
||||
|
||||
async function swipeMainToEnd(page: Page): Promise<void> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const state = await mainScrollState(page);
|
||||
if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return;
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: 300, y: 700, id: 1 }],
|
||||
});
|
||||
for (const y of [580, 460, 340, 220, 100]) {
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: 300, y, id: 1 }],
|
||||
});
|
||||
}
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
throw new Error("touch input did not reach the end of the Memory document");
|
||||
}
|
||||
|
||||
Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => {
|
||||
const build = await new Deno.Command(Deno.execPath(), {
|
||||
args: ["task", "build"],
|
||||
cwd: workspaceRoot,
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).output();
|
||||
if (!build.success) {
|
||||
throw new Error(
|
||||
`Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const server = new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
fixtureServer,
|
||||
String(port),
|
||||
join(workspaceRoot, "build"),
|
||||
],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).spawn();
|
||||
|
||||
try {
|
||||
await waitForServer(`${baseUrl}/health`);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (
|
||||
const scenario of [
|
||||
{ label: "wheel", width: 1440, height: 900, colorScheme: "light" as const },
|
||||
{ label: "keyboard", width: 768, height: 900, colorScheme: "dark" as const },
|
||||
{
|
||||
label: "touch",
|
||||
width: 390,
|
||||
height: 844,
|
||||
colorScheme: "light" as const,
|
||||
mobile: true,
|
||||
},
|
||||
]
|
||||
) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: scenario.width, height: scenario.height },
|
||||
colorScheme: scenario.colorScheme,
|
||||
hasTouch: scenario.mobile ?? false,
|
||||
isMobile: scenario.mobile ?? false,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await openMemoryPage(page, baseUrl, scenario.mobile);
|
||||
|
||||
const initial = await mainScrollState(page);
|
||||
assert(initial.scrollHeight > initial.clientHeight);
|
||||
assertEquals(await markerIsInsideMain(page), false);
|
||||
assertEquals(
|
||||
await page.evaluate(() => {
|
||||
const document = (globalThis as any).document;
|
||||
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
if (scenario.label === "wheel") {
|
||||
assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]);
|
||||
assertEquals(
|
||||
await page.locator("strong").filter({ hasText: "important context" }).count() > 0,
|
||||
true,
|
||||
);
|
||||
assertEquals(await page.locator("blockquote").count() > 0, true);
|
||||
assertEquals(await page.locator("ul li").count() > 0, true);
|
||||
for (
|
||||
const heading of [
|
||||
{ selector: "h5", text: "Deep document heading" },
|
||||
{ selector: "h6", text: "Deepest document heading" },
|
||||
]
|
||||
) {
|
||||
const element = page.locator(heading.selector, { hasText: heading.text });
|
||||
assertEquals(await element.count(), 1);
|
||||
assert(
|
||||
await element.evaluate((node) =>
|
||||
Number.parseFloat((globalThis as any).getComputedStyle(node).fontSize) >= 12
|
||||
),
|
||||
`${heading.selector} fell below the design language minimum text size`,
|
||||
);
|
||||
}
|
||||
assertEquals(await page.locator('a[href="https://example.com"]').count(), 1);
|
||||
assertEquals(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||
assertEquals(await page.locator("article img, article script").count(), 0);
|
||||
assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0);
|
||||
|
||||
for (const label of ["Markdown table", "rust code block"]) {
|
||||
const region = page.getByRole("region", { name: label });
|
||||
assertEquals(await region.getAttribute("tabindex"), "0");
|
||||
assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth));
|
||||
}
|
||||
|
||||
await page.locator("main").hover();
|
||||
await page.mouse.wheel(0, 100_000);
|
||||
} else if (scenario.label === "keyboard") {
|
||||
await page.getByText("Document details", { exact: true }).focus();
|
||||
await page.keyboard.press("End");
|
||||
} else {
|
||||
await swipeMainToEnd(page);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const afterInteraction = await mainScrollState(page);
|
||||
assert(
|
||||
afterInteraction.scrollTop > 0,
|
||||
`${scenario.label} did not move the main scroll owner: ${
|
||||
JSON.stringify(afterInteraction)
|
||||
}`,
|
||||
);
|
||||
assertEquals(await markerIsInsideMain(page), true);
|
||||
assertEquals(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const emptyPage = await stateContext.newPage();
|
||||
await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
body_md: " \n",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 2,
|
||||
record_source: "fixture",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
await emptyPage.getByText("Memory document is empty.").waitFor();
|
||||
assertEquals(await emptyPage.getByRole("alert").count(), 0);
|
||||
|
||||
const errorPage = await stateContext.newPage();
|
||||
await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => {
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
|
||||
});
|
||||
await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
const alert = errorPage.getByRole("alert");
|
||||
await alert.waitFor();
|
||||
assert((await alert.textContent())?.includes("Memory document unavailable."));
|
||||
assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0);
|
||||
await stateContext.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
server.kill("SIGTERM");
|
||||
await server.status;
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts"
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
|
||||
@@ -96,6 +96,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
|
||||
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
|
||||
- `src/lib/workspace/header/`: Header frame, context, and overrides.
|
||||
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
|
||||
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
|
||||
- `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`.
|
||||
- `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom.
|
||||
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown, {
|
||||
buildUnsupportedHTML,
|
||||
type Renderers,
|
||||
type SvelteMarkdownProps,
|
||||
} from "@humanspeak/svelte-markdown";
|
||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
|
||||
import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte";
|
||||
import SafeMarkdown from "$lib/workspace/markdown/SafeMarkdown.svelte";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
@@ -36,24 +32,15 @@
|
||||
|
||||
let { text, streamId = "static", class: className = "" }: Props = $props();
|
||||
|
||||
const options = {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const renderers = {
|
||||
code: ShikiCode,
|
||||
html: buildUnsupportedHTML(),
|
||||
link: MarkdownLink,
|
||||
} satisfies Partial<Renderers>;
|
||||
</script>
|
||||
|
||||
<div class={`rich-markdown ${className}`}>
|
||||
<SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
|
||||
</div>
|
||||
<SafeMarkdown {text} {streamId} class={`rich-markdown ${className}`} {renderers} streaming />
|
||||
|
||||
<style>
|
||||
.rich-markdown {
|
||||
:global(.rich-markdown) {
|
||||
color: inherit;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -496,11 +496,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
assert(
|
||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
memoryDocumentLoad.includes('"/memory"') &&
|
||||
memoryDocumentPage.includes("Memory Document") &&
|
||||
memoryDocumentPage.includes("This view is read-only") &&
|
||||
memoryDocumentPage.includes("DocumentMarkdown from") &&
|
||||
memoryDocumentPage.includes("Read-only") &&
|
||||
memoryDocumentPage.includes("data.memory.data.body_md") &&
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at"),
|
||||
"Memory Document page should read the scoped API and expose the durable document without mutation controls",
|
||||
memoryDocumentPage.includes("data.memory.data.updated_at") &&
|
||||
memoryDocumentPage.includes('role="alert"') &&
|
||||
!memoryDocumentPage.includes('<pre class="memory-document-body"'),
|
||||
"Memory Document page should read the scoped API and expose safe Markdown states without mutation controls",
|
||||
);
|
||||
assert(
|
||||
memoryStagingLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
lang = "",
|
||||
text,
|
||||
}: {
|
||||
lang?: string;
|
||||
text: string;
|
||||
codeBlockStyle?: "indented";
|
||||
} = $props();
|
||||
|
||||
const label = $derived(lang ? `${lang} code block` : "Code block");
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
|
||||
<div class="markdown-code-region" role="region" aria-label={label} tabindex="0">
|
||||
<pre><code>{text}</code></pre>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import DocumentMarkdown from "./DocumentMarkdown.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("renders document Markdown semantics through the shared safe policy", async () => {
|
||||
const source = [
|
||||
"# Memory heading",
|
||||
"",
|
||||
"##### Deep heading",
|
||||
"",
|
||||
"###### Deepest heading",
|
||||
"",
|
||||
"A paragraph with **strong context** and `inline-code`.",
|
||||
"",
|
||||
"- first item",
|
||||
"- second item",
|
||||
"",
|
||||
"> durable quote",
|
||||
"",
|
||||
"| Decision | Owner |",
|
||||
"| --- | --- |",
|
||||
"| Keep the document safe | Workspace |",
|
||||
"",
|
||||
"```rust",
|
||||
'fn main() { println!("safe"); }',
|
||||
"```",
|
||||
"",
|
||||
"[safe](https://example.com) [script](javascript:alert(1)) [relative](/internal)",
|
||||
"",
|
||||
'<img src="x" onerror="alert(1)"><script>alert(1)</script>',
|
||||
].join("\n");
|
||||
const { container } = render(DocumentMarkdown, { text: source });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("h1")?.textContent).toBe("Memory heading");
|
||||
expect(container.querySelector("table")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(container.querySelector("h5")?.textContent).toBe("Deep heading");
|
||||
expect(container.querySelector("h6")?.textContent).toBe("Deepest heading");
|
||||
expect(container.querySelector("strong")?.textContent).toBe("strong context");
|
||||
expect(container.querySelectorAll("li")).toHaveLength(2);
|
||||
expect(container.querySelector("blockquote")?.textContent).toContain(
|
||||
"durable quote",
|
||||
);
|
||||
expect(container.querySelector('a[href="https://example.com"]')).not
|
||||
.toBeNull();
|
||||
expect(container.querySelectorAll("a")).toHaveLength(1);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
|
||||
const tableRegion = container.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label="Markdown table"]',
|
||||
);
|
||||
const codeRegion = container.querySelector<HTMLElement>(
|
||||
'[role="region"][aria-label="rust code block"]',
|
||||
);
|
||||
expect(tableRegion?.tabIndex).toBe(0);
|
||||
expect(codeRegion?.tabIndex).toBe(0);
|
||||
expect(codeRegion?.querySelector("pre > code")?.textContent).toContain(
|
||||
"println!",
|
||||
);
|
||||
});
|
||||
|
||||
test("wraps long prose and identifiers without turning them into document-level controls", async () => {
|
||||
const longIdentifier = "memory-identifier-".repeat(40);
|
||||
const { container } = render(DocumentMarkdown, {
|
||||
text:
|
||||
`Long value: ${longIdentifier}\n\n<details open><summary>unsafe HTML</summary></details>`,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.textContent).toContain(longIdentifier));
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { Renderers } from "@humanspeak/svelte-markdown";
|
||||
import DocumentCodeBlock from "./DocumentCodeBlock.svelte";
|
||||
import MarkdownTable from "./MarkdownTable.svelte";
|
||||
import SafeMarkdown from "./SafeMarkdown.svelte";
|
||||
|
||||
let { text }: { text: string } = $props();
|
||||
|
||||
const renderers = {
|
||||
code: DocumentCodeBlock,
|
||||
table: MarkdownTable,
|
||||
} satisfies Partial<Renderers>;
|
||||
</script>
|
||||
|
||||
<SafeMarkdown {text} class="document-markdown" {renderers} />
|
||||
|
||||
<style>
|
||||
:global(.document-markdown) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--text);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(.document-markdown > :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(.document-markdown > :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.document-markdown h1) {
|
||||
margin: var(--space-6) 0 var(--space-4);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-title);
|
||||
line-height: var(--line-height-title);
|
||||
}
|
||||
|
||||
:global(.document-markdown h2),
|
||||
:global(.document-markdown h3),
|
||||
:global(.document-markdown h4),
|
||||
:global(.document-markdown h5),
|
||||
:global(.document-markdown h6) {
|
||||
margin: var(--space-6) 0 var(--space-2);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
:global(.document-markdown h2) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:global(.document-markdown h3),
|
||||
:global(.document-markdown h4),
|
||||
:global(.document-markdown h5),
|
||||
:global(.document-markdown h6) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.document-markdown p),
|
||||
:global(.document-markdown ul),
|
||||
:global(.document-markdown ol),
|
||||
:global(.document-markdown blockquote),
|
||||
:global(.document-markdown .markdown-code-region),
|
||||
:global(.document-markdown .markdown-table-region) {
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
:global(.document-markdown ul),
|
||||
:global(.document-markdown ol) {
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
:global(.document-markdown li + li) {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
:global(.document-markdown blockquote) {
|
||||
margin-left: 0;
|
||||
padding-left: var(--space-4);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
:global(.document-markdown a) {
|
||||
color: var(--accent);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
:global(.document-markdown :not(pre) > code) {
|
||||
padding: 0 var(--space-1);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region),
|
||||
:global(.document-markdown .markdown-table-region) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region) {
|
||||
background: var(--bg-raised);
|
||||
color: var(--code);
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-code-region pre) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region th),
|
||||
:global(.document-markdown .markdown-table-region td) {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.document-markdown .markdown-table-region th) {
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.document-markdown hr) {
|
||||
margin: var(--space-6) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (horizontal scroll regions must be keyboard-focusable) -->
|
||||
<div class="markdown-table-region" role="region" aria-label="Markdown table" tabindex="0">
|
||||
<table>{@render children?.()}</table>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import SvelteMarkdown, {
|
||||
buildUnsupportedHTML,
|
||||
type Renderers,
|
||||
type SvelteMarkdownProps,
|
||||
} from "@humanspeak/svelte-markdown";
|
||||
import MarkdownLink from "$lib/workspace/markdown/MarkdownLink.svelte";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
streamId?: string | number;
|
||||
class?: string;
|
||||
streaming?: boolean;
|
||||
renderers?: Partial<Renderers>;
|
||||
};
|
||||
|
||||
let {
|
||||
text,
|
||||
streamId = "static",
|
||||
class: className = "",
|
||||
streaming = false,
|
||||
renderers = {},
|
||||
}: Props = $props();
|
||||
|
||||
const options = {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const safeRenderers = $derived({
|
||||
...renderers,
|
||||
html: buildUnsupportedHTML(),
|
||||
link: MarkdownLink,
|
||||
} satisfies Partial<Renderers>);
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<SvelteMarkdown source={text} {streamId} {options} renderers={safeRenderers} {streaming} />
|
||||
</div>
|
||||
@@ -1,123 +1,165 @@
|
||||
<script lang="ts">
|
||||
import DocumentMarkdown from '$lib/workspace/markdown/DocumentMarkdown.svelte';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const lineCount = $derived(data.memory.data?.body_md.split('\n').length ?? 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Memory Document · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory Document" />
|
||||
<title>Memory · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Memory document" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="card memory-document-card">
|
||||
<div class="detail-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace memory</p>
|
||||
<h2>Memory Document</h2>
|
||||
</div>
|
||||
{#if data.memory.data}
|
||||
<span>{data.memory.data.bytes} bytes</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="section-note">
|
||||
Durable workspace Memory as a single Markdown document. This view is read-only.
|
||||
</p>
|
||||
|
||||
<section class="memory-document-page" aria-label="Memory document">
|
||||
{#if data.memory.data}
|
||||
<div class="memory-document-summary" aria-label="Memory document summary">
|
||||
<div>
|
||||
<span>Updated</span>
|
||||
<strong>{formatDate(data.memory.data.updated_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Created</span>
|
||||
<strong>{formatDate(data.memory.data.created_at)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Lines</span>
|
||||
<strong>{lineCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Source</span>
|
||||
<strong>{data.memory.data.record_source}</strong>
|
||||
</div>
|
||||
<div class="memory-document-meta">
|
||||
<p>
|
||||
<span>Read-only</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Updated <time datetime={data.memory.data.updated_at}>{formatDate(data.memory.data.updated_at)}</time></span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Document details</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd><time datetime={data.memory.data.created_at}>{formatDate(data.memory.data.created_at)}</time></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{data.memory.data.bytes} bytes</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd><code>{data.memory.data.record_source}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{#if data.memory.data.body_md.trim().length === 0}
|
||||
<p>No Memory document content is present.</p>
|
||||
<div class="memory-document-state" role="status">
|
||||
<strong>Memory document is empty.</strong>
|
||||
<p>Durable Workspace context will appear here when it is available.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="memory-document-body">{data.memory.data.body_md}</pre>
|
||||
<article class="memory-document-content" aria-label="Memory document content">
|
||||
<DocumentMarkdown text={data.memory.data.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
{:else if data.memory.error}
|
||||
<p class="error">{data.memory.error}</p>
|
||||
<div class="memory-document-state memory-document-error">
|
||||
<p role="alert"><strong>Memory document unavailable.</strong> {data.memory.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/w/{data.workspaceId}/memory</code>…</p>
|
||||
<div class="memory-document-state" role="status">
|
||||
<p>Memory document data is unavailable.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.memory-document-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-document-summary {
|
||||
.memory-document-page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-5);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 78rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.memory-document-summary div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bg-raised);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.memory-document-summary span {
|
||||
.memory-document-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.memory-document-summary strong {
|
||||
.memory-document-meta > p {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.memory-document-meta details {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.memory-document-meta summary {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: min(24rem, calc(100vw - (2 * var(--space-4))));
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3) 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.memory-document-meta dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-document-meta dt {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.memory-document-meta dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
font-size: var(--font-size-compact);
|
||||
margin-top: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.memory-document-body {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
margin: 1rem 0 0;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
.memory-document-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.memory-document-state {
|
||||
padding-block: var(--space-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.memory-document-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.memory-document-state p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.memory-document-state > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.memory-document-error,
|
||||
.memory-document-error strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.memory-document-meta {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.memory-document-summary {
|
||||
grid-template-columns: 1fr;
|
||||
.memory-document-meta details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.memory-document-meta dl {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import MemoryPage from "./+page.svelte";
|
||||
import type { PageProps } from "./$types";
|
||||
|
||||
function pageProps(memory: {
|
||||
data: {
|
||||
body_md: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
bytes: number;
|
||||
record_source: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}): PageProps {
|
||||
return {
|
||||
data: { workspaceId: "workspace-1", memory },
|
||||
params: { workspaceId: "workspace-1" },
|
||||
} as unknown as PageProps;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => {
|
||||
const { container } = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: "# Durable context\n\nRead the **current** document.",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 48,
|
||||
record_source: "sqlite_workspace_authority.memory_document",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Read-only")).not.toBeNull();
|
||||
expect(screen.getByText("Document details")).not.toBeNull();
|
||||
expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false);
|
||||
expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull();
|
||||
expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("distinguishes an empty document from request failure and unavailable data", async () => {
|
||||
const view = render(
|
||||
MemoryPage,
|
||||
pageProps({
|
||||
data: {
|
||||
body_md: " \n",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-02T03:04:05Z",
|
||||
bytes: 3,
|
||||
record_source: "fixture",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("status").textContent).toContain("Memory document is empty.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: "Memory API request failed" }));
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable.");
|
||||
expect(screen.getByRole("alert").textContent).toContain("Memory API request failed");
|
||||
expect(screen.queryByText("Memory document is empty.")).toBeNull();
|
||||
|
||||
await view.rerender(pageProps({ data: null, error: null }));
|
||||
expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable.");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user