feat: add client-hosted external workdirs

This commit is contained in:
2026-09-22 06:49:15 +09:00
parent dc135ee1de
commit ce9534f3a2
45 changed files with 6024 additions and 311 deletions
Generated
+5
View File
@@ -1446,6 +1446,7 @@ dependencies = [
"grep-regex",
"grep-searcher",
"ignore",
"libc",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -6258,9 +6259,11 @@ version = "0.1.0"
dependencies = [
"chrono",
"client",
"futures",
"manifest",
"memory",
"project-record",
"reqwest",
"serde",
"serde_json",
"serde_yaml",
@@ -6270,9 +6273,11 @@ dependencies = [
"tempfile",
"ticket",
"tokio",
"tokio-tungstenite 0.29.0",
"toml",
"tui",
"uuid",
"workdir",
"worker",
]
+59
View File
@@ -7,6 +7,7 @@ use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
const MAX_REDIRECTS: usize = 10;
@@ -173,6 +174,37 @@ impl BackendApiClient {
.bearer_auth(&self.access_token.0))
}
pub fn websocket_request(
&self,
path_and_query: &str,
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, BackendApiClientError> {
let mut url = self.origin.url(path_and_query)?;
let scheme = match url.scheme() {
"http" => "ws",
"https" => "wss",
_ => unreachable!("Backend origin validates HTTP schemes"),
};
url.set_scheme(scheme).map_err(|()| {
BackendApiClientError::InvalidRequestPath(
"Backend WebSocket URL scheme could not be constructed".to_string(),
)
})?;
let mut request = url.as_str().into_client_request().map_err(|error| {
BackendApiClientError::InvalidRequestPath(format!(
"Backend WebSocket request is invalid: {error}"
))
})?;
request.headers_mut().insert(
tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
self.authorization_header_value().parse().map_err(|error| {
BackendApiClientError::InvalidRequestPath(format!(
"Backend authorization header is invalid: {error}"
))
})?,
);
Ok(request)
}
pub fn blocking_request(
&self,
method: Method,
@@ -743,6 +775,33 @@ mod tests {
fs::remove_file(path).unwrap();
}
#[test]
fn websocket_requests_are_same_origin_and_use_the_stored_bearer() {
let client = BackendApiClient::from_access_token_for_test(
"https://backend.example:8443",
"websocket-secret",
)
.unwrap();
let request = client
.websocket_request("/api/w/workspace/external-workdir-grants/grant/provider")
.unwrap();
assert_eq!(request.uri().scheme_str(), Some("wss"));
assert_eq!(request.uri().host(), Some("backend.example"));
assert_eq!(request.uri().port_u16(), Some(8443));
assert_eq!(
request
.headers()
.get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION)
.unwrap(),
"Bearer websocket-secret"
);
assert!(
client
.websocket_request("//other.example/provider")
.is_err()
);
}
#[test]
fn client_debug_and_errors_never_include_token_value() {
let client = BackendApiClient::from_access_token_for_test(
+8 -2
View File
@@ -21,6 +21,7 @@ pub use server_api::{
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
WorkingDirectorySource as BackendWorkingDirectorySource,
WorkingDirectorySummary as BackendWorkingDirectorySummary,
};
use std::fmt;
@@ -1018,7 +1019,7 @@ mod tests {
"alias": "checkout",
"working_directory": {
"working_directory_id": "wd-1",
"repository_key": "main",
"source": {"kind": "repository", "repository_key": "main"},
"materializer_kind": "runtime_git_clone",
"status": "active",
"occupied_by": {
@@ -1035,7 +1036,12 @@ mod tests {
let attachment = worker.workdir_attachments.into_iter().next().unwrap();
assert_eq!(attachment.alias, "checkout");
let workdir = attachment.working_directory;
assert_eq!(workdir.repository_key, "main");
assert_eq!(
workdir.source,
BackendWorkingDirectorySource::Repository {
repository_key: "main".to_string(),
}
);
let occupied_by = workdir.occupied_by.expect("occupied Workdir");
assert_eq!(occupied_by.runtime_id, "arcadia");
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
+4 -3
View File
@@ -28,9 +28,10 @@ pub use backend_runtime::{
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers,
list_backend_workers, observe_backend_worker_session, restore_backend_worker,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource, BackendWorkingDirectorySummary,
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
restore_backend_worker,
};
pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
+1
View File
@@ -10,6 +10,7 @@ grep-matcher = "0.1.8"
grep-regex = "0.1.14"
grep-searcher = "0.1.16"
ignore = "0.4.25"
libc = "0.2"
serde = { workspace = true, features = ["derive"] }
sha2 = "0.10.9"
tempfile.workspace = true
+25 -2
View File
@@ -26,15 +26,31 @@ pub fn run_glob(
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
.compile_matcher();
let mut matches = Vec::new();
let mut retained_path_bytes = 0_usize;
let mut provider_truncated = false;
let mut walker = WalkBuilder::new(base);
walker.hidden(false).follow_links(false);
let mut visited = 0_usize;
for entry in walker.build().flatten() {
visited = visited.saturating_add(1);
if visited > crate::MAX_TRAVERSAL_ENTRIES {
return Err(FsError::InvalidArgument(format!(
"glob traversal exceeds provider limit {}",
crate::MAX_TRAVERSAL_ENTRIES
)));
}
let path = entry.path();
let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !path.is_file() || !readable {
continue;
}
let Ok(file) = access.open_read_file(path, path) else {
continue;
};
if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
continue;
}
let relative = path.strip_prefix(base).unwrap_or(path);
if !matcher.is_match(relative) {
continue;
@@ -42,10 +58,17 @@ pub fn run_glob(
let logical = path.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?;
matches.push(FsPath::new(logical.to_string_lossy())?);
let result_path = FsPath::new(logical.to_string_lossy())?;
let retained = result_path.as_str().len().saturating_add(16);
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
provider_truncated = true;
break;
}
retained_path_bytes = retained_path_bytes.saturating_add(retained);
matches.push(result_path);
}
matches.sort_by(|left, right| left.as_str().cmp(right.as_str()));
let truncated = matches.len() > request.limit;
let truncated = provider_truncated || matches.len() > request.limit;
matches.truncate(request.limit);
Ok(GlobResult {
paths: matches,
+312 -1
View File
@@ -14,15 +14,167 @@ use std::path::{Path, PathBuf};
use thiserror::Error;
pub use glob::run_glob;
pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
pub use local::{
resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_stat, run_write,
};
pub use operation::*;
pub use search::run_grep;
/// Provider-side ceiling for directory/tree traversal, independent of result truncation.
pub const MAX_TRAVERSAL_ENTRIES: usize = 100_000;
/// Provider-side ceiling for aggregate retained result paths per operation.
pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024;
/// Provider-side ceiling for aggregate grep source bytes per operation.
pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
/// Open `path` beneath `root` without following any symbolic link. Linux uses
/// `openat2` so resolution and open are one kernel-enforced operation. Other
/// platforms fail closed rather than silently weakening an External grant.
#[cfg(target_os = "linux")]
pub fn open_root_no_symlinks(root: &Path) -> std::io::Result<std::fs::File> {
use std::ffi::CString;
use std::os::fd::{FromRawFd, RawFd};
use std::os::unix::ffi::OsStrExt;
let root = CString::new(root.as_os_str().as_bytes())
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "root contains NUL"))?;
// SAFETY: `root` is a valid C string and the returned descriptor is checked
// before ownership is transferred to File.
let fd: RawFd = unsafe {
libc::open(
root.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: fd is a new owned descriptor returned by open.
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
}
#[cfg(not(target_os = "linux"))]
pub fn open_root_no_symlinks(_root: &Path) -> std::io::Result<std::fs::File> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"root-confined no-symlink open is unavailable on this platform",
))
}
#[cfg(target_os = "linux")]
pub fn open_beneath_no_symlinks_at(
root: &std::fs::File,
relative: &Path,
) -> std::io::Result<std::fs::File> {
use std::ffi::CString;
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
use std::os::unix::ffi::OsStrExt;
#[repr(C)]
struct OpenHow {
flags: u64,
mode: u64,
resolve: u64,
}
const RESOLVE_NO_XDEV: u64 = 0x01;
const RESOLVE_NO_SYMLINKS: u64 = 0x04;
const RESOLVE_BENEATH: u64 = 0x08;
if relative.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
}
let relative = if relative.as_os_str().is_empty() {
CString::new(".").expect("static path")
} else {
CString::new(relative.as_os_str().as_bytes()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL")
})?
};
let how = OpenHow {
flags: (libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) as u64,
mode: 0,
resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV,
};
// SAFETY: `how` and the path remain alive for the syscall duration, and the
// borrowed root descriptor remains valid for the call.
let fd = unsafe {
libc::syscall(
libc::SYS_openat2,
root.as_raw_fd(),
relative.as_ptr(),
&how as *const OpenHow,
std::mem::size_of::<OpenHow>(),
) as RawFd
};
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: fd is a new owned descriptor returned by openat2.
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
}
#[cfg(not(target_os = "linux"))]
pub fn open_beneath_no_symlinks_at(
_root: &std::fs::File,
_relative: &Path,
) -> std::io::Result<std::fs::File> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"root-confined no-symlink open is unavailable on this platform",
))
}
#[cfg(target_os = "linux")]
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
let relative = path.strip_prefix(root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
)
})?;
let root = open_root_no_symlinks(root)?;
open_beneath_no_symlinks_at(&root, relative)
}
#[cfg(not(target_os = "linux"))]
pub fn open_beneath_no_symlinks(_root: &Path, _path: &Path) -> std::io::Result<std::fs::File> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"root-confined no-symlink open is unavailable on this platform",
))
}
/// Provider-owned access policy used by local filesystem operations.
pub trait FsAccessPolicy: Send + Sync {
fn is_readable(&self, path: &Path) -> bool;
fn is_writable(&self, path: &Path) -> bool;
/// Open an already-authorized readable file. Capability providers override
/// this to bind path resolution and open into one root-confined operation.
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
std::fs::File::open(resolved)
}
/// Obtain metadata for an already-authorized path. Capability providers
/// override this to prevent a path swap between authorization and stat.
fn read_metadata(
&self,
logical: &Path,
_resolved: &Path,
) -> std::io::Result<std::fs::Metadata> {
std::fs::symlink_metadata(logical)
}
/// Open an already-authorized directory for bounded enumeration.
/// Capability providers override this to bind traversal to a confined
/// directory descriptor rather than reopening a mutable path.
fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
std::fs::read_dir(resolved)
}
/// Authorize both the Workdir-visible path and its provider-resolved
/// target. Implementations that do not distinguish symbolic-link identity
/// retain resolved-target semantics through the defaults.
@@ -201,6 +353,165 @@ mod tests {
assert!(serde_json::from_str::<FsPath>(r#""../secret""#).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn root_confined_open_rejects_a_symlink_swap_between_authorization_and_open() {
use std::os::unix::fs::symlink;
use std::sync::atomic::{AtomicBool, Ordering};
struct SwapAccess {
root: PathBuf,
selected: PathBuf,
outside: PathBuf,
swapped: AtomicBool,
}
impl FsAccessPolicy for SwapAccess {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.root)
}
fn is_writable(&self, _path: &Path) -> bool {
false
}
fn is_readable_paths(&self, _logical: &Path, _resolved: &Path) -> bool {
if !self.swapped.swap(true, Ordering::SeqCst) {
std::fs::remove_file(&self.selected).unwrap();
symlink(&self.outside, &self.selected).unwrap();
}
true
}
fn open_read_file(
&self,
_logical: &Path,
resolved: &Path,
) -> std::io::Result<std::fs::File> {
open_beneath_no_symlinks(&self.root, resolved)
}
}
let root_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let root = root_dir.path().canonicalize().unwrap();
let outside = outside_dir.path().join("secret.txt");
let selected = root.join("selected.txt");
std::fs::write(&selected, "safe").unwrap();
std::fs::write(&outside, "secret").unwrap();
let access = SwapAccess {
root: root.clone(),
selected,
outside,
swapped: AtomicBool::new(false),
};
let error = run_read_bounded(
&root,
ReadRequest {
path: FsPath::new("selected.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
},
&access,
BoundedReadLimits::EXTERNAL_DEFAULT,
)
.expect_err("openat2 must reject the swapped symbolic link");
assert!(matches!(error, FsError::Io { .. }));
}
#[cfg(target_os = "linux")]
#[test]
fn root_confined_stat_and_list_reject_swaps_after_authorization() {
use std::os::unix::fs::symlink;
use std::sync::atomic::{AtomicBool, Ordering};
struct SwapAccess {
root: PathBuf,
selected: PathBuf,
outside: PathBuf,
directory: bool,
swapped: AtomicBool,
}
impl FsAccessPolicy for SwapAccess {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.root)
}
fn is_writable(&self, _path: &Path) -> bool {
false
}
fn is_readable_paths(&self, logical: &Path, _resolved: &Path) -> bool {
if logical == self.selected && !self.swapped.swap(true, Ordering::SeqCst) {
if self.directory {
std::fs::remove_dir(&self.selected).unwrap();
} else {
std::fs::remove_file(&self.selected).unwrap();
}
symlink(&self.outside, &self.selected).unwrap();
}
true
}
fn read_metadata(
&self,
_logical: &Path,
resolved: &Path,
) -> std::io::Result<std::fs::Metadata> {
open_beneath_no_symlinks(&self.root, resolved)?.metadata()
}
fn open_read_dir(
&self,
_logical: &Path,
resolved: &Path,
) -> std::io::Result<std::fs::ReadDir> {
use std::os::fd::AsRawFd;
let directory = open_beneath_no_symlinks(&self.root, resolved)?;
std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))
}
}
let root_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let root = root_dir.path().canonicalize().unwrap();
let selected_file = root.join("selected.txt");
let outside_file = outside_dir.path().join("secret.txt");
std::fs::write(&selected_file, "safe").unwrap();
std::fs::write(&outside_file, "secret").unwrap();
let stat_error = run_stat(
&root,
StatRequest {
path: FsPath::new("selected.txt").unwrap(),
},
&SwapAccess {
root: root.clone(),
selected: selected_file,
outside: outside_file,
directory: false,
swapped: AtomicBool::new(false),
},
)
.expect_err("confined stat must reject a swapped symlink");
assert!(matches!(stat_error, FsError::Io { .. }));
let selected_dir = root.join("selected-dir");
let outside_content = outside_dir.path().join("outside-dir");
std::fs::create_dir(&selected_dir).unwrap();
std::fs::create_dir(&outside_content).unwrap();
std::fs::write(outside_content.join("secret.txt"), "secret").unwrap();
let list_error = run_list(
&root,
ListRequest {
path: FsPath::new("selected-dir").unwrap(),
limit: 10,
},
&SwapAccess {
root: root.clone(),
selected: selected_dir,
outside: outside_content,
directory: true,
swapped: AtomicBool::new(false),
},
)
.expect_err("confined list must reject a swapped symlink");
assert!(matches!(list_error, FsError::Io { .. }));
}
#[test]
fn direct_operations_cover_stat_read_write_edit_and_list() {
let temp = tempfile::tempdir().unwrap();
+214 -43
View File
@@ -1,16 +1,18 @@
use std::ffi::OsString;
use std::fs;
use std::io::Write;
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::{
ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError, FsPath, ListEntry,
ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest,
WriteResult, direct_symlink,
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError,
FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult,
WriteRequest, WriteResult, direct_symlink,
};
const READ_BUFFER_BYTES: usize = 16 * 1024;
/// Execute stat while keeping host paths inside the provider boundary.
pub fn run_stat(
root: &Path,
@@ -23,7 +25,9 @@ pub fn run_stat(
if !access.is_readable_paths(&path, &resolved) {
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
}
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
let metadata = access
.read_metadata(&path, &resolved)
.map_err(|error| map_io(&logical, error))?;
let kind = if metadata.file_type().is_symlink() {
EntryKind::Symlink
} else if metadata.is_file() {
@@ -44,20 +48,110 @@ pub fn run_read(
root: &Path,
request: ReadRequest,
access: &dyn FsAccessPolicy,
) -> Result<ReadResult, FsError> {
run_read_with_limits(root, request, access, None)
}
/// Execute a read while enforcing provider-owned source and response bounds.
///
/// The source is streamed through a fixed-size buffer. Files larger than the
/// configured source bound fail before their content is read, and growth after
/// the metadata check is fenced while streaming.
pub fn run_read_bounded(
root: &Path,
request: ReadRequest,
access: &dyn FsAccessPolicy,
limits: BoundedReadLimits,
) -> Result<ReadResult, FsError> {
run_read_with_limits(root, request, access, Some(limits))
}
fn run_read_with_limits(
root: &Path,
request: ReadRequest,
access: &dyn FsAccessPolicy,
limits: Option<BoundedReadLimits>,
) -> Result<ReadResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false, false)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
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())));
}
let bytes = fs::read(&path).map_err(|error| map_io(&logical, error))?;
let content_hash = hash_bytes(&bytes);
let lines = bytes
.split_inclusive(|byte| *byte == b'\n')
.collect::<Vec<_>>();
let total_lines = lines.len();
if !metadata.is_file() {
return Err(FsError::InvalidArgument(
"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!(
"read source size {} exceeds provider limit {}",
metadata.len(),
limits.max_source_bytes
)));
}
let response_limit = limits
.map(|limits| request.max_bytes.min(limits.max_response_bytes))
.unwrap_or(request.max_bytes);
let retained_limit = response_limit.saturating_add(4);
let selected_end = request.offset.saturating_add(request.limit);
let mut selected = Vec::with_capacity(retained_limit.min(READ_BUFFER_BYTES));
let mut selected_bytes_seen = 0usize;
let mut current_line = 0usize;
let mut source_bytes_seen = 0u64;
let mut last_byte = None;
let mut content_hasher = Sha256::new();
let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file);
let mut buffer = [0u8; READ_BUFFER_BYTES];
loop {
let read_limit = limits
.map(|limits| {
limits
.max_source_bytes
.saturating_sub(source_bytes_seen)
.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;
}
source_bytes_seen = source_bytes_seen.saturating_add(read as u64);
if let Some(limits) = limits
&& source_bytes_seen > limits.max_source_bytes
{
return Err(FsError::InvalidArgument(format!(
"read source exceeds provider limit {}",
limits.max_source_bytes
)));
}
content_hasher.update(&buffer[..read]);
for byte in &buffer[..read] {
if current_line >= request.offset && current_line < selected_end {
selected_bytes_seen = selected_bytes_seen.saturating_add(1);
if selected.len() < retained_limit {
selected.push(*byte);
}
}
if *byte == b'\n' {
current_line = current_line.saturating_add(1);
}
last_byte = Some(*byte);
}
}
let total_lines =
current_line.saturating_add(usize::from(last_byte.is_some_and(|byte| byte != b'\n')));
if request.offset > total_lines && request.offset != 0 {
return Err(FsError::InvalidArgument(format!(
"offset {} exceeds file length {total_lines}",
@@ -68,16 +162,14 @@ pub fn run_read(
.offset
.saturating_add(request.limit)
.min(total_lines);
let mut selected = lines[request.offset.min(total_lines)..end]
.iter()
.flat_map(|line| line.iter().copied())
.collect::<Vec<_>>();
let byte_truncated = selected.len() > request.max_bytes;
let byte_truncated = selected_bytes_seen > response_limit;
if byte_truncated {
let mut byte_end = request.max_bytes;
if let Ok(text) = std::str::from_utf8(&selected) {
while byte_end > 0 && !text.is_char_boundary(byte_end) {
byte_end -= 1;
let mut byte_end = response_limit.min(selected.len());
while byte_end > 0 {
match std::str::from_utf8(&selected[..byte_end]) {
Ok(_) => break,
Err(error) if error.error_len().is_none() => byte_end -= 1,
Err(_) => break,
}
}
selected.truncate(byte_end);
@@ -87,7 +179,7 @@ pub fn run_read(
bytes: selected,
start_line: request.offset,
total_lines,
content_hash,
content_hash: content_hasher.finalize().into(),
truncated: end < total_lines || byte_truncated,
})
}
@@ -173,35 +265,39 @@ pub fn run_list(
let path = resolve(root, &logical)?;
let logical_base = path.clone();
let path = require_access(&path, &logical, access, false, true)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
let metadata = access
.open_read_file(&logical_base, &path)
.and_then(|directory| directory.metadata())
.map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
}
let mut entries = Vec::new();
let read_dir = fs::read_dir(&path).map_err(|error| map_io(&logical, error))?;
for entry in read_dir {
let mut retained_path_bytes = 0_usize;
let mut provider_truncated = false;
let read_dir = access
.open_read_dir(&logical_base, &path)
.map_err(|error| map_io(&logical, error))?;
for (entry_index, entry) in read_dir.enumerate() {
if entry_index >= crate::MAX_TRAVERSAL_ENTRIES {
return Err(FsError::InvalidArgument(format!(
"directory traversal exceeds provider limit {}",
crate::MAX_TRAVERSAL_ENTRIES
)));
}
let entry = entry.map_err(|error| map_io(&logical, error))?;
let absolute = entry.path();
let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
})?;
let logical_absolute = logical_base.join(relative_to_base);
let resolved = match resolve_access_path(&absolute) {
let logical_absolute = logical_base.join(entry.file_name());
let resolved = match resolve_access_path(&logical_absolute) {
Ok(resolved) => resolved,
Err(_) => continue,
};
if !access.is_readable_paths(&logical_absolute, &resolved) {
continue;
}
let link_metadata =
fs::symlink_metadata(&absolute).map_err(|error| map_io(&logical, error))?;
let is_symlink = link_metadata.file_type().is_symlink();
let metadata = if is_symlink {
link_metadata
} else {
entry.metadata().map_err(|error| map_io(&logical, error))?
};
let kind = if is_symlink {
let metadata = access
.read_metadata(&logical_absolute, &resolved)
.map_err(|error| map_io(&logical, error))?;
let kind = if metadata.file_type().is_symlink() {
EntryKind::Symlink
} else if metadata.is_file() {
EntryKind::File
@@ -213,8 +309,15 @@ pub fn run_list(
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?;
let result_path = FsPath::new(relative.to_string_lossy())?;
let retained = result_path.as_str().len().saturating_add(64);
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
provider_truncated = true;
break;
}
retained_path_bytes = retained_path_bytes.saturating_add(retained);
entries.push(ListEntry {
path: FsPath::new(relative.to_string_lossy())?,
path: result_path,
kind,
size: metadata.len(),
});
@@ -228,7 +331,7 @@ pub fn run_list(
});
let total_entries = entries.len();
let total_bytes = entries.iter().map(|entry| entry.size).sum();
let truncated = entries.len() > request.limit;
let truncated = provider_truncated || entries.len() > request.limit;
entries.truncate(request.limit);
Ok(ListResult {
entries,
@@ -366,3 +469,71 @@ fn map_io(logical: &FsPath, error: std::io::Error) -> FsError {
},
}
}
#[cfg(test)]
mod bounded_read_tests {
use std::path::{Path, PathBuf};
use super::*;
struct RootAccess(PathBuf);
impl FsAccessPolicy for RootAccess {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.0)
}
fn is_writable(&self, _path: &Path) -> bool {
false
}
}
#[test]
fn bounded_read_rejects_an_oversized_source_before_loading_content() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let source = root.join("large.log");
let file = fs::File::create(&source).unwrap();
file.set_len(4097).unwrap();
let error = run_read_bounded(
&root,
ReadRequest {
path: FsPath::new("large.log").unwrap(),
offset: 0,
limit: 10,
max_bytes: usize::MAX,
},
&RootAccess(root.clone()),
BoundedReadLimits::new(4096, 1024).unwrap(),
)
.unwrap_err();
assert!(matches!(error, FsError::InvalidArgument(_)));
assert!(error.to_string().contains("exceeds provider limit 4096"));
}
#[test]
fn bounded_read_caps_retained_response_independently_of_request() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
fs::write(root.join("events.log"), "alpha\nbeta\ngamma\n").unwrap();
let result = run_read_bounded(
&root,
ReadRequest {
path: FsPath::new("events.log").unwrap(),
offset: 0,
limit: 10,
max_bytes: usize::MAX,
},
&RootAccess(root.clone()),
BoundedReadLimits::new(1024, 5).unwrap(),
)
.unwrap();
assert_eq!(result.bytes, b"alpha");
assert_eq!(result.total_lines, 3);
assert!(result.truncated);
}
}
+79
View File
@@ -5,6 +5,71 @@ use serde::{Deserialize, Serialize};
use crate::FsError;
/// Absolute provider-side bounds for one filesystem read.
///
/// `max_source_bytes` limits how much of the source the provider will inspect
/// while computing line and content metadata. `max_response_bytes` limits the
/// bytes retained for the response independently of the caller's requested
/// `ReadRequest::max_bytes`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct BoundedReadLimits {
pub max_source_bytes: u64,
pub max_response_bytes: usize,
}
impl BoundedReadLimits {
/// Conservative defaults for a client-hosted, read-only provider.
pub const EXTERNAL_DEFAULT: Self = Self {
max_source_bytes: 64 * 1024 * 1024,
max_response_bytes: 1024 * 1024,
};
pub fn new(max_source_bytes: u64, max_response_bytes: usize) -> Result<Self, FsError> {
let limits = Self {
max_source_bytes,
max_response_bytes,
};
limits.validate()?;
Ok(limits)
}
pub fn validate(self) -> Result<(), FsError> {
if self.max_source_bytes == 0 {
return Err(FsError::InvalidArgument(
"max_source_bytes must be greater than zero".to_string(),
));
}
if self.max_response_bytes == 0 {
return Err(FsError::InvalidArgument(
"max_response_bytes must be greater than zero".to_string(),
));
}
if self.max_response_bytes as u128 > self.max_source_bytes as u128 {
return Err(FsError::InvalidArgument(
"max_response_bytes must not exceed max_source_bytes".to_string(),
));
}
Ok(())
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BoundedReadLimitsWire {
max_source_bytes: u64,
max_response_bytes: usize,
}
impl<'de> Deserialize<'de> for BoundedReadLimits {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = BoundedReadLimitsWire::deserialize(deserializer)?;
Self::new(wire.max_source_bytes, wire.max_response_bytes).map_err(serde::de::Error::custom)
}
}
/// Scope-checked filesystem path. Relative paths resolve below the bound
/// Workdir root; absolute paths require an explicit matching scope rule.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
@@ -89,11 +154,13 @@ impl fmt::Display for FsPath {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StatRequest {
pub path: FsPath,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StatResult {
pub path: FsPath,
pub kind: EntryKind,
@@ -112,6 +179,7 @@ pub enum EntryKind {
pub type ContentHash = [u8; 32];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadRequest {
pub path: FsPath,
pub offset: usize,
@@ -120,6 +188,7 @@ pub struct ReadRequest {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadResult {
pub path: FsPath,
pub bytes: Vec<u8>,
@@ -130,6 +199,7 @@ pub struct ReadResult {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WriteRequest {
pub path: FsPath,
pub content: Vec<u8>,
@@ -143,6 +213,7 @@ pub struct WriteResult {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EditRequest {
pub path: FsPath,
pub old_string: String,
@@ -152,6 +223,7 @@ pub struct EditRequest {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EditResult {
pub replacements: usize,
pub bytes_written: usize,
@@ -159,12 +231,14 @@ pub struct EditResult {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListRequest {
pub path: FsPath,
pub limit: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListEntry {
pub path: FsPath,
pub kind: EntryKind,
@@ -172,6 +246,7 @@ pub struct ListEntry {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListResult {
pub entries: Vec<ListEntry>,
pub total_entries: usize,
@@ -180,6 +255,7 @@ pub struct ListResult {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GlobRequest {
pub pattern: String,
pub path: FsPath,
@@ -187,6 +263,7 @@ pub struct GlobRequest {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GlobResult {
pub paths: Vec<FsPath>,
pub truncated: bool,
@@ -207,6 +284,7 @@ impl Default for GrepOutputMode {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GrepRequest {
pub pattern: String,
pub path: FsPath,
@@ -222,6 +300,7 @@ pub struct GrepRequest {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GrepResult {
/// Provider-rendered bounded grep report. Keeping rendering here avoids
/// transferring candidate files across a remote provider boundary.
+158 -5
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::FsAccessPolicy;
@@ -14,6 +15,35 @@ use crate::{
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
};
struct SourceBoundedReader<'a, R> {
inner: R,
remaining: &'a mut u64,
}
impl<R: Read> Read for SourceBoundedReader<'_, R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if buffer.is_empty() {
return Ok(0);
}
if *self.remaining == 0 {
let mut probe = [0_u8; 1];
return match self.inner.read(&mut probe)? {
0 => Ok(0),
_ => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"grep source exceeded provider byte limit",
)),
};
}
let limit = usize::try_from(*self.remaining)
.unwrap_or(usize::MAX)
.min(buffer.len());
let read = self.inner.read(&mut buffer[..limit])?;
*self.remaining = (*self.remaining).saturating_sub(read as u64);
Ok(read)
}
}
struct ContentLine {
path: PathBuf,
line_number: Option<u64>,
@@ -27,9 +57,25 @@ struct GrepReport {
files: Vec<PathBuf>,
counts: Vec<(PathBuf, usize)>,
lines: Vec<ContentLine>,
retained_bytes: usize,
truncated: bool,
}
const MAX_GREP_RESULT_RETAINED_BYTES: usize = 512 * 1024;
fn reserve_report_bytes(report: &mut GrepReport, bytes: usize) -> bool {
let Some(next) = report.retained_bytes.checked_add(bytes) else {
report.truncated = true;
return false;
};
if next > MAX_GREP_RESULT_RETAINED_BYTES {
report.truncated = true;
return false;
}
report.retained_bytes = next;
true
}
impl GrepReport {
fn into_result(self, root: &Path) -> GrepResult {
let (match_count, matched_files) = match self.mode {
@@ -281,12 +327,14 @@ pub fn run_grep(
files: Vec::new(),
counts: Vec::new(),
lines: Vec::new(),
retained_bytes: 0,
truncated: false,
};
let mut matching_files_seen = 0;
let mut matches_seen = 0;
if base_meta.is_file() {
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) {
scan_path(
&mut searcher,
@@ -298,6 +346,8 @@ pub fn run_grep(
&mut matches_seen,
offset,
head_limit,
&mut source_bytes_remaining,
access,
)?;
}
return Ok(report.into_result(root));
@@ -319,7 +369,16 @@ pub fn run_grep(
walker.overrides(overrides);
}
let mut visited = 0_usize;
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
for entry in walker.build().flatten() {
visited = visited.saturating_add(1);
if visited > crate::MAX_TRAVERSAL_ENTRIES {
return Err(FsError::InvalidArgument(format!(
"grep traversal exceeds provider limit {}",
crate::MAX_TRAVERSAL_ENTRIES
)));
}
if !entry
.file_type()
.map(|kind| kind.is_file())
@@ -343,6 +402,8 @@ pub fn run_grep(
&mut matches_seen,
offset,
head_limit,
&mut source_bytes_remaining,
access,
)? {
break;
}
@@ -362,13 +423,38 @@ fn scan_path(
matches_seen: &mut usize,
offset: usize,
head_limit: usize,
source_bytes_remaining: &mut u64,
access: &dyn FsAccessPolicy,
) -> Result<bool, FsError> {
let reader = access
.open_read_file(path, path)
.map_err(|error| FsError::io(path, error))?;
let metadata = reader
.metadata()
.map_err(|error| FsError::io(path, error))?;
if !metadata.is_file() {
return Err(FsError::InvalidArgument(
"grep source must be a regular file".to_string(),
));
}
if metadata.len() > *source_bytes_remaining {
return Err(FsError::InvalidArgument(
"grep sources exceed provider byte limit".to_string(),
));
}
let mut reader = SourceBoundedReader {
inner: reader,
remaining: source_bytes_remaining,
};
match mode {
GrepOutputMode::FilesWithMatches => {
if !scan_any_match(searcher, matcher, path)? {
if !scan_any_match(searcher, matcher, &mut reader, path)? {
return Ok(false);
}
if *matching_files_seen >= offset {
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(1)) {
return Ok(true);
}
report.files.push(path.to_path_buf());
if report.files.len() >= head_limit {
report.truncated = true;
@@ -378,11 +464,14 @@ fn scan_path(
*matching_files_seen += 1;
}
GrepOutputMode::Count => {
let count = scan_count(searcher, matcher, path)?;
let count = scan_count(searcher, matcher, &mut reader, path)?;
if count == 0 {
return Ok(false);
}
if *matching_files_seen >= offset {
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(32)) {
return Ok(true);
}
report.counts.push((path.to_path_buf(), count));
if report.counts.len() >= head_limit {
report.truncated = true;
@@ -396,13 +485,18 @@ fn scan_path(
let mut sink = ContentSink {
path: path.to_path_buf(),
lines: &mut report.lines,
retained_bytes: &mut report.retained_bytes,
truncated: &mut report.truncated,
matches_seen,
offset,
head_limit,
};
searcher
.search_path(matcher, path, &mut sink)
.search_reader(matcher, &mut reader, &mut sink)
.map_err(|error| FsError::io(path, error))?;
if report.truncated {
return Ok(true);
}
if *matches_seen >= offset.saturating_add(head_limit) && *matches_seen > before_count {
report.truncated = true;
return Ok(true);
@@ -415,6 +509,7 @@ fn scan_path(
fn scan_any_match(
searcher: &mut Searcher,
matcher: &grep_regex::RegexMatcher,
reader: &mut dyn Read,
path: &Path,
) -> Result<bool, FsError> {
let mut hit = false;
@@ -423,7 +518,7 @@ fn scan_any_match(
Ok(false) // stop searching this file immediately
});
searcher
.search_path(matcher, path, sink)
.search_reader(matcher, reader, sink)
.map_err(|e| FsError::io(path, e))?;
Ok(hit)
}
@@ -431,6 +526,7 @@ fn scan_any_match(
fn scan_count(
searcher: &mut Searcher,
matcher: &grep_regex::RegexMatcher,
reader: &mut dyn Read,
path: &Path,
) -> Result<usize, FsError> {
let mut count = 0usize;
@@ -439,7 +535,7 @@ fn scan_count(
Ok(true)
});
searcher
.search_path(matcher, path, sink)
.search_reader(matcher, reader, sink)
.map_err(|e| FsError::io(path, e))?;
Ok(count)
}
@@ -447,11 +543,34 @@ fn scan_count(
struct ContentSink<'a> {
path: PathBuf,
lines: &'a mut Vec<ContentLine>,
retained_bytes: &'a mut usize,
truncated: &'a mut bool,
matches_seen: &'a mut usize,
offset: usize,
head_limit: usize,
}
impl ContentSink<'_> {
fn reserve(&mut self, content_bytes: usize) -> bool {
let retained = self
.path
.to_string_lossy()
.len()
.saturating_add(content_bytes)
.saturating_add(64);
let Some(next) = (*self.retained_bytes).checked_add(retained) else {
*self.truncated = true;
return false;
};
if next > MAX_GREP_RESULT_RETAINED_BYTES {
*self.truncated = true;
return false;
}
*self.retained_bytes = next;
true
}
}
impl Sink for ContentSink<'_> {
type Error = std::io::Error;
@@ -468,6 +587,9 @@ impl Sink for ContentSink<'_> {
return Ok(false);
}
if !self.reserve(mat.bytes().len()) {
return Ok(false);
}
let text = String::from_utf8_lossy(mat.bytes())
.trim_end_matches('\n')
.trim_end_matches('\r')
@@ -493,6 +615,9 @@ impl Sink for ContentSink<'_> {
if seen >= self.offset.saturating_add(self.head_limit) {
return Ok(false);
}
if !self.reserve(ctx.bytes().len()) {
return Ok(false);
}
let text = String::from_utf8_lossy(ctx.bytes())
.trim_end_matches('\n')
.trim_end_matches('\r')
@@ -506,3 +631,31 @@ impl Sink for ContentSink<'_> {
Ok(true)
}
}
#[cfg(test)]
mod bounded_reader_tests {
use std::io::{Cursor, Read as _};
use super::SourceBoundedReader;
#[test]
fn source_budget_is_shared_across_candidate_files() {
let mut remaining = 3_u64;
let mut first = SourceBoundedReader {
inner: Cursor::new(b"ab"),
remaining: &mut remaining,
};
let mut output = Vec::new();
first.read_to_end(&mut output).unwrap();
drop(first);
assert_eq!(remaining, 1);
let mut second = SourceBoundedReader {
inner: Cursor::new(b"cd"),
remaining: &mut remaining,
};
let error = second.read_to_end(&mut output).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(output, b"abc");
}
}
+6
View File
@@ -352,6 +352,12 @@ pub struct LogicalWorkdirAttachment {
/// Stable Worker-local routing key. This is not a Workdir id or display name.
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory_id: String,
#[serde(default = "default_workdir_session_capabilities")]
pub capabilities: workdir::WorkdirSessionCapabilities,
}
fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities {
workdir::WorkdirSessionCapabilities::ALL
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+54 -4
View File
@@ -1482,6 +1482,7 @@ pub struct Diagnostic {
pub enum WorkingDirectoryMaterializerKind {
#[default]
RuntimeGitClone,
ClientHostedExternal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -1612,6 +1613,15 @@ pub struct RuntimeWorkingDirectorySummary {
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
/// Public Workdir source identity. Provider routing details and host paths are intentionally absent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkingDirectorySource {
Repository { repository_key: String },
ExternalGrant { grant_id: String },
}
/// Public, provider-neutral Workdir inventory projection.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -1622,7 +1632,7 @@ pub struct WorkingDirectorySummary {
/// Optional human-facing label, never a routing key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_key: String,
pub source: WorkingDirectorySource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1662,6 +1672,9 @@ impl From<workdir::workspace::WorkingDirectoryMaterializerKind>
workdir::workspace::WorkingDirectoryMaterializerKind::RuntimeGitClone => {
Self::RuntimeGitClone
}
workdir::workspace::WorkingDirectoryMaterializerKind::ClientHostedExternal => {
Self::ClientHostedExternal
}
}
}
}
@@ -1738,7 +1751,14 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
Self {
working_directory_id: value.working_directory_id,
display_name: value.display_name,
repository_key: value.repository_key,
source: match value.source {
workdir::workspace::WorkingDirectorySource::Repository { repository_key } => {
WorkingDirectorySource::Repository { repository_key }
}
workdir::workspace::WorkingDirectorySource::ExternalGrant { grant_id } => {
WorkingDirectorySource::ExternalGrant { grant_id }
}
},
creation_selector: value.creation_selector,
creation_ref: value.creation_ref,
creation_tree: value.creation_tree,
@@ -1755,6 +1775,31 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirGrantCreateRequest {
pub provider_instance_id: String,
pub display_name: String,
pub ttl_seconds: u64,
pub read_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirGrantResponse {
pub grant_id: String,
pub workspace_id: String,
pub working_directory_id: String,
pub provider_instance_id: String,
pub display_name: String,
pub permissions: String,
pub expires_at: String,
pub generation: u64,
pub status: String,
}
/// Browser/Rust-client Workdir materialization request.
///
/// `runtime_id = None` requests Workspace default Runtime resolution and
@@ -1791,7 +1836,8 @@ pub struct WorkingDirectoryListResponse {
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryDetailResponse {
pub workspace_id: String,
pub runtime_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
pub item: WorkingDirectorySummary,
pub diagnostics: Vec<Diagnostic>,
}
@@ -3653,6 +3699,7 @@ pub fn workdir_api_typescript() -> String {
WorkingDirectoryStatusKind::decl(&config),
WorkingDirectoryCleanupTarget::decl(&config),
WorkingDirectoryOccupancy::decl(&config),
WorkingDirectorySource::decl(&config),
WorkingDirectorySummary::decl(&config),
WorkingDirectoryCreateRequest::decl(&config),
WorkingDirectoryListResponse::decl(&config),
@@ -3683,6 +3730,7 @@ pub fn worker_launch_api_typescript() -> String {
RuntimeWorkingDirectoryCleanupTarget::decl(&config),
RuntimeWorkingDirectorySummary::decl(&config),
WorkingDirectoryOccupancy::decl(&config),
WorkingDirectorySource::decl(&config),
WorkingDirectorySummary::decl(&config),
WorkerWorkspaceSummary::decl(&config),
WorkerImplementationSummary::decl(&config),
@@ -5123,7 +5171,9 @@ mod tests {
let value = serde_json::to_value(WorkingDirectorySummary {
working_directory_id: "workdir-1".into(),
display_name: None,
repository_key: "main".into(),
source: WorkingDirectorySource::Repository {
repository_key: "main".into(),
},
creation_selector: None,
creation_ref: None,
creation_tree: None,
+10 -8
View File
@@ -3,8 +3,9 @@ use std::io;
use std::time::Duration;
use client::{
BackendRuntimeListTarget, BackendWorkerSummary, WorkerSessionAvailability,
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
BackendRuntimeListTarget, BackendWorkerSummary, BackendWorkingDirectorySource,
WorkerSessionAvailability, list_backend_stopped_workers, list_backend_workers,
observe_backend_worker_session,
};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Frame;
@@ -416,10 +417,11 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
}
let attachment = &worker.workdir_attachments[0];
let directory = &attachment.working_directory;
let label = directory
.display_name
.as_deref()
.unwrap_or(&directory.repository_key);
let source_label = match &directory.source {
BackendWorkingDirectorySource::Repository { repository_key } => repository_key.as_str(),
BackendWorkingDirectorySource::ExternalGrant { .. } => "external",
};
let label = directory.display_name.as_deref().unwrap_or(source_label);
format!(
"wd:{}:{}・{}",
attachment.alias, label, directory.working_directory_id
@@ -547,7 +549,7 @@ mod tests {
"working_directory": {
"working_directory_id": "001a06a9f0202000000",
"display_name": "Checkout",
"repository_key": "main",
"source": {"kind": "repository", "repository_key": "main"},
"materializer_kind": "runtime_git_clone",
"status": "active",
"cleanliness": "clean"
@@ -591,7 +593,7 @@ mod tests {
"alias": "checkout",
"working_directory": {
"working_directory_id": "workdir-1",
"repository_key": "main",
"source": {"kind": "repository", "repository_key": "main"},
"materializer_kind": "runtime_git_clone",
"status": "active"
}
+599
View File
@@ -0,0 +1,599 @@
//! Versioned websocket DTOs for a client-hosted External Workdir provider.
//!
//! Authentication belongs to the websocket upgrade and Backend grant
//! authority. These frames intentionally contain only opaque identities,
//! logical Workdir operations, generation fences, and bounded control data.
//! They must never carry a provider host path, credential, or tunnel token.
use std::path::{Component, Path};
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;
/// 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
/// represented as a JSON byte array, so this must exceed the semantic payload
/// bound while remaining an explicit transport allocation ceiling.
pub const MAX_EXTERNAL_PROVIDER_FRAME_BYTES: usize = 8 * 1024 * 1024;
const MAX_PROTOCOL_ID_BYTES: usize = 128;
const MAX_EXTERNAL_PATH_BYTES: usize = 4 * 1024;
const MAX_EXTERNAL_PATTERN_BYTES: usize = 4 * 1024;
const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000;
const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
/// Fail-closed wire version for the External Workdir provider protocol.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct ExternalWorkdirProtocolVersion(u16);
impl ExternalWorkdirProtocolVersion {
pub const CURRENT: Self = Self(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION);
pub const fn as_u16(self) -> u16 {
self.0
}
}
impl<'de> Deserialize<'de> for ExternalWorkdirProtocolVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let version = u16::deserialize(deserializer)?;
if version == EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION {
Ok(Self::CURRENT)
} else {
Err(serde::de::Error::custom(format!(
"unsupported External Workdir provider protocol version {version}"
)))
}
}
}
macro_rules! protocol_id {
($name:ident, $label:literal) => {
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
let value = value.into();
if value.is_empty()
|| value.len() > MAX_PROTOCOL_ID_BYTES
|| value.trim() != value
|| value.chars().any(char::is_control)
{
return Err(concat!($label, " is invalid").to_string());
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
};
}
protocol_id!(ExternalProviderInstanceId, "provider instance id");
protocol_id!(ExternalWorkdirGrantId, "External Workdir grant id");
protocol_id!(ExternalWorkdirOperationId, "External Workdir operation id");
/// First provider frame on a newly authenticated outbound websocket.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirProviderRegistration {
pub provider_instance_id: ExternalProviderInstanceId,
pub grant_id: ExternalWorkdirGrantId,
pub workdir_id: WorkdirId,
/// Monotonic grant connection generation. Reconnect advances it and fences
/// every frame from an older connection.
pub generation: u64,
pub capabilities: WorkdirSessionCapabilities,
pub read_limits: BoundedReadLimits,
}
fn is_root_relative(path: &WorkdirPath) -> bool {
let path_value = path.as_str();
let path = Path::new(path_value);
path_value.len() <= MAX_EXTERNAL_PATH_BYTES
&& !path.is_absolute()
&& !path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
}
fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
let path_is_valid = match operation {
WorkdirSessionOperation::AuthorizeScope(request) => {
is_root_relative(&request.path)
&& request.rules.len() <= MAX_EXTERNAL_SCOPE_RULES
&& request
.rules
.iter()
.all(|rule| is_root_relative(&rule.target))
}
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
is_root_relative(&request.left.target) && is_root_relative(&request.right.target)
}
WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path),
WorkdirSessionOperation::Read(request) => {
is_root_relative(&request.path)
&& request.limit <= 1_000_000
&& request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
}
WorkdirSessionOperation::List(request) => {
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
}
WorkdirSessionOperation::Glob(request) => {
is_root_relative(&request.path)
&& request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
}
WorkdirSessionOperation::Grep(request) => {
is_root_relative(&request.path)
&& request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES
&& request
.glob
.as_ref()
.is_none_or(|glob| glob.len() <= MAX_EXTERNAL_PATTERN_BYTES)
&& request
.file_type
.as_ref()
.is_none_or(|file_type| file_type.len() <= 128)
&& request.before_context <= MAX_EXTERNAL_GREP_CONTEXT
&& request.after_context <= MAX_EXTERNAL_GREP_CONTEXT
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
&& request.offset <= 1_000_000
}
WorkdirSessionOperation::Write(_)
| WorkdirSessionOperation::Edit(_)
| WorkdirSessionOperation::CommandStart(_)
| WorkdirSessionOperation::CommandStatus(_)
| WorkdirSessionOperation::CommandOutput(_)
| WorkdirSessionOperation::CommandCancel(_) => {
return Err("operation is not available to a read-only External Workdir".to_string());
}
};
if path_is_valid {
Ok(())
} else {
Err("External Workdir operation paths must be root-relative".to_string())
}
}
/// Validated read-only subset of the shared Workdir operation contract.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct ExternalWorkdirOperation(WorkdirSessionOperation);
impl ExternalWorkdirOperation {
pub fn into_inner(self) -> WorkdirSessionOperation {
self.0
}
pub fn as_inner(&self) -> &WorkdirSessionOperation {
&self.0
}
}
impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
type Error = String;
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
validate_read_only_operation(&operation)?;
Ok(Self(operation))
}
}
impl<'de> Deserialize<'de> for ExternalWorkdirOperation {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let operation = WorkdirSessionOperation::deserialize(deserializer)?;
Self::try_from(operation).map_err(serde::de::Error::custom)
}
}
fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> bool {
paths
.into_iter()
.try_fold(0_usize, |retained, path| {
retained.checked_add(path.as_str().len().saturating_add(64))
})
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
}
fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
let result_is_valid = match result {
WorkdirSessionOperationResult::AuthorizeScope
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path),
WorkdirSessionOperationResult::Read(result) => {
is_root_relative(&result.path)
&& result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
}
WorkdirSessionOperationResult::List(result) => {
result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS
&& result
.entries
.iter()
.all(|entry| is_root_relative(&entry.path))
&& result_paths_fit(result.entries.iter().map(|entry| &entry.path))
}
WorkdirSessionOperationResult::Glob(result) => {
result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
&& result.paths.iter().all(is_root_relative)
&& result_paths_fit(&result.paths)
}
WorkdirSessionOperationResult::Grep(result) => {
result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
&& result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS
}
WorkdirSessionOperationResult::Write(_)
| WorkdirSessionOperationResult::Edit(_)
| WorkdirSessionOperationResult::CommandStart(_)
| WorkdirSessionOperationResult::CommandStatus(_)
| WorkdirSessionOperationResult::CommandOutput(_)
| WorkdirSessionOperationResult::CommandCancel => {
return Err(
"operation result is not available to a read-only External Workdir".to_string(),
);
}
};
if result_is_valid {
Ok(())
} else {
Err("External Workdir result exceeds path or response bounds".to_string())
}
}
/// Validated read-only subset of the shared Workdir operation result contract.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct ExternalWorkdirOperationResult(WorkdirSessionOperationResult);
impl ExternalWorkdirOperationResult {
pub fn into_inner(self) -> WorkdirSessionOperationResult {
self.0
}
pub fn as_inner(&self) -> &WorkdirSessionOperationResult {
&self.0
}
}
impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
type Error = String;
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
validate_read_only_result(&result)?;
Ok(Self(result))
}
}
impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let result = WorkdirSessionOperationResult::deserialize(deserializer)?;
Self::try_from(result).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
pub enum ExternalWorkdirOperationOutcome {
Completed {
result: ExternalWorkdirOperationResult,
},
Failed {
error: WorkdirTransportError,
},
Cancelled,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExternalWorkdirRevokeReason {
UserRequested,
Expired,
AuthorityWithdrawn,
}
/// Frames sent by the authenticated Backend to the outbound provider.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ExternalWorkdirServerMessage {
Registered {
generation: u64,
heartbeat_interval_ms: u64,
max_in_flight_operations: usize,
},
Operation {
generation: u64,
operation_id: ExternalWorkdirOperationId,
operation: ExternalWorkdirOperation,
},
Cancel {
generation: u64,
operation_id: ExternalWorkdirOperationId,
},
Revoke {
generation: u64,
reason: ExternalWorkdirRevokeReason,
},
Heartbeat {
generation: u64,
sequence: u64,
},
}
/// Frames sent by the client-hosted provider over its outbound connection.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ExternalWorkdirProviderMessage {
Register {
registration: ExternalWorkdirProviderRegistration,
},
OperationResult {
generation: u64,
operation_id: ExternalWorkdirOperationId,
outcome: ExternalWorkdirOperationOutcome,
},
Heartbeat {
generation: u64,
sequence: u64,
},
RevokeAcknowledged {
generation: u64,
},
}
/// Versioned server-to-provider websocket envelope.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirServerFrame {
pub version: ExternalWorkdirProtocolVersion,
pub message: ExternalWorkdirServerMessage,
}
impl ExternalWorkdirServerFrame {
pub fn current(message: ExternalWorkdirServerMessage) -> Self {
Self {
version: ExternalWorkdirProtocolVersion::CURRENT,
message,
}
}
}
/// Versioned provider-to-server websocket envelope.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirProviderFrame {
pub version: ExternalWorkdirProtocolVersion,
pub message: ExternalWorkdirProviderMessage,
}
impl ExternalWorkdirProviderFrame {
pub fn current(message: ExternalWorkdirProviderMessage) -> Self {
Self {
version: ExternalWorkdirProtocolVersion::CURRENT,
message,
}
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
use crate::{CommandRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath};
fn assert_no_provider_authority(value: &Value) {
match value {
Value::Object(object) => {
for (key, value) in object {
assert!(
![
"host_path",
"root",
"credential",
"credentials",
"token",
"access_token",
"bearer_token",
"endpoint",
"url",
]
.contains(&key.as_str()),
"provider authority field leaked into protocol: {key}"
);
assert_no_provider_authority(value);
}
}
Value::Array(values) => values.iter().for_each(assert_no_provider_authority),
Value::String(value) => {
assert!(!value.contains("/home/operator/private"));
assert!(!value.contains("provider-secret"));
}
_ => {}
}
}
#[test]
fn protocol_is_versioned_correlated_and_contains_no_provider_authority() {
let workdir = Workdir::new("working-directory-1");
let registration =
ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register {
registration: ExternalWorkdirProviderRegistration {
provider_instance_id: ExternalProviderInstanceId::new("provider-1").unwrap(),
grant_id: ExternalWorkdirGrantId::new("grant-1").unwrap(),
workdir_id: workdir.id().clone(),
generation: 7,
capabilities: WorkdirSessionCapabilities::READ_ONLY,
read_limits: BoundedReadLimits::new(4096, 1024).unwrap(),
},
});
let operation =
ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Operation {
generation: 7,
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
operation: WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new("sessions/events.jsonl").unwrap(),
offset: 0,
limit: 20,
max_bytes: 1024,
})
.try_into()
.unwrap(),
});
for value in [
serde_json::to_value(registration).unwrap(),
serde_json::to_value(operation).unwrap(),
serde_json::to_value(ExternalWorkdirServerFrame::current(
ExternalWorkdirServerMessage::Cancel {
generation: 7,
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
},
))
.unwrap(),
serde_json::to_value(ExternalWorkdirServerFrame::current(
ExternalWorkdirServerMessage::Revoke {
generation: 7,
reason: ExternalWorkdirRevokeReason::Expired,
},
))
.unwrap(),
serde_json::to_value(ExternalWorkdirProviderFrame::current(
ExternalWorkdirProviderMessage::Heartbeat {
generation: 7,
sequence: 3,
},
))
.unwrap(),
] {
assert_eq!(
value.get("version").and_then(Value::as_u64),
Some(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION.into())
);
assert_no_provider_authority(&value);
}
}
#[test]
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
let future = r#"{
"version": 2,
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
}"#;
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
let invalid_limits = r#"{
"max_source_bytes": 1024,
"max_response_bytes": 2048
}"#;
assert!(serde_json::from_str::<BoundedReadLimits>(invalid_limits).is_err());
}
#[test]
fn read_only_protocol_rejects_host_paths_and_command_payloads() {
let absolute = WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
offset: 0,
limit: 1,
max_bytes: 1024,
});
assert!(ExternalWorkdirOperation::try_from(absolute).is_err());
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
command: "cat /home/operator/private/session.log".to_string(),
timeout_secs: 1,
output_limit: 1024,
cwd: None,
spill_dir: Some("/home/operator/private/output".into()),
tool_call_id: None,
});
assert!(ExternalWorkdirOperation::try_from(command).is_err());
}
#[test]
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
let unknown = r#"{
"version": 1,
"message": {
"kind": "heartbeat",
"generation": 1,
"sequence": 1,
"credential": "must-not-be-ignored"
}
}"#;
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(unknown).is_err());
let mut nested = serde_json::to_value(ExternalWorkdirServerFrame::current(
ExternalWorkdirServerMessage::Operation {
generation: 1,
operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(),
operation: WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new("events.jsonl").unwrap(),
offset: 0,
limit: 1,
max_bytes: 1024,
})
.try_into()
.unwrap(),
},
))
.unwrap();
nested["message"]["operation"]["request"]["credential"] =
Value::String("must-not-be-ignored".to_string());
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(nested).is_err());
let unbounded = WorkdirSessionOperation::List(ListRequest {
path: WorkdirPath::root(),
limit: MAX_EXTERNAL_RESULT_ITEMS + 1,
});
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
match_count: 1,
matched_files: 1,
truncated: true,
});
assert!(ExternalWorkdirOperationResult::try_from(oversized_result).is_err());
}
#[test]
fn protocol_ids_are_bounded_and_control_free() {
assert!(ExternalWorkdirOperationId::new("").is_err());
assert!(ExternalWorkdirOperationId::new(" operation-1").is_err());
assert!(ExternalWorkdirOperationId::new("operation\n1").is_err());
assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err());
assert!(ExternalWorkdirOperationId::new("operation-1").is_ok());
}
}
+257
View File
@@ -0,0 +1,257 @@
use crate::http::{
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
};
use crate::{
BoundedReadLimits, CommandRequest, EditRequest, LocalWorkdirSession, ReadRequest, Workdir,
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
WorkdirSessionCapability, WriteRequest,
};
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
LocalWorkdirSession::external_read_only(Workdir::new("external-workdir-1"), root.path(), limits)
.unwrap()
}
#[tokio::test]
async fn external_local_provider_is_strictly_read_only() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("item.txt"), "original").unwrap();
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
assert_eq!(
session.capabilities(),
WorkdirSessionCapabilities::READ_ONLY
);
let write = WorkdirSession::write(
&session,
WriteRequest {
path: WorkdirPath::new("item.txt").unwrap(),
content: b"changed".to_vec(),
expected_hash: None,
},
)
.await
.unwrap_err();
assert!(matches!(
write,
WorkdirError::Unsupported(WorkdirSessionCapability::Write)
));
let edit = WorkdirSession::edit(
&session,
EditRequest {
path: WorkdirPath::new("item.txt").unwrap(),
old_string: "original".to_string(),
new_string: "changed".to_string(),
replace_all: false,
expected_hash: [0; 32],
},
)
.await
.unwrap_err();
assert!(matches!(
edit,
WorkdirError::Unsupported(WorkdirSessionCapability::Edit)
));
let command = WorkdirSession::start_command(
&session,
CommandRequest {
command: "echo changed > item.txt".to_string(),
timeout_secs: 1,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
)
.await
.unwrap_err();
assert!(matches!(
command,
WorkdirError::Unsupported(WorkdirSessionCapability::Command)
));
assert_eq!(
std::fs::read_to_string(root.path().join("item.txt")).unwrap(),
"original"
);
}
#[tokio::test]
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("item.txt"), "visible").unwrap();
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
let result = dispatch_workdir_session_operation(
&session,
WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new("item.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
}),
)
.await
.unwrap();
assert!(matches!(
result,
WorkdirSessionOperationResult::Read(ref result) if result.bytes == b"visible"
));
let error = dispatch_workdir_session_operation(
&session,
WorkdirSessionOperation::Write(WriteRequest {
path: WorkdirPath::new("item.txt").unwrap(),
content: b"blocked".to_vec(),
expected_hash: None,
}),
)
.await
.unwrap_err();
assert!(matches!(
error,
WorkdirError::Unsupported(WorkdirSessionCapability::Write)
));
}
#[tokio::test]
async fn external_local_provider_rejects_absolute_and_parent_paths() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
assert!(WorkdirPath::new("../outside.txt").is_err());
let absolute = WorkdirPath::new_scoped(outside.path().to_string_lossy()).unwrap();
let error = WorkdirSession::read(
&session,
ReadRequest {
path: absolute,
offset: 0,
limit: 10,
max_bytes: 1024,
},
)
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::InvalidPath(_)));
}
#[tokio::test]
async fn external_local_provider_enforces_source_and_response_bounds() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("large.log"), vec![b'x'; 2048]).unwrap();
std::fs::write(root.path().join("small.log"), b"0123456789\n").unwrap();
let session = external_session(&root, BoundedReadLimits::new(1024, 4).unwrap());
let error = WorkdirSession::read(
&session,
ReadRequest {
path: WorkdirPath::new("large.log").unwrap(),
offset: 0,
limit: 10,
max_bytes: usize::MAX,
},
)
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::InvalidArgument(_)));
let bounded = WorkdirSession::read(
&session,
ReadRequest {
path: WorkdirPath::new("small.log").unwrap(),
offset: 0,
limit: 10,
max_bytes: usize::MAX,
},
)
.await
.unwrap();
assert_eq!(bounded.bytes, b"0123");
assert!(bounded.truncated);
}
#[cfg(unix)]
#[tokio::test]
async fn external_local_provider_rejects_symlink_roots_and_traversal() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
symlink(
outside.path().join("secret.txt"),
root.path().join("outside.txt"),
)
.unwrap();
std::fs::write(root.path().join("inside.txt"), "inside").unwrap();
symlink(
root.path().join("inside.txt"),
root.path().join("inside-link.txt"),
)
.unwrap();
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
for path in ["outside.txt", "inside-link.txt"] {
let error = WorkdirSession::read(
&session,
ReadRequest {
path: WorkdirPath::new(path).unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
},
)
.await
.unwrap_err();
assert!(matches!(
error,
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
));
}
let parent = tempfile::tempdir().unwrap();
let root_link = parent.path().join("shared-root");
symlink(root.path(), &root_link).unwrap();
let error = LocalWorkdirSession::external_read_only(
Workdir::new("external-workdir-2"),
&root_link,
BoundedReadLimits::new(4096, 1024).unwrap(),
)
.unwrap_err();
assert!(matches!(error, WorkdirError::Denied(_)));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn external_local_provider_pins_the_approved_root_across_directory_replacement() {
let parent = tempfile::tempdir().unwrap();
let approved = parent.path().join("shared");
std::fs::create_dir(&approved).unwrap();
std::fs::write(approved.join("item.txt"), "approved").unwrap();
let session = LocalWorkdirSession::external_read_only(
Workdir::new("external-workdir-pinned-root"),
&approved,
BoundedReadLimits::new(4096, 1024).unwrap(),
)
.unwrap();
std::fs::rename(&approved, parent.path().join("approved-original")).unwrap();
std::fs::create_dir(&approved).unwrap();
std::fs::write(approved.join("item.txt"), "replacement").unwrap();
let read = WorkdirSession::read(
&session,
ReadRequest {
path: WorkdirPath::new("item.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(read.bytes, b"approved");
}
+80 -4
View File
@@ -11,8 +11,8 @@ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
WriteRequest, WriteResult,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WriteRequest, WriteResult,
};
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -54,7 +54,12 @@ pub struct OpenWorkdirSessionResponse {
/// One provider-side Workdir operation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
#[serde(
tag = "operation",
content = "request",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
@@ -80,7 +85,12 @@ pub struct WorkdirSessionOperationRequest {
/// Typed result paired with [`WorkdirSessionOperation`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
#[serde(
tag = "operation",
content = "result",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
@@ -97,6 +107,72 @@ pub enum WorkdirSessionOperationResult {
CommandCancel,
}
/// Dispatch one typed operation against any provider-neutral Workdir session.
///
/// Runtime HTTP handlers, outbound External providers, and Backend brokers can
/// share this operation/result pairing while keeping transport authorization,
/// generation fencing, cancellation timeouts, and session lookup at their own
/// boundaries.
pub async fn dispatch_workdir_session_operation(
session: &dyn WorkdirSession,
operation: WorkdirSessionOperation,
) -> Result<WorkdirSessionOperationResult, WorkdirError> {
match operation {
WorkdirSessionOperation::AuthorizeScope(request) => session
.authorize_scope_path(request)
.await
.map(|()| WorkdirSessionOperationResult::AuthorizeScope),
WorkdirSessionOperation::ScopeRulesOverlap(request) => session
.scope_rules_overlap(request)
.await
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
WorkdirSessionOperation::Stat(request) => session
.stat(request)
.await
.map(WorkdirSessionOperationResult::Stat),
WorkdirSessionOperation::Read(request) => session
.read(request)
.await
.map(WorkdirSessionOperationResult::Read),
WorkdirSessionOperation::Write(request) => session
.write(request)
.await
.map(WorkdirSessionOperationResult::Write),
WorkdirSessionOperation::Edit(request) => session
.edit(request)
.await
.map(WorkdirSessionOperationResult::Edit),
WorkdirSessionOperation::List(request) => session
.list(request)
.await
.map(WorkdirSessionOperationResult::List),
WorkdirSessionOperation::Glob(request) => session
.glob(request)
.await
.map(WorkdirSessionOperationResult::Glob),
WorkdirSessionOperation::Grep(request) => session
.grep(request)
.await
.map(WorkdirSessionOperationResult::Grep),
WorkdirSessionOperation::CommandStart(request) => session
.start_command(request)
.await
.map(WorkdirSessionOperationResult::CommandStart),
WorkdirSessionOperation::CommandStatus(handle) => session
.command_status(handle)
.await
.map(WorkdirSessionOperationResult::CommandStatus),
WorkdirSessionOperation::CommandOutput(request) => session
.command_output(request)
.await
.map(WorkdirSessionOperationResult::CommandOutput),
WorkdirSessionOperation::CommandCancel(handle) => session
.cancel_command(handle)
.await
.map(|()| WorkdirSessionOperationResult::CommandCancel),
}
}
/// Stable, host-path-free error code crossing the Runtime boundary.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
+8 -3
View File
@@ -5,6 +5,7 @@
//! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup.
pub mod external;
pub mod http;
mod local;
mod operation;
@@ -12,6 +13,9 @@ mod router;
mod scope;
pub mod workspace;
#[cfg(test)]
mod external_local_tests;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -20,10 +24,11 @@ use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
pub use fs_operation::{
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
};
pub use http::dispatch_workdir_session_operation;
pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
};
+260 -23
View File
@@ -18,17 +18,17 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
use sha2::{Digest, Sha256};
use tokio::process::Command;
use tokio::sync::{Mutex, broadcast, watch};
use tokio::task::JoinHandle;
use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
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,
@@ -203,26 +203,112 @@ impl CommandTelemetry {
}
#[derive(Debug)]
struct ScopeAccess(Arc<Scope>);
struct ScopeAccess {
scope: Arc<Scope>,
root: PathBuf,
pinned_root: Option<Arc<std::fs::File>>,
reject_symlinks: bool,
}
impl ScopeAccess {
fn new(
scope: Arc<Scope>,
root: &Path,
pinned_root: Option<Arc<std::fs::File>>,
reject_symlinks: bool,
) -> Self {
Self {
scope,
root: root.to_path_buf(),
pinned_root,
reject_symlinks,
}
}
fn open_confined(&self, path: &Path) -> std::io::Result<std::fs::File> {
let root = self.pinned_root.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"External Workdir root handle is unavailable",
)
})?;
let relative = path.strip_prefix(&self.root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
)
})?;
fs_operation::open_beneath_no_symlinks_at(root, relative)
}
fn path_is_confined(&self, logical: &Path, resolved: &Path) -> bool {
!self.reject_symlinks
|| (logical.starts_with(&self.root)
&& resolved.starts_with(&self.root)
&& fs_operation::first_symlink(logical).is_none())
}
}
impl fs_operation::FsAccessPolicy for ScopeAccess {
fn is_readable(&self, path: &Path) -> bool {
self.0.is_readable(path)
self.scope.is_readable(path)
}
fn is_writable(&self, path: &Path) -> bool {
self.0.is_writable(path)
self.scope.is_writable(path)
}
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
if self.reject_symlinks {
self.open_confined(resolved)
} else {
std::fs::File::open(resolved)
}
}
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
if self.reject_symlinks {
self.open_confined(resolved)?.metadata()
} else {
std::fs::symlink_metadata(logical)
}
}
fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
if !self.reject_symlinks {
return std::fs::read_dir(resolved);
}
#[cfg(target_os = "linux")]
{
use std::os::fd::AsRawFd;
let directory = self.open_confined(resolved)?;
if !directory.metadata()?.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
"path is not a directory",
));
}
return std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()));
}
#[cfg(not(target_os = "linux"))]
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"root-confined directory enumeration is unavailable on this platform",
))
}
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
matches!(
self.0.permission_at_paths(logical, resolved),
Some(Permission::Read | Permission::Write)
)
self.path_is_confined(logical, resolved)
&& matches!(
self.scope.permission_at_paths(logical, resolved),
Some(Permission::Read | Permission::Write)
)
}
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
self.path_is_confined(logical, resolved)
&& self.scope.permission_at_paths(logical, resolved) == Some(Permission::Write)
}
}
@@ -265,9 +351,12 @@ fn rule_targets(
struct LocalWorkdirSessionInner {
workdir: Workdir,
root: PathBuf,
pinned_root: Option<Arc<std::fs::File>>,
scope: SharedScope,
cwd: PathBuf,
capabilities: WorkdirSessionCapabilities,
read_limits: Option<BoundedReadLimits>,
reject_symlinks: bool,
closed: AtomicBool,
close_lock: Mutex<()>,
next_command_id: AtomicU64,
@@ -388,14 +477,113 @@ impl LocalWorkdirSession {
capabilities: WorkdirSessionCapabilities,
command_environment: BTreeMap<String, String>,
resources: Vec<Arc<dyn WorkdirSessionResource>>,
) -> Self {
Self::materialized_bound_with_policy(
workdir,
root,
cwd,
scope,
capabilities,
command_environment,
resources,
None,
None,
false,
)
}
/// Construct a strictly read-only provider session for an
/// authority-assigned Workdir and an operator-selected local directory.
///
/// The selected directory is canonicalized and fixed for the lifetime of
/// the session. A symlink selected as the root is rejected, and all
/// operation paths are confined below the canonical root without following
/// symbolic links. Read source and response sizes are provider-bounded.
pub fn external_read_only(
workdir: Workdir,
directory: impl AsRef<Path>,
read_limits: BoundedReadLimits,
) -> Result<Self, WorkdirError> {
read_limits.validate().map_err(WorkdirError::from)?;
let directory = directory.as_ref();
if !directory.is_absolute() {
return Err(WorkdirError::InvalidArgument(
"External Workdir directory must be absolute".to_string(),
));
}
let selected_metadata = std::fs::symlink_metadata(directory)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if selected_metadata.file_type().is_symlink() {
return Err(WorkdirError::Denied(
"External Workdir root must not be a symbolic link".to_string(),
));
}
let root = directory
.canonicalize()
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if !root
.metadata()
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?
.is_dir()
{
return Err(WorkdirError::InvalidArgument(
"External Workdir root must be a directory".to_string(),
));
}
let pinned_root = Arc::new(
fs_operation::open_root_no_symlinks(&root)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?,
);
let scope = Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.clone(),
permission: Permission::Read,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}],
deny: Vec::new(),
})
.map_err(|_| {
WorkdirError::InvalidArgument(
"External Workdir read scope could not be established".to_string(),
)
})?;
Ok(Self::materialized_bound_with_policy(
workdir,
root.clone(),
root,
SharedScope::new(scope),
WorkdirSessionCapabilities::READ_ONLY,
BTreeMap::new(),
Vec::new(),
Some(pinned_root),
Some(read_limits),
true,
))
}
fn materialized_bound_with_policy(
workdir: Workdir,
root: PathBuf,
cwd: PathBuf,
scope: SharedScope,
capabilities: WorkdirSessionCapabilities,
command_environment: BTreeMap<String, String>,
resources: Vec<Arc<dyn WorkdirSessionResource>>,
pinned_root: Option<Arc<std::fs::File>>,
read_limits: Option<BoundedReadLimits>,
reject_symlinks: bool,
) -> Self {
Self {
inner: Arc::new(LocalWorkdirSessionInner {
workdir,
root,
pinned_root,
scope,
cwd,
capabilities,
read_limits,
reject_symlinks,
closed: AtomicBool::new(false),
close_lock: Mutex::new(()),
next_command_id: AtomicU64::new(1),
@@ -592,6 +780,37 @@ impl LocalWorkdirSession {
self.inner.root.join(path.as_str())
}
}
fn validate_operation_path(&self, path: &WorkdirPath) -> Result<(), WorkdirError> {
if !self.inner.reject_symlinks {
return Ok(());
}
let path = Path::new(path.as_str());
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return Err(WorkdirError::InvalidPath(
"External Workdir paths must be root-relative".to_string(),
));
}
Ok(())
}
fn scope_access(&self) -> ScopeAccess {
ScopeAccess::new(
self.inner.scope.snapshot(),
&self.inner.root,
self.inner.pinned_root.clone(),
self.inner.reject_symlinks,
)
}
}
#[async_trait]
@@ -609,7 +828,13 @@ impl WorkdirSession for LocalWorkdirSession {
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.ensure_open()?;
self.validate_operation_path(&request.path)?;
let logical = self.inner.root.join(request.path.as_str());
if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() {
return Err(WorkdirError::Denied(
"External Workdir scope cannot traverse symbolic links".to_string(),
));
}
let resolved = fs_operation::resolve_access_path(&logical)
.map_err(|error| WorkdirError::io(&logical, error))?;
let parent_permission = self
@@ -687,7 +912,8 @@ impl WorkdirSession for LocalWorkdirSession {
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
let access = ScopeAccess(self.inner.scope.snapshot());
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
fs_operation::run_stat(&self.inner.root, request, &access)
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
@@ -696,16 +922,23 @@ impl WorkdirSession for LocalWorkdirSession {
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
let access = ScopeAccess(self.inner.scope.snapshot());
fs_operation::run_read(&self.inner.root, request, &access)
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
match self.inner.read_limits {
Some(limits) => {
fs_operation::run_read_bounded(&self.inner.root, request, &access, limits)
}
None => fs_operation::run_read(&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();
let access = ScopeAccess(self.inner.scope.snapshot());
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
fs_operation::run_write(&self.inner.root, request, &access)
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
@@ -714,7 +947,8 @@ impl WorkdirSession for LocalWorkdirSession {
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Edit)?;
let logical = request.path.clone();
let access = ScopeAccess(self.inner.scope.snapshot());
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
fs_operation::run_edit(&self.inner.root, request, &access)
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
@@ -723,7 +957,8 @@ impl WorkdirSession for LocalWorkdirSession {
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
let access = ScopeAccess(self.inner.scope.snapshot());
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
fs_operation::run_list(&self.inner.root, request, &access)
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
@@ -733,7 +968,8 @@ impl WorkdirSession for LocalWorkdirSession {
self.ensure_capability(WorkdirSessionCapability::Glob)?;
let logical = request.path.clone();
let base = self.resolve(&request.path);
let access = ScopeAccess(self.inner.scope.snapshot());
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
fs_operation::run_glob(&self.inner.root, &base, request, &access)
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
@@ -743,7 +979,8 @@ impl WorkdirSession for LocalWorkdirSession {
self.ensure_capability(WorkdirSessionCapability::Grep)?;
let base = self.resolve(&request.path);
let logical = request.path.clone();
let access = ScopeAccess(self.inner.scope.snapshot());
self.validate_operation_path(&request.path)?;
let access = self.scope_access();
fs_operation::run_grep(&self.inner.root, base, request, &access)
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
+12 -2
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
pub enum WorkingDirectoryMaterializerKind {
#[default]
RuntimeGitClone,
ClientHostedExternal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -98,6 +99,13 @@ pub struct RuntimeWorkingDirectorySummary {
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkingDirectorySource {
Repository { repository_key: String },
ExternalGrant { grant_id: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectorySummary {
@@ -105,7 +113,7 @@ pub struct WorkingDirectorySummary {
/// Optional human-facing label. Never used for attachment routing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_key: String,
pub source: WorkingDirectorySource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -215,7 +223,9 @@ mod tests {
let summary = WorkingDirectorySummary {
working_directory_id: "wd-1".to_string(),
display_name: Some("Checkout".to_string()),
repository_key: "repo-1".to_string(),
source: WorkingDirectorySource::Repository {
repository_key: "repo-1".to_string(),
},
creation_selector: None,
creation_ref: None,
creation_tree: None,
+6
View File
@@ -204,6 +204,12 @@ pub struct RepositoryRefObservation {
pub struct LogicalWorkdirAttachment {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory_id: String,
#[serde(default = "default_workdir_session_capabilities")]
pub capabilities: workdir::WorkdirSessionCapabilities,
}
fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities {
workdir::WorkdirSessionCapabilities::ALL
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+1
View File
@@ -2409,6 +2409,7 @@ mod tests {
[LogicalWorkdirAttachment {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory_id: "remote-workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
}]
);
}
+2
View File
@@ -4293,6 +4293,7 @@ fn logical_workdir_attachments_from_statuses(
.summary
.working_directory_id
.clone(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
})
.collect()
}
@@ -6228,6 +6229,7 @@ mod tests {
let attachment = LogicalWorkdirAttachment {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory_id: "workdir-on-another-runtime".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
};
let detail = runtime
+7 -1
View File
@@ -817,10 +817,11 @@ fn restored_workdir_router(
router
.attach(
attachment.alias.clone(),
WorkspaceAttachedWorkdirSession::handle_for_workdir(
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities(
workspace_client.clone(),
attachment.alias.as_str(),
&attachment.working_directory_id,
attachment.capabilities,
),
)
.map_err(|error| {
@@ -2971,6 +2972,7 @@ mod tests {
&[LogicalWorkdirAttachment {
alias: alias.clone(),
working_directory_id: "remote-workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
}],
scope,
client,
@@ -2979,6 +2981,10 @@ mod tests {
let session = router.session(&alias).unwrap();
assert_eq!(session.workdir().id().as_str(), "remote-workdir");
assert_eq!(
session.capabilities(),
workdir::WorkdirSessionCapabilities::READ_ONLY
);
}
#[test]
@@ -215,6 +215,7 @@ pub struct WorkspaceAttachedWorkdirSession {
client: Arc<dyn WorkspaceClient>,
target_workdir: String,
workdir: Workdir,
capabilities: WorkdirSessionCapabilities,
}
impl WorkspaceAttachedWorkdirSession {
@@ -229,11 +230,26 @@ impl WorkspaceAttachedWorkdirSession {
client: Arc<dyn WorkspaceClient>,
target_workdir: impl Into<String>,
workdir_id: impl Into<String>,
) -> WorkdirSessionHandle {
Self::handle_for_workdir_with_capabilities(
client,
target_workdir,
workdir_id,
WorkdirSessionCapabilities::ALL,
)
}
pub fn handle_for_workdir_with_capabilities(
client: Arc<dyn WorkspaceClient>,
target_workdir: impl Into<String>,
workdir_id: impl Into<String>,
capabilities: WorkdirSessionCapabilities,
) -> WorkdirSessionHandle {
Arc::new(Self {
client,
target_workdir: target_workdir.into(),
workdir: Workdir::new(workdir_id),
capabilities,
})
}
@@ -304,7 +320,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
WorkdirSessionCapabilities::ALL
self.capabilities
}
async fn authorize_scope_path(
@@ -522,10 +538,11 @@ impl WorkspaceHttpWorkdirBackend {
let response = self.attach_response(alias, working_directory_id)?;
if let Err(error) = self.session_router.attach(
alias_key.clone(),
WorkspaceAttachedWorkdirSession::handle_for_workdir(
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities(
self.client.clone(),
alias,
working_directory_id,
response.capabilities,
),
) {
let _ = self.detach(alias);
@@ -924,6 +941,7 @@ struct WorkdirAttachmentResponse {
workspace_id: String,
alias: String,
working_directory_id: String,
capabilities: WorkdirSessionCapabilities,
attached: bool,
}
@@ -1014,7 +1032,7 @@ mod tests {
fn workdir_json(id: &str) -> serde_json::Value {
json!({
"working_directory_id": id,
"repository_key": "main",
"source": {"kind": "repository", "repository_key": "main"},
"creation_selector": "refs/heads/main",
"creation_ref": "0123456789abcdef",
"materializer_kind": "runtime_git_clone",
@@ -1195,12 +1213,14 @@ mod tests {
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-created",
"capabilities": {"bits": 63},
"attached": true
})),
response(json!({
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-created",
"capabilities": {"bits": 63},
"attached": false
})),
response(json!({
@@ -1510,6 +1530,7 @@ mod tests {
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-attached",
"capabilities": {"bits": 63},
"attached": false
}))]));
let router = Arc::new(workdir::WorkdirSessionRouter::new());
@@ -1582,6 +1603,7 @@ mod tests {
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-attached",
"capabilities": {"bits": 63},
"attached": true
})),
response(json!({
@@ -1596,6 +1618,7 @@ mod tests {
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-attached",
"capabilities": {"bits": 63},
"attached": false
})),
]));
@@ -1640,12 +1663,40 @@ mod tests {
);
}
#[test]
fn attach_propagates_provider_read_only_capabilities_to_the_alias_session() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"workspace_id": "workspace/test",
"alias": "external",
"working_directory_id": "external-workdir",
"capabilities": {"bits": 25},
"attached": true
}))]));
let backend = WorkspaceHttpWorkdirBackend::new(client);
backend
.attach(WorkdirAttachInput {
alias: "external".to_string(),
working_directory_id: "external-workdir".to_string(),
})
.unwrap();
let session = backend
.session_router
.session(&workdir::WorkdirAttachmentAlias::new("external").unwrap())
.unwrap();
assert_eq!(
session.capabilities(),
WorkdirSessionCapabilities::READ_ONLY
);
}
#[tokio::test]
async fn successful_attach_reopens_internal_subworker_admission() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-attached",
"capabilities": {"bits": 63},
"attached": true
}))]));
let reopen_calls = Arc::new(AtomicUsize::new(0));
+32 -3
View File
@@ -668,8 +668,10 @@ CREATE TABLE "workdir_registry" (
workspace_id TEXT NOT NULL,
workdir_id TEXT NOT NULL,
display_name TEXT,
runtime_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')),
runtime_id TEXT,
repository_id TEXT,
external_grant_id TEXT,
creation_selector TEXT,
creation_ref TEXT,
materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')),
@@ -679,9 +681,34 @@ CREATE TABLE "workdir_registry" (
current_selector TEXT,
current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER,
PRIMARY KEY (workspace_id, workdir_id),
CHECK (
(source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL)
OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL)
),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, repository_id)
REFERENCES "repositories"(workspace_id, repository_id)
REFERENCES "repositories"(workspace_id, repository_id),
FOREIGN KEY (workspace_id, external_grant_id)
REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE external_workdir_grants (
grant_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
workdir_id TEXT NOT NULL,
provider_instance_id TEXT NOT NULL,
display_name TEXT NOT NULL,
permissions TEXT NOT NULL CHECK (permissions = 'read_only'),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
generation INTEGER NOT NULL CHECK (generation > 0),
status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')),
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, grant_id),
UNIQUE (workspace_id, workdir_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, workdir_id)
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE workdir_removal_operations (
workspace_id TEXT NOT NULL,
@@ -1049,6 +1076,8 @@ CREATE INDEX idx_typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
CREATE INDEX idx_workdir_registry_workspace_updated
ON workdir_registry(workspace_id, updated_at DESC);
CREATE INDEX idx_external_workdir_grants_status_expiry
ON external_workdir_grants(workspace_id, status, expires_at);
CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending
ON workdir_removal_operations(workspace_id, workdir_id)
WHERE state = 'pending';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+45 -27
View File
@@ -11,7 +11,7 @@ use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::store::WorkdirRegistryRecord;
use crate::store::{WorkdirRegistryRecord, WorkdirRegistrySource};
use crate::{Error, Result, SqliteWorkspaceStore};
const MAX_REASON_BYTES: usize = 500;
@@ -95,8 +95,7 @@ pub fn workdir_materialization_fingerprint(record: &WorkdirRegistryRecord) -> St
let bytes = serde_json::to_vec(&serde_json::json!([
record.workspace_id,
record.workdir_id,
record.runtime_id,
record.repository_id,
record.source,
record.creation_selector,
record.creation_ref,
record.creation_tree,
@@ -116,21 +115,29 @@ pub fn workdir_removal_intent(
let fingerprint_bytes = serde_json::to_vec(&serde_json::json!([
record.workspace_id,
record.workdir_id,
record.runtime_id,
record.repository_id,
record.source,
materialization_fingerprint,
source_actor,
reason,
]))
.map_err(|error| Error::Store(format!("Workdir removal fingerprint failed: {error}")))?;
let request_fingerprint = hex_sha256(&fingerprint_bytes);
let WorkdirRegistrySource::Repository {
runtime_id,
repository_id,
} = &record.source
else {
return Err(Error::InvalidInput(
"External Workdirs must be revoked through their grant authority".to_string(),
));
};
Ok(WorkdirRemovalIntent {
operation_id: format!("wdr_{}", &request_fingerprint[..32]),
request_fingerprint,
workspace_id: record.workspace_id.clone(),
working_directory_id: record.workdir_id.clone(),
runtime_id: record.runtime_id.clone(),
repository_id: record.repository_id.clone(),
runtime_id: runtime_id.clone(),
repository_id: repository_id.clone(),
materialization_fingerprint,
source_actor: source_actor.to_string(),
reason: reason.to_string(),
@@ -682,8 +689,7 @@ fn require_matching_materialization(
) -> Result<()> {
if intent.workspace_id != record.workspace_id
|| intent.working_directory_id != record.workdir_id
|| intent.runtime_id != record.runtime_id
|| intent.repository_id != record.repository_id
|| !matches!(&record.source, WorkdirRegistrySource::Repository { runtime_id, repository_id } if runtime_id == &intent.runtime_id && repository_id == &intent.repository_id)
|| intent.materialization_fingerprint != workdir_materialization_fingerprint(record)
{
return Err(Error::WorkdirAttachmentConflict(
@@ -699,8 +705,7 @@ fn require_operation_materialization(
) -> Result<()> {
if operation.workspace_id != record.workspace_id
|| operation.working_directory_id != record.workdir_id
|| operation.runtime_id != record.runtime_id
|| operation.repository_id != record.repository_id
|| !matches!(&record.source, WorkdirRegistrySource::Repository { runtime_id, repository_id } if runtime_id == &operation.runtime_id && repository_id == &operation.repository_id)
|| operation.materialization_fingerprint != workdir_materialization_fingerprint(record)
{
return Err(Error::WorkdirAttachmentConflict(
@@ -716,7 +721,7 @@ fn load_workdir_record(
workdir_id: &str,
) -> Result<Option<WorkdirRegistryRecord>> {
conn.query_row(
r#"SELECT workspace_id, workdir_id, display_name, runtime_id, repository_id,
r#"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id,
creation_selector, creation_ref, creation_tree,
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
materialization_status, cleanliness, created_at, updated_at
@@ -727,19 +732,30 @@ fn load_workdir_record(
workspace_id: row.get(0)?,
workdir_id: row.get(1)?,
display_name: row.get(2)?,
runtime_id: row.get(3)?,
repository_id: row.get(4)?,
creation_selector: row.get(5)?,
creation_ref: row.get(6)?,
creation_tree: row.get(7)?,
current_selector: row.get(8)?,
current_ref: row.get(9)?,
current_tree: row.get(10)?,
observed_at_epoch_seconds: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
materialization_status: row.get(12)?,
cleanliness: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
source: match row.get::<_, String>(3)?.as_str() {
"repository" => WorkdirRegistrySource::Repository {
runtime_id: row.get(4)?,
repository_id: row.get(5)?,
},
_ => {
return Err(rusqlite::Error::InvalidColumnType(
3,
"source_kind".to_string(),
rusqlite::types::Type::Text,
));
}
},
creation_selector: row.get(6)?,
creation_ref: row.get(7)?,
creation_tree: row.get(8)?,
current_selector: row.get(9)?,
current_ref: row.get(10)?,
current_tree: row.get(11)?,
observed_at_epoch_seconds: row.get::<_, Option<i64>>(12)?.map(|value| value as u64),
materialization_status: row.get(13)?,
cleanliness: row.get(14)?,
created_at: row.get(15)?,
updated_at: row.get(16)?,
})
},
)
@@ -936,8 +952,10 @@ mod tests {
workspace_id: "workspace-a".to_string(),
workdir_id: "workdir-a".to_string(),
display_name: None,
runtime_id: "runtime-a".to_string(),
repository_id: "repository-a".to_string(),
source: WorkdirRegistrySource::Repository {
runtime_id: "runtime-a".to_string(),
repository_id: "repository-a".to_string(),
},
creation_selector: Some("refs/heads/develop".to_string()),
creation_ref: Some("abc".to_string()),
creation_tree: Some("tree-a".to_string()),
@@ -18,6 +18,7 @@ use crate::{Error, Result};
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"artifacts",
"audit_events",
"external_workdir_grants",
"flow_source_revisions",
"flow_sources",
"memory_staging_records",
@@ -455,7 +455,7 @@ fn project_working_directory(
attachments.push(
protocol::subscription::SubscriptionWorkerWorkdirAttachment {
alias: link.alias,
repository_id: Some(workdir.repository_id),
repository_id: workdir.source.repository_id().map(str::to_string),
repository_key: None,
working_directory_id,
},
+5 -1
View File
@@ -10,12 +10,16 @@ e2e-test = ["tui/e2e-test"]
[dependencies]
project-record = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
client = { workspace = true }
memory = { workspace = true }
futures = { workspace = true }
manifest = { workspace = true }
worker = { workspace = true }
server-api = { workspace = true }
workdir = { workspace = true }
tokio-tungstenite = { workspace = true }
session-store = { workspace = true }
session-analytics = { workspace = true }
ticket = { workspace = true }
@@ -23,7 +27,7 @@ tui = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = "0.9.34"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] }
toml = { workspace = true }
uuid = { workspace = true }
+236
View File
@@ -5,6 +5,7 @@ mod objective_cli;
mod plugin_cli;
mod session_cli;
mod ticket_cli;
mod workdir_share;
mod worker_cleanup_cli;
mod workspace_bootstrap;
@@ -53,6 +54,8 @@ enum Mode {
no_wait: bool,
},
Init(InitOptions),
WorkdirHelp,
WorkdirShare(workdir_share::WorkdirShareOptions),
WorkerRuntime(Vec<String>),
Keys,
SetupModel,
@@ -133,6 +136,17 @@ async fn run(mode: Mode) -> ExitCode {
ExitCode::FAILURE
}
},
Mode::WorkdirHelp => {
print_workdir_help();
ExitCode::SUCCESS
}
Mode::WorkdirShare(options) => match workdir_share::run(options).await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("yoi workdir share: {error}");
ExitCode::FAILURE
}
},
Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE,
@@ -420,6 +434,9 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
"--help" | "-h" => return Ok(Mode::Help),
"resume" => return parse_resume_args(&args[1..], &target_selection, connection_resolver),
"workers" => return parse_workers_args(&args[1..], &target_selection, connection_resolver),
"workdir" => {
return parse_workdir_args(&args[1..], &target_selection);
}
"worker" => {
if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))?
@@ -611,6 +628,142 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
parse_console_options(args, &target_selection, connection_resolver)
}
fn parse_workdir_args(
args: &[String],
target_selection: &TargetSelection,
) -> Result<Mode, ParseError> {
if target_selection.explicit_local {
return Err(ParseError(
"yoi workdir share requires a Backend target and cannot use --local".to_string(),
));
}
let Some((subcommand, args)) = args.split_first() else {
return Ok(Mode::WorkdirHelp);
};
if matches!(subcommand.as_str(), "--help" | "-h") {
return Ok(Mode::WorkdirHelp);
}
if subcommand != "share" {
return Err(ParseError(format!(
"unknown yoi workdir subcommand `{subcommand}`"
)));
}
let mut path = None;
let mut workspace_id = target_selection.workspace_id.clone();
let mut backend_url = target_selection.backend_url.clone();
let mut display_name = None;
let mut ttl = None;
let mut read_only = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--workspace-id" => {
if workspace_id.is_some() {
return Err(ParseError(
"--workspace-id may only be provided once".to_string(),
));
}
workspace_id =
Some(required_option_value(args, index, "--workspace-id")?.to_string());
index += 2;
}
"--backend" => {
if backend_url.is_some() {
return Err(ParseError(
"--backend may only be provided once".to_string(),
));
}
backend_url = Some(required_option_value(args, index, "--backend")?.to_string());
index += 2;
}
"--display-name" => {
if display_name.is_some() {
return Err(ParseError(
"--display-name may only be provided once".to_string(),
));
}
display_name =
Some(required_option_value(args, index, "--display-name")?.to_string());
index += 2;
}
"--ttl" => {
if ttl.is_some() {
return Err(ParseError("--ttl may only be provided once".to_string()));
}
ttl = Some(parse_share_ttl(required_option_value(
args, index, "--ttl",
)?)?);
index += 2;
}
"--read-only" => {
if read_only {
return Err(ParseError(
"--read-only may only be provided once".to_string(),
));
}
read_only = true;
index += 1;
}
"--help" | "-h" => return Ok(Mode::WorkdirHelp),
option if option.starts_with('-') => {
return Err(ParseError(format!(
"unknown yoi workdir share option `{option}`"
)));
}
value => {
if path.is_some() {
return Err(ParseError(
"yoi workdir share accepts exactly one directory path".to_string(),
));
}
path = Some(PathBuf::from(value));
index += 1;
}
}
}
if !read_only {
return Err(ParseError(
"yoi workdir share requires --read-only".to_string(),
));
}
let workspace_id = workspace_id
.ok_or_else(|| ParseError("yoi workdir share requires --workspace-id".to_string()))?;
let backend_url = resolve_backend_url(backend_url, Some(&workspace_id))?;
Ok(Mode::WorkdirShare(workdir_share::WorkdirShareOptions {
path: path
.ok_or_else(|| ParseError("yoi workdir share requires a directory path".to_string()))?,
workspace_id,
backend_url,
display_name: display_name
.ok_or_else(|| ParseError("yoi workdir share requires --display-name".to_string()))?,
ttl: ttl.ok_or_else(|| ParseError("yoi workdir share requires --ttl".to_string()))?,
}))
}
fn parse_share_ttl(value: &str) -> Result<Duration, ParseError> {
let (number, multiplier) = if let Some(value) = value.strip_suffix('h') {
(value, 60 * 60)
} else if let Some(value) = value.strip_suffix('m') {
(value, 60)
} else if let Some(value) = value.strip_suffix('s') {
(value, 1)
} else {
return Err(ParseError(
"--ttl must use an h, m, or s suffix (for example 1h)".to_string(),
));
};
let amount = number.parse::<u64>().map_err(|_| {
ParseError("--ttl must contain a positive integer and h, m, or s suffix".to_string())
})?;
let seconds = amount
.checked_mul(multiplier)
.ok_or_else(|| ParseError("--ttl is too large".to_string()))?;
if !(60..=86_400).contains(&seconds) {
return Err(ParseError("--ttl must be between 60s and 24h".to_string()));
}
Ok(Duration::from_secs(seconds))
}
fn parse_init_args(
args: &[String],
explicit_backend_url: Option<String>,
@@ -1734,6 +1887,7 @@ Usage:
yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
yoi [--backend <URL>] login [--no-wait]
yoi [--backend <URL>] workdir share <PATH> --workspace-id <ID> --read-only --ttl <TTL> --display-name <NAME>
yoi <HOST_COMMAND> [OPTIONS]
Target selection:
@@ -1759,6 +1913,7 @@ Console options:
Host commands:
yoi init Register the current Git repository as a new Backend Workspace.
yoi workdir share Share a temporary read-only client-hosted External Workdir.
keys Manage local model/API keys
setup-model Configure a local model provider
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
@@ -1777,6 +1932,12 @@ Options:
-h, --help Print help
"#;
fn print_workdir_help() {
println!(
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> --workspace-id <ID> --read-only --ttl <TTL> --display-name <NAME>\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nNo host path is sent to the Backend. TTL accepts s, m, or h suffixes from 60s through 24h.\nCtrl-C explicitly revokes the grant before exit.\n"
);
}
fn print_help() {
println!("{TOP_LEVEL_HELP}");
}
@@ -1834,6 +1995,81 @@ fn print_memory_lint_help() {
mod tests {
use super::*;
#[test]
fn workdir_share_parses_explicit_backend_workspace_and_bounded_ttl() {
let args = vec![
"share".to_string(),
"./session-log".to_string(),
"--read-only".to_string(),
"--ttl".to_string(),
"90m".to_string(),
"--display-name".to_string(),
"session-analysis".to_string(),
];
let target = TargetSelection {
backend_url: Some("https://backend.example".to_string()),
workspace_id: Some("workspace-a".to_string()),
..TargetSelection::default()
};
let Mode::WorkdirShare(options) = parse_workdir_args(&args, &target).unwrap() else {
panic!("expected Workdir share mode");
};
assert_eq!(options.path, PathBuf::from("./session-log"));
assert_eq!(options.workspace_id, "workspace-a");
assert_eq!(options.backend_url, "https://backend.example");
assert_eq!(options.display_name, "session-analysis");
assert_eq!(options.ttl, Duration::from_secs(5_400));
}
#[test]
fn workdir_share_fails_closed_without_explicit_read_only_workspace_or_valid_ttl() {
let base = vec![
"share".to_string(),
".".to_string(),
"--ttl".to_string(),
"1h".to_string(),
"--display-name".to_string(),
"logs".to_string(),
];
let target = TargetSelection {
backend_url: Some("https://backend.example".to_string()),
workspace_id: Some("workspace-a".to_string()),
..TargetSelection::default()
};
assert!(
parse_workdir_args(&base, &target)
.err()
.unwrap()
.0
.contains("--read-only")
);
let mut invalid_ttl = base.clone();
invalid_ttl.push("--read-only".to_string());
invalid_ttl[3] = "25h".to_string();
assert!(
parse_workdir_args(&invalid_ttl, &target)
.err()
.unwrap()
.0
.contains("24h")
);
let missing_workspace = TargetSelection {
backend_url: Some("https://backend.example".to_string()),
..TargetSelection::default()
};
let mut valid = base;
valid.push("--read-only".to_string());
assert!(
parse_workdir_args(&valid, &missing_workspace)
.err()
.unwrap()
.0
.contains("--workspace-id")
);
}
#[test]
fn cli_parses_connection_before_starting_tokio_runtime() {
let mut parsed = false;
+564
View File
@@ -0,0 +1,564 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::Utc;
use client::BackendApiClient;
use futures::{SinkExt, StreamExt};
use server_api::{ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse};
use tokio_tungstenite::connect_async_with_config;
use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};
use workdir::external::{
ExternalProviderInstanceId, ExternalWorkdirGrantId, ExternalWorkdirOperationId,
ExternalWorkdirOperationOutcome, ExternalWorkdirProviderFrame, ExternalWorkdirProviderMessage,
ExternalWorkdirProviderRegistration, ExternalWorkdirServerFrame, ExternalWorkdirServerMessage,
};
use workdir::http::{WorkdirTransportError, dispatch_workdir_session_operation};
use workdir::{
BoundedReadLimits, LocalWorkdirSession, Workdir, WorkdirSession, WorkdirSessionCapabilities,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkdirShareOptions {
pub path: PathBuf,
pub workspace_id: String,
pub backend_url: String,
pub display_name: String,
pub ttl: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderEnd {
Disconnected,
Revoked,
Interrupted,
}
struct OperationCompletion {
operation_id: ExternalWorkdirOperationId,
outcome: ExternalWorkdirOperationOutcome,
}
struct OperationTask {
cancelled: Arc<AtomicBool>,
}
impl OperationTask {
fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
}
fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Acquire)
}
}
impl Drop for OperationTask {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Release);
}
}
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
let metadata = std::fs::symlink_metadata(&options.path)
.map_err(|error| format!("selected path is unavailable: {error}"))?;
if metadata.file_type().is_symlink() {
return Err("selected External Workdir root must not be a symbolic link".to_string());
}
let canonical = options
.path
.canonicalize()
.map_err(|error| format!("failed to canonicalize selected directory: {error}"))?;
if !canonical.is_dir() {
return Err("selected External Workdir path must be a directory".to_string());
}
let client = BackendApiClient::from_stored_token(&options.backend_url)
.map_err(|error| error.to_string())?;
let provider_instance_id = format!("cli-{}", uuid::Uuid::now_v7());
let create_path = format!(
"/api/w/{}/external-workdir-grants",
encode_path_segment(&options.workspace_id)
);
let response = client
.request(reqwest::Method::POST, &create_path)
.map_err(|error| error.to_string())?
.timeout(Duration::from_secs(10))
.json(&ExternalWorkdirGrantCreateRequest {
provider_instance_id: provider_instance_id.clone(),
display_name: options.display_name.clone(),
ttl_seconds: options.ttl.as_secs(),
read_only: true,
})
.send()
.await
.map_err(|error| format!("External Workdir grant request failed: {error}"))?;
let response = client
.require_success(response)
.await
.map_err(|error| error.to_string())?;
let grant = response
.json::<ExternalWorkdirGrantResponse>()
.await
.map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))?;
let limits = BoundedReadLimits::EXTERNAL_DEFAULT;
let session = LocalWorkdirSession::external_read_only(
Workdir::new(&grant.working_directory_id),
&canonical,
limits,
)
.map_err(|error| error.to_string())?;
println!("External Workdir grant: {}", grant.grant_id);
println!("Logical Workdir: {}", grant.working_directory_id);
println!("Display name: {}", grant.display_name);
println!("Permission: {}", grant.permissions);
println!("Expires at: {}", grant.expires_at);
let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
.map_err(|_| "Backend returned an invalid External Workdir expiry".to_string())?
.with_timezone(&Utc);
let mut generation = grant.generation;
loop {
let outcome = serve_provider_connection(
&client,
&options,
&grant,
&provider_instance_id,
&session,
generation,
)
.await;
let outcome = match outcome {
Ok(outcome) => outcome,
Err(connection_error) => {
if Utc::now() >= expires_at {
return Err(format!(
"External Workdir grant expired after provider connection failure: {connection_error}"
));
}
let refresh = fetch_grant(&client, &options.workspace_id, &grant.grant_id);
let refreshed = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(&client, &options.workspace_id, &grant.grant_id).await?;
println!("External Workdir grant revoked.");
return Ok(());
}
refreshed = refresh => refreshed,
};
let current = match refreshed {
Ok(current) => current,
Err(refresh_error) => {
eprintln!(
"Connection refresh failed; retrying: {connection_error}; {refresh_error}"
);
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id)
.await?
{
println!("External Workdir grant revoked.");
return Ok(());
}
continue;
}
};
match current.status.as_str() {
"pending" => generation = current.generation,
"offline" => {
generation = current.generation.checked_add(1).ok_or_else(|| {
"External Workdir provider generation overflow".to_string()
})?;
}
"online" => {
// The Backend may still be observing the old socket close.
// Wait for its durable offline fence before reconnecting.
}
"revoked" | "expired" => {
println!("External Workdir grant is no longer active.");
return Ok(());
}
status => {
return Err(format!(
"Backend returned unknown External Workdir grant status `{status}`"
));
}
}
println!("Connection: reconnecting");
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? {
println!("External Workdir grant revoked.");
return Ok(());
}
continue;
}
};
match outcome {
ProviderEnd::Interrupted => {
println!("External Workdir grant revoked.");
return Ok(());
}
ProviderEnd::Revoked => {
println!("External Workdir grant is no longer active.");
return Ok(());
}
ProviderEnd::Disconnected => {
if Utc::now() >= expires_at {
return Err("External Workdir grant expired while disconnected".to_string());
}
generation = generation
.checked_add(1)
.ok_or_else(|| "External Workdir provider generation overflow".to_string())?;
println!("Connection: reconnecting");
if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? {
println!("External Workdir grant revoked.");
return Ok(());
}
}
}
}
}
async fn serve_provider_connection(
client: &BackendApiClient,
options: &WorkdirShareOptions,
grant: &ExternalWorkdirGrantResponse,
provider_instance_id: &str,
session: &LocalWorkdirSession,
generation: u64,
) -> Result<ProviderEnd, String> {
let provider_path = format!(
"/api/w/{}/external-workdir-grants/{}/provider",
encode_path_segment(&options.workspace_id),
encode_path_segment(&grant.grant_id)
);
let request = client
.websocket_request(&provider_path)
.map_err(|error| error.to_string())?;
let websocket_config = WebSocketConfig::default()
.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! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted);
}
connection = connection => connection
.map_err(|error| format!("External Workdir provider connection failed: {error}"))?,
};
let registration =
ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register {
registration: ExternalWorkdirProviderRegistration {
provider_instance_id: ExternalProviderInstanceId::new(provider_instance_id)
.map_err(|error| error.to_string())?,
grant_id: ExternalWorkdirGrantId::new(grant.grant_id.clone())
.map_err(|error| error.to_string())?,
workdir_id: session.workdir().id().clone(),
generation,
capabilities: WorkdirSessionCapabilities::READ_ONLY,
read_limits: BoundedReadLimits::EXTERNAL_DEFAULT,
},
});
send_provider_frame(&mut socket, &registration).await?;
let registered = tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted);
}
registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => {
registered
.map_err(|_| "Backend timed out External Workdir provider registration".to_string())?
.ok_or_else(|| "Backend closed the External Workdir provider connection".to_string())?
.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>(&registered)
.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());
}
println!("Connection: online (generation {generation})");
let (completion_sender, mut completions) =
tokio::sync::mpsc::channel::<OperationCompletion>(16);
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}"))?;
abort_operations(&mut operations);
revoke(client, &options.workspace_id, &grant.grant_id).await?;
return Ok(ProviderEnd::Interrupted);
}
completion = completions.recv() => {
let Some(completion) = completion else {
abort_operations(&mut operations);
return Err("External Workdir operation executor closed".to_string());
};
let Some(operation) = operations.remove(completion.operation_id.as_str()) else {
continue;
};
if operation.is_cancelled() {
continue;
}
let result = ExternalWorkdirProviderFrame::current(
ExternalWorkdirProviderMessage::OperationResult {
generation,
operation_id: completion.operation_id,
outcome: completion.outcome,
},
);
if send_provider_frame(&mut socket, &result).await.is_err() {
abort_operations(&mut operations);
return Ok(ProviderEnd::Disconnected);
}
}
incoming = socket.next() => {
let Some(incoming) = incoming else {
abort_operations(&mut operations);
return Ok(ProviderEnd::Disconnected);
};
let incoming = match incoming {
Ok(incoming) => incoming,
Err(_) => {
abort_operations(&mut operations);
return Ok(ProviderEnd::Disconnected);
}
};
match incoming {
Message::Text(text) => {
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text)
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
match frame.message {
ExternalWorkdirServerMessage::Operation {
generation: frame_generation,
operation_id,
operation,
} if frame_generation == generation => {
if operations.len() >= 16 || operations.contains_key(operation_id.as_str()) {
abort_operations(&mut operations);
return Err("Backend exceeded External Workdir operation bounds".to_string());
}
let key = operation_id.as_str().to_string();
let completion_sender = completion_sender.clone();
let session = session.clone();
let task_operation_id = operation_id.clone();
let cancelled = Arc::new(AtomicBool::new(false));
let operation = operation.into_inner();
std::thread::Builder::new()
.name("external-workdir-operation".to_string())
.spawn(move || {
let outcome = futures::executor::block_on(execute_operation(&session, operation));
let _ = completion_sender.blocking_send(OperationCompletion {
operation_id: task_operation_id,
outcome,
});
})
.map_err(|error| format!("failed to start External Workdir operation: {error}"))?;
operations.insert(key, OperationTask { cancelled });
}
ExternalWorkdirServerMessage::Heartbeat {
generation: frame_generation,
sequence,
} if frame_generation == generation => {
let heartbeat = ExternalWorkdirProviderFrame::current(
ExternalWorkdirProviderMessage::Heartbeat { generation, sequence },
);
send_provider_frame(&mut socket, &heartbeat).await?;
}
ExternalWorkdirServerMessage::Cancel {
generation: frame_generation,
operation_id,
} if frame_generation == generation => {
if let Some(operation) = operations.get(operation_id.as_str()) {
operation.cancel();
let cancelled = ExternalWorkdirProviderFrame::current(
ExternalWorkdirProviderMessage::OperationResult {
generation,
operation_id,
outcome: ExternalWorkdirOperationOutcome::Cancelled,
},
);
send_provider_frame(&mut socket, &cancelled).await?;
}
}
ExternalWorkdirServerMessage::Revoke {
generation: frame_generation,
..
} if frame_generation == generation => {
abort_operations(&mut operations);
let acknowledged = ExternalWorkdirProviderFrame::current(
ExternalWorkdirProviderMessage::RevokeAcknowledged { generation },
);
let _ = send_provider_frame(&mut socket, &acknowledged).await;
return Ok(ProviderEnd::Revoked);
}
_ => {
abort_operations(&mut operations);
return Err("stale or unexpected External Workdir provider frame".to_string());
}
}
}
Message::Ping(bytes) => {
let pong = tokio::time::timeout(
Duration::from_secs(5),
socket.send(Message::Pong(bytes)),
)
.await;
if !matches!(pong, Ok(Ok(()))) {
abort_operations(&mut operations);
return Ok(ProviderEnd::Disconnected);
}
}
Message::Close(_) => {
abort_operations(&mut operations);
return Ok(ProviderEnd::Disconnected);
}
_ => {}
}
}
}
}
}
async fn execute_operation(
session: &LocalWorkdirSession,
operation: workdir::http::WorkdirSessionOperation,
) -> ExternalWorkdirOperationOutcome {
match dispatch_workdir_session_operation(session, operation).await {
Ok(result) => match result.try_into() {
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
Err(message) => ExternalWorkdirOperationOutcome::Failed {
error: WorkdirTransportError {
code: workdir::http::WorkdirTransportErrorCode::Unsupported,
message,
},
},
},
Err(error) => ExternalWorkdirOperationOutcome::Failed {
error: WorkdirTransportError::from_workdir_error(&error),
},
}
}
fn abort_operations(operations: &mut HashMap<String, OperationTask>) {
for (_, operation) in operations.drain() {
operation.cancel();
}
}
async fn send_provider_frame<S>(
socket: &mut tokio_tungstenite::WebSocketStream<S>,
frame: &ExternalWorkdirProviderFrame,
) -> Result<(), String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let message = Message::Text(
serde_json::to_string(frame)
.map_err(|error| error.to_string())?
.into(),
);
tokio::time::timeout(Duration::from_secs(5), socket.send(message))
.await
.map_err(|_| "timed out sending External Workdir provider frame".to_string())?
.map_err(|error| format!("failed to send External Workdir provider frame: {error}"))
}
async fn wait_to_reconnect(
client: &BackendApiClient,
workspace_id: &str,
grant_id: &str,
) -> Result<bool, String> {
tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
revoke(client, workspace_id, grant_id).await?;
Ok(true)
}
_ = tokio::time::sleep(Duration::from_millis(250)) => Ok(false),
}
}
async fn fetch_grant(
client: &BackendApiClient,
workspace_id: &str,
grant_id: &str,
) -> Result<ExternalWorkdirGrantResponse, String> {
let path = format!(
"/api/w/{}/external-workdir-grants/{}",
encode_path_segment(workspace_id),
encode_path_segment(grant_id)
);
let response = client
.request(reqwest::Method::GET, &path)
.map_err(|error| error.to_string())?
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|error| format!("External Workdir grant refresh failed: {error}"))?;
client
.require_success(response)
.await
.map_err(|error| error.to_string())?
.json::<ExternalWorkdirGrantResponse>()
.await
.map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))
}
async fn revoke(
client: &BackendApiClient,
workspace_id: &str,
grant_id: &str,
) -> Result<(), String> {
let path = format!(
"/api/w/{}/external-workdir-grants/{}",
encode_path_segment(workspace_id),
encode_path_segment(grant_id)
);
let response = client
.request(reqwest::Method::DELETE, &path)
.map_err(|error| error.to_string())?
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|error| format!("External Workdir revoke request failed: {error}"))?;
client.require_success(response).await.map_err(|error| {
format!("External Workdir revoke was not confirmed by the Backend: {error}")
})?;
Ok(())
}
fn encode_path_segment(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_segments_are_encoded_without_exposing_structure() {
assert_eq!(encode_path_segment("workspace/a b"), "workspace%2Fa%20b");
assert_eq!(encode_path_segment("safe-_.~"), "safe-_.~");
}
}
@@ -5,7 +5,7 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
@@ -13,11 +13,13 @@ export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
export type WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, };
export type WorkingDirectorySummary = { working_directory_id: string,
/**
* Optional human-facing label, never a routing key.
*/
display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
display_name?: string | null, source: WorkingDirectorySource, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null,
/**
@@ -27,6 +29,6 @@ display_name?: string | null, repository_key: string, selector?: string | null,
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id?: string | null, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array<Diagnostic>, };
@@ -7,7 +7,7 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external";
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
@@ -23,11 +23,13 @@ display_name?: string | null, repository_id: string, creation_selector?: string
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
export type WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, };
export type WorkingDirectorySummary = { working_directory_id: string,
/**
* Optional human-facing label, never a routing key.
*/
display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
display_name?: string | null, source: WorkingDirectorySource, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkerWorkspaceSummary = { visibility: string, identity: string, workspace_id?: string | null, };
+56 -13
View File
@@ -6,13 +6,14 @@ import type {
WorkingDirectoryDetailResponse,
WorkingDirectoryListResponse,
WorkingDirectoryOccupancy,
WorkingDirectorySource,
WorkingDirectorySummary,
} from "../../generated/workdir-api";
const SUMMARY_KEYS = new Set([
"working_directory_id",
"display_name",
"repository_key",
"source",
"creation_selector",
"creation_ref",
"creation_tree",
@@ -64,13 +65,30 @@ export function parseWorkingDirectoryListResponse(
export function parseWorkingDirectoryDetailResponse(
value: unknown,
): WorkingDirectoryDetailResponse {
return parseDetailLike(value, "Workdir detail response");
const record = parseDetailRecord(value, "Workdir detail response");
const response: WorkingDirectoryDetailResponse = {
workspace_id: stringField(record, "workspace_id"),
item: parseWorkingDirectorySummary(record.item),
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
};
if (record.runtime_id !== undefined) {
response.runtime_id = record.runtime_id === null
? null
: stringField(record, "runtime_id");
}
return response;
}
export function parseWorkingDirectoryCreateResponse(
value: unknown,
): WorkingDirectoryCreateResponse {
return parseDetailLike(value, "Workdir create response");
const record = parseDetailRecord(value, "Workdir create response");
return {
workspace_id: stringField(record, "workspace_id"),
runtime_id: stringField(record, "runtime_id"),
item: parseWorkingDirectorySummary(record.item),
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
};
}
export function validateWorkingDirectoryCreateRequest(
@@ -91,21 +109,15 @@ export function validateWorkingDirectoryCreateRequest(
return request;
}
function parseDetailLike(
function parseDetailRecord(
value: unknown,
label: string,
): WorkingDirectoryDetailResponse {
const record = exactRecord(
): Record<string, unknown> {
return exactRecord(
value,
new Set(["workspace_id", "runtime_id", "item", "diagnostics"]),
label,
);
return {
workspace_id: stringField(record, "workspace_id"),
runtime_id: stringField(record, "runtime_id"),
item: parseWorkingDirectorySummary(record.item),
diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic),
};
}
export function parseWorkingDirectorySummary(
@@ -114,9 +126,10 @@ export function parseWorkingDirectorySummary(
const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary");
const summary: WorkingDirectorySummary = {
working_directory_id: stringField(record, "working_directory_id"),
repository_key: stringField(record, "repository_key"),
source: parseWorkingDirectorySource(record.source),
materializer_kind: enumField(record, "materializer_kind", [
"runtime_git_clone",
"client_hosted_external",
]),
status: enumField(record, "status", [
"active",
@@ -160,6 +173,36 @@ export function parseWorkingDirectorySummary(
return summary;
}
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
const source = exactRecord(
value,
new Set(["kind", "repository_key", "grant_id"]),
"Workdir source",
);
const kind = stringField(source, "kind");
if (kind === "repository") {
if (source.grant_id !== undefined) {
throw new Error("Repository Workdir source must not contain grant_id");
}
return {
kind,
repository_key: stringField(source, "repository_key"),
};
}
if (kind === "external_grant") {
if (source.repository_key !== undefined) {
throw new Error(
"External Workdir source must not contain repository_key",
);
}
return {
kind,
grant_id: stringField(source, "grant_id"),
};
}
throw new Error("Workdir source.kind has an unsupported value");
}
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
const record = exactRecord(
value,
@@ -17,7 +17,7 @@ function workdir(
): WorkingDirectorySummary {
return {
working_directory_id: "workdir-1",
repository_key: "repository-1",
source: { kind: "repository", repository_key: "repository-1" },
current_selector,
current_ref,
materializer_kind: "runtime_git_clone",
@@ -28,6 +28,33 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initi
assertEquals(form.working_directory_selector, "HEAD");
});
Deno.test("defaultWorkerLaunchForm preserves an available read-only External Workdir", () => {
const external = {
working_directory_id: "external-1",
display_name: "Session analysis",
source: { kind: "external_grant" as const, grant_id: "grant-1" },
materializer_kind: "client_hosted_external" as const,
status: "active" as const,
cleanliness: "unknown",
};
const form = defaultWorkerLaunchForm(
{ ...options, working_directories: [external] },
emptyForm({
workdir_attachments: [{
alias: "sessions",
working_directory_id: "external-1",
relative_cwd: "",
}],
}),
);
assertEquals(form.workdir_attachments, [{
alias: "sessions",
working_directory_id: "external-1",
relative_cwd: "",
}]);
});
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
const reviewerOptions = {
...options,
@@ -87,7 +114,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () =>
{
...options.working_directories[0],
working_directory_id: "ticket-workdir",
repository_key: "ticket-repo",
source: { kind: "repository", repository_key: "ticket-repo" },
creation_selector: "work/ticket",
},
],
@@ -318,7 +345,7 @@ const options: WorkerLaunchOptionsResponse = {
working_directories: [
{
working_directory_id: "wd-1-repo",
repository_key: "repo",
source: { kind: "repository", repository_key: "repo" },
creation_selector: "HEAD",
creation_ref: "0123456789abcdef",
current_selector: null,
@@ -40,7 +40,8 @@ export function defaultWorkerLaunchForm(
const availableWorkingDirectories =
options?.working_directories.filter((directory) =>
directory.status === "active" &&
directory.cleanliness === "clean" &&
(directory.source.kind === "external_grant" ||
directory.cleanliness === "clean") &&
directory.occupied_by == null
) ?? [];
const selectedRuntime = current.runtime_id
@@ -54,13 +55,17 @@ export function defaultWorkerLaunchForm(
? undefined
: availableWorkingDirectories.find((directory) =>
Boolean(current.working_directory_repository_key) &&
directory.repository_key === current.working_directory_repository_key &&
directory.source.kind === "repository" &&
directory.source.repository_key ===
current.working_directory_repository_key &&
(!current.working_directory_selector ||
(directory.current_selector ?? directory.creation_selector) ===
current.working_directory_selector)
) ?? availableWorkingDirectories.find((directory) =>
Boolean(current.working_directory_repository_key) &&
directory.repository_key === current.working_directory_repository_key
directory.source.kind === "repository" &&
directory.source.repository_key ===
current.working_directory_repository_key
) ?? (current.working_directory_repository_key
? undefined
: availableWorkingDirectories[0]);
@@ -25,7 +25,7 @@ function workdir(
): WorkingDirectorySummary {
return {
working_directory_id: "workdir-1234567890abcdef",
repository_key: "main",
source: { kind: "repository", repository_key: "main" },
materializer_kind: "runtime_git_clone",
status: "active",
...overrides,
@@ -39,7 +39,9 @@ function attachment(
const workingDirectory = workdir(overrides);
return {
alias,
repository_key: workingDirectory.repository_key,
repository_key: workingDirectory.source.kind === "repository"
? workingDirectory.source.repository_key
: null,
working_directory_id: workingDirectory.working_directory_id,
working_directory: workingDirectory,
};
@@ -105,13 +107,30 @@ Deno.test("Worker sidebar Workdir label has an explicit unavailable-projection f
assertEquals(meta.text, "main:unknown@workdir-1234");
});
Deno.test("Worker sidebar Workdir label uses an External Workdir display name without fake repository metadata", () => {
const meta = sidebarWorkdirMeta([
attachment("sessions", {
display_name: "Session analysis",
source: { kind: "external_grant", grant_id: "grant-1" },
materializer_kind: "client_hosted_external",
cleanliness: "unknown",
}),
]);
assertEquals(meta.text, "Session analysis");
assert(
meta.details.includes("sessions — Session analysis"),
"alias should remain visible",
);
});
Deno.test("Worker sidebar Workdir metadata preserves long names for truncated display details", () => {
const repository = "repository-with-a-name-that-exceeds-the-sidebar-width";
const branch =
"feature/workdir-label-with-a-name-that-also-exceeds-the-sidebar-width";
const meta = sidebarWorkdirMeta([
attachment("long-checkout", {
repository_key: repository,
source: { kind: "repository", repository_key: repository },
current_selector: branch,
}),
]);
@@ -128,7 +147,7 @@ Deno.test("Worker sidebar Workdir metadata keeps multiple attachments distinguis
attachment("frontend", { current_selector: "develop" }),
attachment("backend", {
working_directory_id: "workdir-fedcba0987654321",
repository_key: "server",
source: { kind: "repository", repository_key: "server" },
current_selector: "release",
}),
]);
@@ -27,9 +27,13 @@ export function sidebarWorkdirMeta(
function attachmentLabel(attachment: SidebarWorkdirAttachment): string {
const workdir = attachment.working_directory;
const repository = clean(workdir?.repository_key) ??
clean(attachment.repository_key) ?? "unknown-repo";
return `${repository}:${revisionLabel(attachment)}`;
if (workdir?.source.kind === "external_grant") {
return clean(workdir.display_name) ?? "external";
}
const repository = workdir?.source.kind === "repository"
? clean(workdir.source.repository_key)
: clean(attachment.repository_key);
return `${repository ?? "unknown-repo"}:${revisionLabel(attachment)}`;
}
function revisionLabel(attachment: SidebarWorkdirAttachment): string {
@@ -24,9 +24,15 @@
workdirs = data.workdirs?.items ?? [];
});
function repositoryKey(workdir: WorkingDirectorySummary): string | null {
return workdir.source.kind === 'repository' ? workdir.source.repository_key : null;
}
function repositoryProvider(workdir: WorkingDirectorySummary): string | null {
return data.repositories?.items.find((repository) => repository.repository_key === workdir.repository_key)
?.provider ?? null;
const key = repositoryKey(workdir);
return key
? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null
: null;
}
function currentRevision(workdir: WorkingDirectorySummary): string {
@@ -142,7 +148,7 @@
<span>{workdir.display_name ?? '—'}</span>
<small><code>{workdir.working_directory_id}</code></small>
</td>
<td>{workdir.repository_key}</td>
<td>{repositoryKey(workdir) ?? 'External'}</td>
<td><code>{currentRevision(workdir)}</code></td>
<td>{workdir.status}</td>
<td>{workdir.cleanliness ?? 'unknown'}</td>
@@ -148,9 +148,13 @@
const attachments = worker.workdir_attachments ?? [];
if (attachments.length === 0) return '—';
return attachments.map(({ alias, working_directory: directory }) => {
const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key)
?.provider;
const label = directory.display_name ?? directory.repository_key;
const repositoryKey = directory.source.kind === 'repository'
? directory.source.repository_key
: null;
const provider = repositoryKey
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
: null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
}).join(', ');
}
@@ -36,9 +36,13 @@
};
function workdirOptionLabel(directory: WorkingDirectorySummary): string {
const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key)
?.provider;
const label = directory.display_name ?? directory.repository_key;
const repositoryKey = directory.source.kind === 'repository'
? directory.source.repository_key
: null;
const provider = repositoryKey
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
: null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
}
@@ -83,7 +87,7 @@
? []
: (options?.working_directories ?? []).filter((directory) =>
directory.status === 'active' &&
directory.cleanliness === 'clean' &&
(directory.source.kind === 'external_grant' || directory.cleanliness === 'clean') &&
directory.occupied_by == null
),
);
+23 -1
View File
@@ -4,6 +4,7 @@ declare const Deno: {
import {
parseWorkingDirectoryCreateResponse,
parseWorkingDirectoryDetailResponse,
parseWorkingDirectoryListResponse,
validateWorkingDirectoryCreateRequest,
} from "../src/lib/workspace/api/workdirs.ts";
@@ -11,7 +12,7 @@ import {
const summary = {
working_directory_id: "workdir-1",
display_name: "Checkout",
repository_key: "main",
source: { kind: "repository", repository_key: "main" },
materializer_kind: "runtime_git_clone",
status: "active",
occupied_by: {
@@ -46,6 +47,27 @@ Deno.test("Workdir REST validation accepts the generated list and create contrac
}
});
Deno.test("Workdir REST validation accepts External grant sources without Runtime identity", () => {
const detail = parseWorkingDirectoryDetailResponse({
workspace_id: "workspace-a",
item: {
working_directory_id: "external-1",
display_name: "Session analysis",
source: { kind: "external_grant", grant_id: "grant-1" },
materializer_kind: "client_hosted_external",
status: "active",
cleanliness: "unknown",
},
diagnostics: [],
});
if (detail.runtime_id !== undefined) {
throw new Error("External Workdir acquired a fake Runtime identity");
}
if (detail.item.source.kind !== "external_grant") {
throw new Error("External Workdir source was not preserved");
}
});
Deno.test("Workdir REST validation rejects stale response JSON", () => {
let rejected = false;
try {