feat: add client-hosted external workdirs

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