feat: add client-hosted external workdirs
This commit is contained in:
Generated
+5
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -2409,6 +2409,7 @@ mod tests {
|
||||
[LogicalWorkdirAttachment {
|
||||
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
|
||||
working_directory_id: "remote-workdir".to_string(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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';
|
||||
|
||||
+1805
-102
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ®istration).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>(®istered)
|
||||
.map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?;
|
||||
if !matches!(
|
||||
frame.message,
|
||||
ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. }
|
||||
if registered_generation == generation
|
||||
) {
|
||||
return Err("Backend rejected the External Workdir provider registration".to_string());
|
||||
}
|
||||
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, };
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+9
-3
@@ -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
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user