diff --git a/Cargo.lock b/Cargo.lock index e067dc4a5..4bb517eb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6575,6 +6575,7 @@ dependencies = [ "flow", "fs4", "futures", + "libc", "manifest", "protocol", "reqwest", diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index 3f4a327c3..ef2f92af8 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -31,6 +31,7 @@ decodal.workspace = true flow = { path = "../flow" } fs4 = { workspace = true, optional = true } manifest.workspace = true +libc.workspace = true protocol.workspace = true runtime-api.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 0326f899a..df39f7651 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -2486,6 +2486,8 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { if matches!( diagnostic.code.as_str(), "repository_ref_provider_unavailable" + | "working_directory_cleanup_mount_check_unavailable" + | "working_directory_cleanup_storage_unavailable" | "repository_ref_provider_timeout" | "repository_access_provider_unavailable" ) => @@ -2504,6 +2506,23 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { { StatusCode::FORBIDDEN } + RuntimeError::WorkingDirectory(diagnostic) + if matches!( + diagnostic.code.as_str(), + "working_directory_cleanup_mount_present" + | "working_directory_cleanup_resource_busy" + | "working_directory_cleanup_ownership_unknown" + | "working_directory_cleanup_changes_present" + | "working_directory_cleanup_changes_unknown" + ) => + { + StatusCode::CONFLICT + } + RuntimeError::WorkingDirectory(diagnostic) + if diagnostic.code == "working_directory_cleanup_permission_denied" => + { + StatusCode::FORBIDDEN + } RuntimeError::WorkingDirectory(diagnostic) if diagnostic.code == "repository_ref_not_found" => { @@ -4322,6 +4341,37 @@ mod tests { assert!(!error.to_string().contains('/')); } + #[tokio::test] + async fn cleanup_cause_codes_survive_http_error_serialization() { + let cases = [ + ("mount_present", StatusCode::CONFLICT), + ("mount_check_unavailable", StatusCode::SERVICE_UNAVAILABLE), + ("permission_denied", StatusCode::FORBIDDEN), + ("resource_busy", StatusCode::CONFLICT), + ("storage_unavailable", StatusCode::SERVICE_UNAVAILABLE), + ("ownership_unknown", StatusCode::CONFLICT), + ("changes_present", StatusCode::CONFLICT), + ("changes_unknown", StatusCode::CONFLICT), + ]; + for (cause, status) in cases { + let code = format!("working_directory_cleanup_{cause}"); + let diagnostic = crate::working_directory::WorkingDirectoryDiagnostic::rejected( + &code, + "Resolve the current blocker and retry removal.", + ); + let response = + RuntimeHttpRestError::runtime(RuntimeError::WorkingDirectory(diagnostic)) + .into_response(); + assert_eq!(response.status(), status); + let body = axum::body::to_bytes(response.into_body(), 2048) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], code, "{json}"); + assert!(json["error"]["message"].as_str().unwrap().contains("retry")); + } + } + #[test] fn workdir_runtime_errors_preserve_diagnostic_code() { let cases = [ diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 543d89244..3489140e9 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -819,23 +819,30 @@ impl Runtime { &self, working_directory_id: &str, ) -> Result { - let backend = { - let state = self.lock()?; - state.ensure_running()?; - if let Some(worker_id) = state.worker_id_for_workdir(working_directory_id) { - return Err(RuntimeError::InvalidRequest(format!( - "working directory {working_directory_id} is assigned to worker {worker_id}" - ))); + // Hold occupancy authority through the side effect, preventing an + // attachment from entering between the check and physical removal. + // This synchronous provider call must not reenter Runtime. The Git + // materializer does filesystem/Git work only. The global guard also + // delays unrelated state operations during hashing/unlink; releasing it + // would require equivalent per-workdir exclusion on ALL attachment paths, + // not just moving the occupied check before the provider call. + let state = self.lock()?; + state.ensure_running()?; + if let Some(worker_id) = state.worker_id_for_workdir(working_directory_id) { + return Err(RuntimeError::InvalidRequest(format!( + "working directory {working_directory_id} is assigned to worker {worker_id}" + ))); + } + let backend = state.execution_backend.clone().ok_or_else(|| { + RuntimeError::ExecutionBackendUnavailable { + message: "working directory cleanup requires an execution backend".to_string(), } - state.execution_backend.clone().ok_or_else(|| { - RuntimeError::ExecutionBackendUnavailable { - message: "working directory cleanup requires an execution backend".to_string(), - } - })? - }; - backend + })?; + let result = backend .cleanup_working_directory(working_directory_id) - .map_err(RuntimeError::from) + .map_err(RuntimeError::from); + drop(state); + result } fn annotate_working_directory_statuses( @@ -7665,6 +7672,7 @@ mod tests { repository_accesses: Mutex>, repository_access_available: AtomicBool, working_directory_requests: Mutex>, + cleanup_occupancy_probe: Mutex>>>, preserve_submission_acknowledgement_id: AtomicBool, #[cfg(feature = "ws-server")] snapshots: Mutex>, @@ -7705,6 +7713,22 @@ mod tests { } impl WorkerExecutionBackend for TestExecutionBackend { + fn cleanup_working_directory( + &self, + _id: &str, + ) -> Result { + if let Some(state) = self.cleanup_occupancy_probe.lock().unwrap().as_ref() { + assert!( + matches!(state.try_lock(), Err(std::sync::TryLockError::WouldBlock)), + "occupancy must remain exclusive during physical cleanup" + ); + } + Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_cleanup_resource_busy", + "Retry removal after releasing the resource", + )) + } + fn backend_id(&self) -> &str { "test-execution-backend" } @@ -8043,6 +8067,22 @@ mod tests { .block_on(subscription.recv()) } + #[test] + fn workdir_removal_keeps_occupancy_exclusive_during_provider_side_effect() { + let (runtime, backend) = runtime_and_backend(); + *backend.cleanup_occupancy_probe.lock().unwrap() = Some(runtime.inner.clone()); + let error = runtime + .cleanup_working_directory("workdir-test") + .unwrap_err(); + assert!( + matches!(error, RuntimeError::WorkingDirectory(ref diagnostic) + if diagnostic.code == "working_directory_cleanup_resource_busy") + ); + // The lock is released on failure, permitting normal attachment/retry. + assert!(runtime.inner.try_lock().is_ok()); + *backend.cleanup_occupancy_probe.lock().unwrap() = None; + } + #[test] fn unknown_typed_intent_is_rejected_before_backend_dispatch() { let (runtime, backend) = runtime_and_backend(); diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d338848a8..9ac79973b 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -19,7 +19,6 @@ use crate::catalog::{ CreateWorkerRequest, ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest, WorkingDirectoryAttachmentStatus, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, - WorkingDirectoryStatusKind, }; use crate::config_bundle::{ConfigBundle, workspace_config_etag}; #[cfg(feature = "ws-server")] @@ -3773,28 +3772,24 @@ where ) })?; for previous in &request.previous_workdir_attachments { - let current = materializer - .working_directory_status( - &previous.working_directory.summary.working_directory_id, - ) - .map_err(|message| { - WorkerExecutionResult::rejected( - WorkerExecutionOperation::Restore, - format!( - "Persisted Worker Workdir attachment `{}` is unavailable: {message}", - previous.alias - ), - ) - })?; - if current.summary.status != WorkingDirectoryStatusKind::Active { - return Err(WorkerExecutionResult::rejected( + let relative_cwd = request + .request + .workdir_attachments + .iter() + .find(|claim| claim.alias == previous.alias) + .and_then(|claim| claim.relative_cwd.as_deref()); + // Recovery attachments remain CleanupPending across restart. + // Validate current bindability/identity, not the old state name. + // Preflight is read-only; actual restore binds again below. + materializer.preflight_bind_working_directory( + &previous.working_directory.summary.working_directory_id, + relative_cwd, + ).map_err(|message| { + WorkerExecutionResult::rejected( WorkerExecutionOperation::Restore, - format!( - "Persisted Worker Workdir attachment `{}` is not active", - previous.alias - ), - )); - } + format!("Persisted Worker Workdir attachment `{}` is unavailable: {message}", previous.alias), + ) + })?; } } if request.previous_workdir_attachments.is_empty() @@ -4637,7 +4632,7 @@ mod tests { ConfigBundleRef, CreateWorkerRequest, LogicalWorkdirAttachment, MaterializerKind, ProfileSelector, RepositorySelector, WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest, WorkingDirectoryRepository, WorkingDirectoryRequest, - WorkspaceApiRef, + WorkingDirectoryStatusKind, WorkspaceApiRef, }; use crate::execution::WorkerExecutionContext; use crate::identity::WorkerId; @@ -8919,6 +8914,160 @@ mod tests { ); } + #[cfg(target_os = "linux")] + fn pending_owner_restore_fixture() -> ( + tempfile::TempDir, + WorkerRuntimeExecutionBackend, + WorkerExecutionRestoreRequest, + PathBuf, + Arc>>, + ) { + use crate::working_directory::{WorkingDirectoryMaterializer, fail_workdir_cleanup_once}; + let root = tempfile::tempdir().unwrap(); + let repo = create_clean_repo(); + let workdirs = root.path().join("workdirs"); + let materializer = RuntimeGitMaterializer::new(&workdirs); + let binding = materializer + .create(&working_directory_request(repo.path())) + .unwrap(); + let id = binding.working_directory.id.clone(); + fail_workdir_cleanup_once(&materializer, &id); + // The cleanup owner has legitimately reattached to preserve these changes. + fs::write(binding.root.join("README.md"), "owner recovery in progress").unwrap(); + let observed = Arc::new(Mutex::new(Vec::new())); + let factory = || MockFactory { + client: MockClient::new(simple_text_events()), + runtime_base: root.path().join("runtime"), + cwd: root.path().to_path_buf(), + store_dir: root.path().join("sessions"), + worker_metadata_dir: root.path().join("workers"), + observed_cwds: observed.clone(), + observed_workspace_clients: Arc::new(Mutex::new(Vec::new())), + }; + let backend = Arc::new( + WorkerRuntimeExecutionBackend::new(factory()) + .unwrap() + .with_working_directory_materializer(materializer), + ); + let runtime = + EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone()) + .unwrap(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let mut request = create_request("cleanup-owner"); + request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim { + alias: WorkdirAttachmentAlias::new("checkout").unwrap(), + working_directory_id: id, + relative_cwd: None, + capabilities: WorkdirSessionCapabilities::ALL, + }]; + let detail = runtime.create_worker(request.clone()).unwrap(); + runtime.stop_worker(&detail.worker_ref, None).unwrap(); + let restore = WorkerExecutionRestoreRequest { + operation_id: crate::execution::WorkerLifecycleOperationId::new(), + worker_ref: detail.worker_ref.clone(), + request, + workspace_scope: None, + context: test_execution_context(detail.worker_ref), + previous_workdir_attachments: detail.workdir_attachments, + logical_workdir_attachments: Vec::new(), + workdir_attachments: BTreeMap::new(), + config_bundle: None, + }; + drop(runtime); + drop(backend); + // A fresh backend/materializer loads the durable pending identity evidence. + let restarted = WorkerRuntimeExecutionBackend::new(factory()) + .unwrap() + .with_working_directory_materializer(RuntimeGitMaterializer::new(&workdirs)); + (root, restarted, restore, binding.root, observed) + } + + #[cfg(target_os = "linux")] + #[test] + fn cleanup_pending_owner_restores_after_restart_with_validated_dirty_checkout() { + let (root, backend, request, checkout, observed) = pending_owner_restore_fixture(); + let before = persisted_files(&root.path().join("workdirs")); + backend.preflight_restore(&request).unwrap(); + assert_eq!( + persisted_files(&root.path().join("workdirs")), + before, + "preflight must not rewrite removal or Git authority" + ); + let restored = match backend.restore_worker(request.clone()) { + WorkerExecutionSpawnResult::Connected { + workdir_attachments, + .. + } => workdir_attachments, + other => panic!("cleanup-owner restore failed: {other:?}"), + }; + assert_eq!(restored.len(), 1); + assert_eq!( + restored[0].working_directory.summary.status, + WorkingDirectoryStatusKind::CleanupPending + ); + assert_eq!( + restored[0].working_directory.summary.cleanliness.as_deref(), + Some("dirty") + ); + assert_eq!( + observed.lock().unwrap().as_slice(), + &[checkout.clone(), checkout.clone()] + ); + assert_eq!( + fs::read_to_string(checkout.join("README.md")).unwrap(), + "owner recovery in progress" + ); + assert!(backend.stop_worker(&request.worker_ref).is_accepted()); + } + + #[cfg(target_os = "linux")] + #[test] + fn cleanup_pending_owner_restore_rejects_incomplete_replaced_and_raced_checkouts() { + for damage in ["missing_git", "replacement", "missing_after_preflight"] { + let (root, backend, request, checkout, observed) = pending_owner_restore_fixture(); + if damage == "missing_after_preflight" { + backend.preflight_restore(&request).unwrap(); + } + if damage == "replacement" { + fs::rename(&checkout, root.path().join("original-checkout")).unwrap(); + fs::create_dir(&checkout).unwrap(); + fs::create_dir(checkout.join(".git")).unwrap(); + } else { + fs::remove_dir_all(checkout.join(".git")).unwrap(); + } + let before = persisted_files(root.path()); + let error = backend.preflight_restore(&request).unwrap_err(); + assert_eq!( + error.outcome, + crate::execution::WorkerExecutionOutcome::Rejected, + "{damage}" + ); + assert!( + error + .message_or_default() + .contains("working_directory_cleanup_"), + "{error:?}" + ); + assert!( + matches!( + backend.restore_worker(request), + WorkerExecutionSpawnResult::Rejected(_) + ), + "{damage}" + ); + assert_eq!( + observed.lock().unwrap().len(), + 1, + "must not start a Controller on invalid checkout: {damage}" + ); + assert_eq!( + persisted_files(root.path()), + before, + "must preserve damaged/replaced checkout: {damage}" + ); + } + } + #[test] fn worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo() { let client = MockClient::new(simple_text_events()); diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index e44c574bc..178850a55 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -22,6 +22,11 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use workdir::WorkdirSessionResource; +mod cleanup; + +#[cfg(all(test, target_os = "linux"))] +pub(crate) use cleanup::tests::fail_once as fail_workdir_cleanup_once; + const CHECKOUT_DIR: &str = "checkout"; const MATERIALIZATION_RECORD: &str = "materialization.json"; const REPOSITORY_ACCESS_DIR: &str = ".repository-access"; @@ -127,7 +132,11 @@ impl WorkingDirectoryBinding { working_directory.status = WorkingDirectoryStatusKind::Corrupted; } let mut summary = working_directory.status_summary(); - summary.cleanliness = if summary.status == WorkingDirectoryStatusKind::Active { + summary.cleanliness = if matches!( + summary.status, + WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending + ) && binding_paths_are_available(self) + { let (current_selector, current_ref, current_tree) = binding_current_revision(self); summary.current_selector = current_selector; summary.current_ref = current_ref; @@ -201,6 +210,24 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static { relative_cwd: Option<&str>, ) -> Result; + /// Read-only bind validation: do not consume Repository credentials or open + /// session resources during restore preflight. Providers must opt in with + /// current usable-checkout validation to support non-active attachments. + fn preflight_bind_working_directory( + &self, + working_directory_id: &str, + _relative_cwd: Option<&str>, + ) -> Result<(), WorkingDirectoryDiagnostic> { + let current = self.working_directory_status(working_directory_id)?; + if current.summary.status != WorkingDirectoryStatusKind::Active { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_not_active", + "Persisted Workdir is not active and its provider cannot validate recovery binding", + )); + } + Ok(()) + } + fn list_working_directories( &self, ) -> Result, WorkingDirectoryDiagnostic>; @@ -627,6 +654,29 @@ impl RuntimeGitMaterializer { }) } + fn checked_binding( + &self, + working_directory_id: &str, + relative_cwd: Option<&str>, + ) -> Result { + validate_working_directory_id(working_directory_id)?; + let binding = self.read_binding(working_directory_id)?; + if !matches!( + binding.working_directory.status, + WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending + ) { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_not_active", + "working directory working_directory is not active", + )); + } + if binding.working_directory.status == WorkingDirectoryStatusKind::CleanupPending { + cleanup::validate_recovery_binding(self, working_directory_id, &binding)?; + } + let cwd = validate_relative_cwd(binding.root(), relative_cwd)?; + Ok(WorkingDirectoryBinding { cwd, ..binding }) + } + fn read_binding( &self, working_directory_id: &str, @@ -1137,17 +1187,17 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { working_directory_id: &str, relative_cwd: Option<&str>, ) -> Result { - validate_working_directory_id(working_directory_id)?; - let binding = self.read_binding(working_directory_id)?; - if binding.working_directory.status != WorkingDirectoryStatusKind::Active { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_not_active", - "working directory working_directory is not active", - )); - } - let binding = self.bind_repository_access(working_directory_id, binding)?; - let cwd = validate_relative_cwd(binding.root(), relative_cwd)?; - Ok(WorkingDirectoryBinding { cwd, ..binding }) + let binding = self.checked_binding(working_directory_id, relative_cwd)?; + self.bind_repository_access(working_directory_id, binding) + } + + fn preflight_bind_working_directory( + &self, + working_directory_id: &str, + relative_cwd: Option<&str>, + ) -> Result<(), WorkingDirectoryDiagnostic> { + self.checked_binding(working_directory_id, relative_cwd) + .map(|_| ()) } fn list_working_directories( @@ -1178,6 +1228,10 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { { continue; } + if let Some(status) = cleanup::remaining_status(self, &working_directory_id) { + statuses.push(status); + continue; + } match self.read_binding(&working_directory_id) { Ok(binding) => statuses.push(binding.status()), Err(_) => statuses.push(self.corrupted_status(&working_directory_id)), @@ -1197,11 +1251,24 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { ) -> Result { validate_working_directory_id(working_directory_id)?; let working_directory_root = self.working_directory_root(working_directory_id); - if !working_directory_root.exists() { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_not_found", - "working directory working_directory was not found", - )); + match fs::symlink_metadata(&working_directory_root) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_not_found", + "Runtime working directory was not found", + )); + } + Err(error) => { + return Err(cleanup::os_failure( + working_directory_id, + "lookup_cleanup_root", + error, + )); + } + } + if let Some(status) = cleanup::remaining_status(self, working_directory_id) { + return Ok(status); } match self.read_binding(working_directory_id) { Ok(binding) => Ok(binding.status()), @@ -1214,80 +1281,13 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer { working_directory_id: &str, ) -> Result { validate_working_directory_id(working_directory_id)?; - let status = self.working_directory_status(working_directory_id)?; - if status.summary.status == WorkingDirectoryStatusKind::Corrupted { - let working_directory_root = self.working_directory_root(working_directory_id); - if working_directory_root.exists() { - fs::remove_dir_all(&working_directory_root).map_err(|_| { - WorkingDirectoryDiagnostic::new( - "working_directory_corrupted_cleanup_failed", - "failed to remove corrupted working directory; backend-private path details were omitted", - ) - })?; - } - let mut summary = status.summary; - summary.status = WorkingDirectoryStatusKind::NotFound; - return Ok(WorkingDirectoryStatus { summary }); - } - let binding = self.read_binding(working_directory_id)?; - self.cleanup(&binding)?; - let mut summary = binding.working_directory.status_summary(); - summary.status = WorkingDirectoryStatusKind::NotFound; - summary.cleanliness = Some("unknown".to_string()); - if binding.working_directory_root.exists() { - fs::remove_dir_all(&binding.working_directory_root).map_err(|_| { - WorkingDirectoryDiagnostic::new( - "working_directory_record_cleanup_failed", - "failed to remove working directory record; backend-private path details were omitted", - ) - })?; - } - Ok(WorkingDirectoryStatus { summary }) + cleanup::remove(self, working_directory_id) } fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> { - let mut working_directory = binding.working_directory.clone(); - let working_directory_root = binding.working_directory_root.canonicalize().map_err(|_| { - WorkingDirectoryDiagnostic::new( - "working_directory_cleanup_target_invalid", - "working directory working directory root is unavailable; backend-private path details were omitted", - ) - })?; - let root = binding.root.canonicalize().map_err(|_| { - WorkingDirectoryDiagnostic::new( - "working_directory_cleanup_target_invalid", - "working directory root is unavailable; backend-private path details were omitted", - ) - })?; - if !root.starts_with(&working_directory_root) { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_cleanup_escape_rejected", - "working directory cleanup target is outside the working directory root", - )); - } - let remove_result = fs::remove_dir_all(&working_directory_root).map_err(|_| { - WorkingDirectoryDiagnostic::new( - "working_directory_cleanup_failed", - "failed to remove working directory; backend-private path details were omitted", - ) - }); - if remove_result.is_err() { - working_directory.status = WorkingDirectoryStatusKind::CleanupPending; - let updated = WorkingDirectoryBinding { - working_directory, - root: binding.root.clone(), - cwd: binding.cwd.clone(), - working_directory_root: binding.working_directory_root.clone(), - command_environment: BTreeMap::new(), - session_resources: Vec::new(), - }; - let _ = self.write_record(&updated); - } else { - let _ = self - .repository_access - .remove_pending(&binding.working_directory.id); - } - remove_result + // Reload current authority rather than trusting a stale in-memory binding. + self.cleanup_working_directory(&binding.working_directory.id) + .map(|_| ()) } } @@ -2826,6 +2826,7 @@ where I: IntoIterator, { let output = Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") .arg("-C") .arg(repository_path) .args(args) @@ -3094,7 +3095,7 @@ mod tests { assert!(scheduler_inner.upgrade().is_none()); } - fn git(path: &Path, args: &[&str]) { + pub(super) fn git(path: &Path, args: &[&str]) { let status = Command::new("git") .arg("-C") .arg(path) @@ -3104,7 +3105,7 @@ mod tests { assert!(status.success(), "git {:?} failed", args); } - fn create_clean_repo() -> tempfile::TempDir { + pub(super) fn create_clean_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); git(dir.path(), &["init"]); git( @@ -3118,7 +3119,7 @@ mod tests { dir } - fn request(repo: &Path) -> WorkingDirectoryRequest { + pub(super) fn request(repo: &Path) -> WorkingDirectoryRequest { WorkingDirectoryRequest { repository: WorkingDirectoryRepository { id: "repo-main".to_string(), @@ -3678,6 +3679,8 @@ mod tests { .evidence .host_trust_revision = Some(1); materializer.write_record(&ssh_backed_binding).unwrap(); + #[cfg(target_os = "linux")] + fail_workdir_cleanup_once(&materializer, &id); let mut session_materialization = initial_materialization.clone(); let session_delivery_expiry = repository_access_now_epoch_seconds() + 1; session_materialization @@ -3691,6 +3694,17 @@ mod tests { materialization: session_materialization, }) .unwrap(); + // Restore preflight must validate without consuming this one-shot SSH + // delivery or creating agent/session resources needed by actual bind. + let record_before = + fs::read(runtime_root.path().join(&id).join(MATERIALIZATION_RECORD)).unwrap(); + materializer + .preflight_bind_working_directory(&id, None) + .unwrap(); + assert_eq!( + fs::read(runtime_root.path().join(&id).join(MATERIALIZATION_RECORD)).unwrap(), + record_before + ); assert_eq!(materializer.list_working_directories().unwrap().len(), 1); assert_eq!( fs::read_dir(runtime_root.path().join(".repository-agents")) @@ -3916,6 +3930,22 @@ mod tests { restored.bind_working_directory(&id, None).unwrap_err().code, "working_directory_remote_repository_access_required" ); + #[cfg(target_os = "linux")] + { + // Real SSH reauthorization updated access evidence repeatedly, but + // did not replace this clean materialization or require manual recovery. + assert_eq!( + restored + .working_directory_status(&id) + .unwrap() + .summary + .cleanliness + .as_deref(), + Some("clean") + ); + restored.cleanup_working_directory(&id).unwrap(); + assert!(!restored.working_directory_root(&id).exists()); + } } #[test] @@ -4393,7 +4423,7 @@ mod tests { } #[test] - fn corrupted_working_directory_record_can_be_removed() { + fn corrupted_working_directory_without_removal_authority_is_retained() { let runtime_root = tempfile::tempdir().unwrap(); let materializer = RuntimeGitMaterializer::new(runtime_root.path()); let working_directory_id = "workdir-corrupted"; @@ -4406,11 +4436,11 @@ mod tests { .unwrap(); assert_eq!(status.summary.status, WorkingDirectoryStatusKind::Corrupted); - let removed = materializer + let error = materializer .cleanup_working_directory(working_directory_id) - .unwrap(); - assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound); - assert!(!root.exists()); + .unwrap_err(); + assert_eq!(error.code, "working_directory_cleanup_ownership_unknown"); + assert!(root.exists()); } #[test] diff --git a/crates/worker-runtime/src/working_directory/cleanup.rs b/crates/worker-runtime/src/working_directory/cleanup.rs new file mode 100644 index 000000000..037f01343 --- /dev/null +++ b/crates/worker-runtime/src/working_directory/cleanup.rs @@ -0,0 +1,1575 @@ +//! Ordinary removal authority, not a recovery lifecycle. The content witness is +//! outside the tree being removed so a crash/partial unlink cannot erase it. +//! Every retry checks current identities, mounts and remaining content. Missing +//! entries are expected after partial deletion. The same intact clean checkout +//! may renew its witness after owner recovery, never discarding new ignored content. +use super::*; +use std::io; + +fn cause(kind: &str) -> WorkingDirectoryDiagnostic { + let message = match kind { + "mount_present" => { + "A mounted resource remains. Its owner must safely release it before retrying removal; Runtime will not unmount external or unknown resources." + } + "mount_check_unavailable" => { + "Mount-safe removal is unavailable. Restore Runtime mount-check support and retry." + } + "permission_denied" => { + "Removal was denied by filesystem permissions. Ask the resource owner to resolve permissions and retry." + } + "resource_busy" => { + "A filesystem resource is busy or changed during removal. Release the resource and retry." + } + "storage_unavailable" => { + "Cleanup storage is unavailable. Restore storage access and retry." + } + "ownership_unknown" => { + "The remaining directory cannot be matched to Runtime removal authority. Verify its identity and ownership before removal." + } + "changes_present" => { + "New or changed content is present. Preserve or resolve those changes before retrying removal." + } + _ => { + "The checkout is incomplete and remaining content has no usable removal witness. Preserve residual content outside this Workdir and restore the same usable checkout, or ask its owner to remove verified residual content before retrying. Do not edit removal metadata." + } + }; + WorkingDirectoryDiagnostic::new(format!("working_directory_cleanup_{kind}"), message) +} + +pub(super) fn os_failure(id: &str, action: &str, error: io::Error) -> WorkingDirectoryDiagnostic { + let kind = match error.raw_os_error() { + Some(libc::EXDEV) => "mount_present", + Some(libc::ENOSYS | libc::EOPNOTSUPP | libc::EINVAL) => "mount_check_unavailable", + Some(libc::EACCES | libc::EPERM) => "permission_denied", + Some(libc::EBUSY | libc::ENOTEMPTY | libc::EAGAIN) => "resource_busy", + _ => "storage_unavailable", + }; + tracing::warn!(target: "yoi::workdir_cleanup", working_directory_id = id, + action, cause = kind, raw_os_error = ?error.raw_os_error(), error = %error, + "Runtime-private working directory cleanup failure"); + cause(kind) +} + +pub(super) fn remove( + materializer: &RuntimeGitMaterializer, + id: &str, +) -> Result { + let result = remove_checked(materializer, id); + if let Err(error) = &result { + tracing::warn!(target: "yoi::workdir_cleanup", working_directory_id = id, + action = "cleanup_working_directory", code = %error.code, + "working directory removal blocked"); + } + result +} + +#[cfg(target_os = "linux")] +pub(super) fn remaining_status( + materializer: &RuntimeGitMaterializer, + id: &str, +) -> Option { + let parent = fs::File::open(&materializer.runtime_root).ok()?; + let saved = load_authority(&parent, id).ok()??; + let tree = open_directory(&parent, std::ffi::OsStr::new(id)).ok()?; + if saved.working_directory.id != id || identity(&tree).ok()? != saved.root_identity { + return None; + } + let mut summary = saved.working_directory.status_summary(); + summary.status = WorkingDirectoryStatusKind::CleanupPending; + summary.cleanliness = Some( + match check_mounts( + &materializer.working_directory_root(id), + &fs::read("/proc/self/mountinfo").ok()?, + ) + .and_then(|()| retry_inventory(materializer, id, &tree, &saved)) + { + Ok(_) => "clean", + Err(error) if error.code == "working_directory_cleanup_changes_present" => "dirty", + Err(_) => "unknown", + } + .to_string(), + ); + Some(WorkingDirectoryStatus { summary }) +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn remaining_status( + _materializer: &RuntimeGitMaterializer, + _id: &str, +) -> Option { + None +} + +#[cfg(not(target_os = "linux"))] +fn remove_checked( + _materializer: &RuntimeGitMaterializer, + _id: &str, +) -> Result { + // No fallback to remove_dir_all: mount safety is a precondition. + Err(cause("mount_check_unavailable")) +} + +#[cfg(target_os = "linux")] +use linux::*; + +#[cfg(target_os = "linux")] +fn remove_checked( + materializer: &RuntimeGitMaterializer, + id: &str, +) -> Result { + remove_with_eraser(materializer, id, erase) +} + +#[cfg(target_os = "linux")] +fn remove_with_eraser( + materializer: &RuntimeGitMaterializer, + id: &str, + erase_tree: impl FnOnce(&fs::File, &Path, &BTreeMap) -> Result<(), EraseError>, +) -> Result { + remove_with_checks( + materializer, + id, + || fs::read("/proc/self/mountinfo"), + erase_tree, + ) +} + +#[cfg(target_os = "linux")] +fn remove_with_checks( + materializer: &RuntimeGitMaterializer, + id: &str, + mount_info: impl FnOnce() -> io::Result>, + erase_tree: impl FnOnce(&fs::File, &Path, &BTreeMap) -> Result<(), EraseError>, +) -> Result { + let parent = match fs::File::open(&materializer.runtime_root) { + Ok(parent) => parent, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_not_found", + "Runtime working directory was not found", + )); + } + Err(error) => return Err(os_failure(id, "open_runtime_root", error)), + }; + let tree = match open_directory(&parent, std::ffi::OsStr::new(id)) { + Ok(tree) => tree, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_not_found", + "Runtime working directory was not found", + )); + } + Err(error) if matches!(error.raw_os_error(), Some(libc::ELOOP | libc::ENOTDIR)) => { + return Err(cause("ownership_unknown")); + } + Err(error) => return Err(os_failure(id, "open_cleanup_root", error)), + }; + let root_identity = identity(&tree).map_err(|e| os_failure(id, "identify_root", e))?; + let saved = load_authority(&parent, id).map_err(|error| { + if error.kind() == io::ErrorKind::InvalidData { + cause("ownership_unknown") + } else { + os_failure(id, "read_removal_authority", error) + } + })?; + // Mount table preflight catches mounted files and same-device bind mounts + // before Git/status/hash reads; descriptor NO_XDEV remains the race guard. + check_mounts( + &materializer.working_directory_root(id), + &mount_info().map_err(|e| { + let _ = os_failure(id, "read_mountinfo", e); + cause("mount_check_unavailable") + })?, + )?; + let authority = if let Some(saved) = saved { + if saved.working_directory.id != id || saved.root_identity != root_identity { + return Err(cause("ownership_unknown")); + } + let entries = retry_inventory(materializer, id, &tree, &saved)?; + if verify_remaining(&saved.entries, &entries).is_ok() { + saved + } else { + let authority = Authority { entries, ..saved }; + // Renew obsolete content evidence only from a current clean, intact + // checkout. Partial deletion without Git retains the old witness. + persist_authority(&parent, id, &authority) + .map_err(|e| os_failure(id, "refresh_removal_authority", e))?; + authority + } + } else { + let mut binding = materializer + .read_binding(id) + .map_err(|_| cause("ownership_unknown"))?; + validate_binding(materializer, id, &binding)?; + // Preflight the entire tree without following links or crossing mounts, + // before asking Git to inspect the checkout. + let before = inventory(&tree).map_err(|e| os_failure(id, "inspect_tree", e))?; + // Validated metadata-only residue contains no user content to discard. + if !metadata_only(&before) { + check_clean_checkout(id, &binding, &before, None)?; + } + if inventory(&tree).map_err(|e| os_failure(id, "recheck_inspected_tree", e))? != before { + return Err(cause("changes_present")); + } + binding.working_directory.status = WorkingDirectoryStatusKind::CleanupPending; + let record = WorkingDirectoryMaterializationRecord { + working_directory: binding.working_directory.clone(), + root: binding.root.clone(), + }; + let raw = serde_json::to_vec_pretty(&record).map_err(|_| cause("storage_unavailable"))?; + rewrite_record(&tree, &raw, before.get(Path::new(MATERIALIZATION_RECORD))) + .map_err(|e| os_failure(id, "write_cleanup_pending", e))?; + let entries = inventory(&tree).map_err(|e| os_failure(id, "capture_removal_content", e))?; + // Only our status-record write may differ between the clean observation + // and durable witness. Never authorize a newly arrived user change. + let mut without_record = entries.clone(); + without_record.remove(Path::new(MATERIALIZATION_RECORD)); + let mut before_without_record = before; + before_without_record.remove(Path::new(MATERIALIZATION_RECORD)); + if without_record != before_without_record { + return Err(cause("changes_present")); + } + let authority = Authority { + working_directory: binding.working_directory.clone(), + root_identity, + entries, + }; + persist_authority(&parent, id, &authority) + .map_err(|e| os_failure(id, "persist_removal_authority", e))?; + authority + }; + let current = inventory(&tree).map_err(|e| os_failure(id, "recheck_remaining_content", e))?; + verify_remaining(&authority.entries, ¤t)?; + // Repeat the content/identity comparison at each unlink. No unverified + // recursion into a replaced entry, even if a mount arrives after preflight. + erase_tree(&tree, Path::new(""), &authority.entries).map_err(|e| match e { + EraseError::Io(e) => os_failure(id, "unlink_remaining_tree", e), + EraseError::Changed => cause("changes_present"), + })?; + let current_root = open_directory(&parent, std::ffi::OsStr::new(id)) + .map_err(|e| os_failure(id, "recheck_root_identity", e))?; + if identity(¤t_root).map_err(|e| os_failure(id, "recheck_root_identity", e))? + != authority.root_identity + { + return Err(cause("ownership_unknown")); + } + unlink(&parent, std::ffi::OsStr::new(id), true) + .map_err(|e| os_failure(id, "unlink_cleanup_root", e))?; + parent + .sync_all() + .map_err(|e| os_failure(id, "sync_physical_removal", e))?; + // The authority is obsolete only after physical removal. Its removal cannot + // convert an already completed physical cleanup to an unknown outcome. + if let Err(error) = discard_authority(&parent, id) { + tracing::warn!(target: "yoi::workdir_cleanup", working_directory_id = id, + action = "remove_obsolete_authority", raw_os_error = ?error.raw_os_error(), error = %error); + } + let _ = materializer.repository_access.remove_pending(id); + let mut summary = authority.working_directory.status_summary(); + summary.status = WorkingDirectoryStatusKind::NotFound; + summary.cleanliness = Some("unknown".to_string()); + Ok(WorkingDirectoryStatus { summary }) +} + +#[cfg(target_os = "linux")] +fn metadata_only(entries: &BTreeMap) -> bool { + entries.iter().all(|(path, entry)| { + path == Path::new(MATERIALIZATION_RECORD) + || (path == Path::new(CHECKOUT_DIR) && entry.mode & libc::S_IFMT == libc::S_IFDIR) + }) +} + +#[cfg(target_os = "linux")] +fn same_materialization(saved: &WorkingDirectory, current: &WorkingDirectory) -> bool { + let immutable = |directory: &WorkingDirectory| { + let mut directory = directory.clone(); + // These fields are refreshed by authorize_repository_access, not a new + // materialization. All repository, creation and cleanup identity stays. + directory.evidence.operation_id = None; + directory.evidence.credential_revision = None; + directory.evidence.host_trust_revision = None; + directory + }; + immutable(saved) == immutable(current) +} + +#[cfg(target_os = "linux")] +fn retry_inventory( + materializer: &RuntimeGitMaterializer, + id: &str, + tree: &fs::File, + saved: &Authority, +) -> Result, WorkingDirectoryDiagnostic> { + let before = inventory(tree).map_err(|e| os_failure(id, "inspect_retry_tree", e))?; + if verify_remaining(&saved.entries, &before).is_ok() { + // With no Git left, only the old witness authorizes the surviving subset. + return Ok(before); + } + let binding = materializer + .read_binding(id) + .map_err(|_| cause("changes_present"))?; + validate_binding(materializer, id, &binding)?; + if !same_materialization(&saved.working_directory, &binding.working_directory) + || before + .get(Path::new(MATERIALIZATION_RECORD)) + .is_none_or(|entry| entry.mode & libc::S_IFMT != libc::S_IFREG) + { + return Err(cause("ownership_unknown")); + } + check_clean_checkout(id, &binding, &before, Some(saved))?; + let parent = fs::File::open(&materializer.runtime_root) + .map_err(|e| os_failure(id, "reopen_runtime_root", e))?; + let live = open_directory(&parent, std::ffi::OsStr::new(id)) + .map_err(|e| os_failure(id, "recheck_retry_identity", e))?; + if identity(&live).map_err(|e| os_failure(id, "recheck_retry_identity", e))? + != saved.root_identity + { + return Err(cause("ownership_unknown")); + } + if inventory(tree).map_err(|e| os_failure(id, "recheck_retry_content", e))? != before { + return Err(cause("changes_present")); + } + Ok(before) +} + +#[cfg(target_os = "linux")] +fn check_clean_checkout( + id: &str, + binding: &WorkingDirectoryBinding, + before: &BTreeMap, + saved: Option<&Authority>, +) -> Result<(), WorkingDirectoryDiagnostic> { + for required in [Path::new(CHECKOUT_DIR), Path::new("checkout/.git")] { + let Some(entry) = before + .get(required) + .filter(|entry| entry.mode & libc::S_IFMT == libc::S_IFDIR) + else { + return Err(cause("changes_unknown")); + }; + if let Some(saved) = saved { + if saved.entries.get(required) != Some(entry) { + return Err(cause("ownership_unknown")); + } + } + } + let run = |args: &[&str]| -> Result, WorkingDirectoryDiagnostic> { + let output = isolated_git_command() + .env("GIT_OPTIONAL_LOCKS", "0") + // Do not run repository-configured fsmonitor hooks while Runtime + // occupancy is held, or trust their cached cleanliness observation. + .args([ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + ]) + .arg("-C") + .arg(binding.root()) + .args(args) + .output() + .map_err(|e| os_failure(id, "inspect_changes", e))?; + if !output.status.success() { + return Err(cause("changes_unknown")); + } + Ok(output.stdout) + }; + // An empty/unborn index is not a usable committed checkout. + run(&["rev-parse", "--verify", "HEAD^{tree}"])?; + if !run(&["status", "--porcelain", "--untracked-files=all"])?.is_empty() { + return Err(cause("changes_present")); + } + if let Some(saved) = saved { + use std::os::unix::ffi::OsStrExt; + let tracked = run(&["ls-files", "--cached", "-z"])?; + let mut git_paths = std::collections::BTreeSet::new(); + for raw in tracked.split(|b| *b == 0).filter(|raw| !raw.is_empty()) { + let path = Path::new(CHECKOUT_DIR).join(std::ffi::OsStr::from_bytes(raw)); + for path in path.ancestors().filter(|path| !path.as_os_str().is_empty()) { + git_paths.insert(path.to_path_buf()); + } + } + // Git clean is not ignored-content permission. Current tracked content + // and Git metadata may change; all other entries stay witness-protected. + for (path, entry) in before { + if path != Path::new(MATERIALIZATION_RECORD) + && !path.starts_with("checkout/.git") + && !git_paths.contains(path) + && saved.entries.get(path) != Some(entry) + { + return Err(cause("changes_present")); + } + } + } + Ok(()) +} + +pub(super) fn validate_recovery_binding( + materializer: &RuntimeGitMaterializer, + id: &str, + binding: &WorkingDirectoryBinding, +) -> Result<(), WorkingDirectoryDiagnostic> { + validate_binding(materializer, id, binding)?; + #[cfg(target_os = "linux")] + { + let parent = fs::File::open(&materializer.runtime_root) + .map_err(|e| os_failure(id, "open_bind_root", e))?; + let tree = open_directory(&parent, std::ffi::OsStr::new(id)) + .map_err(|_| cause("ownership_unknown"))?; + let checkout = open_directory(&tree, std::ffi::OsStr::new(CHECKOUT_DIR)) + .map_err(|_| cause("changes_unknown"))?; + let git = open_directory(&checkout, std::ffi::OsStr::new(".git")) + .map_err(|_| cause("changes_unknown"))?; + if inspect(&tree, std::ffi::OsStr::new(MATERIALIZATION_RECORD)) + .map_err(|_| cause("ownership_unknown"))? + .mode + & libc::S_IFMT + != libc::S_IFREG + { + return Err(cause("ownership_unknown")); + } + if let Some(saved) = load_authority(&parent, id).map_err(|_| cause("ownership_unknown"))? { + if saved.working_directory.id != id + || !same_materialization(&saved.working_directory, &binding.working_directory) + || identity(&tree).ok() != Some(saved.root_identity) + || saved + .entries + .get(Path::new(CHECKOUT_DIR)) + .map(|entry| entry.identity) + != identity(&checkout).ok() + || saved + .entries + .get(Path::new("checkout/.git")) + .map(|entry| entry.identity) + != identity(&git).ok() + { + return Err(cause("ownership_unknown")); + } + } + } + // Dirty is intentional here: ordinary attachment lets the owner preserve + // changes/release resources. Cleanup still separately rechecks occupancy. + if !binding_paths_are_available(binding) + || git_stdout(binding.root(), ["rev-parse", "--verify", "HEAD^{tree}"]).is_err() + { + return Err(cause("changes_unknown")); + } + Ok(()) +} + +pub(super) fn validate_binding( + materializer: &RuntimeGitMaterializer, + id: &str, + binding: &WorkingDirectoryBinding, +) -> Result<(), WorkingDirectoryDiagnostic> { + let directory = &binding.working_directory; + if directory.id != id + || directory.materializer_kind != MaterializerKind::RuntimeGitClone + || directory.evidence.materializer_kind != MaterializerKind::RuntimeGitClone + || directory.repository_id != directory.evidence.repository_id + || directory.cleanup_target.working_directory_id != id + || directory.cleanup_target.repository_id != directory.repository_id + || directory.cleanup_target.kind != "runtime_git_clone" + || binding.root != materializer.working_directory_root(id).join(CHECKOUT_DIR) + { + return Err(cause("ownership_unknown")); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +#[derive(Serialize, Deserialize)] +struct Authority { + working_directory: WorkingDirectory, + root_identity: (u64, u64), + entries: BTreeMap, +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct Entry { + identity: (u64, u64), + mode: u32, + digest: Option, +} + +#[cfg(target_os = "linux")] +fn verify_remaining( + expected: &BTreeMap, + current: &BTreeMap, +) -> Result<(), WorkingDirectoryDiagnostic> { + if current + .iter() + .any(|(name, entry)| expected.get(name) != Some(entry)) + { + return Err(cause("changes_present")); + } + Ok(()) +} + +// mountinfo escapes spaces, tabs, newlines and backslashes as octal. Parse bytes +// instead of lossy Unicode; malformed input is not permission to recurse. +#[cfg(target_os = "linux")] +fn check_mounts(root: &Path, raw: &[u8]) -> Result<(), WorkingDirectoryDiagnostic> { + use std::os::unix::ffi::OsStringExt; + let root = root + .canonicalize() + .map_err(|_| cause("ownership_unknown"))?; + if raw.is_empty() { + return Err(cause("mount_check_unavailable")); + } + for line in raw.split(|b| *b == b'\n').filter(|line| !line.is_empty()) { + let fields: Vec<_> = line.split(|b| *b == b' ').collect(); + if fields.len() < 10 || !fields.contains(&b"-".as_slice()) { + return Err(cause("mount_check_unavailable")); + } + let mut path = Vec::new(); + let mut i = 0; + while i < fields[4].len() { + if fields[4][i] == b'\\' { + let digits = fields[4] + .get(i + 1..i + 4) + .ok_or_else(|| cause("mount_check_unavailable"))?; + if !digits.iter().all(|b| (b'0'..=b'7').contains(b)) { + return Err(cause("mount_check_unavailable")); + } + let value = (digits[0] - b'0') as u16 * 64 + + (digits[1] - b'0') as u16 * 8 + + (digits[2] - b'0') as u16; + path.push(u8::try_from(value).map_err(|_| cause("mount_check_unavailable"))?); + i += 4; + } else { + path.push(fields[4][i]); + i += 1; + } + } + let mount = PathBuf::from(std::ffi::OsString::from_vec(path)); + if !mount.is_absolute() { + return Err(cause("mount_check_unavailable")); + } + if mount.starts_with(&root) { + return Err(cause("mount_present")); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +const MAX_AUTHORITY_BYTES: usize = 128 * 1024 * 1024; + +#[cfg(target_os = "linux")] +fn bounded_authority_bytes(authority: &Authority, limit: usize) -> io::Result> { + let raw = + serde_json::to_vec(authority).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; + if raw.len() > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "removal content authority exceeds its readable size limit", + )); + } + Ok(raw) +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::ffi::{CString, OsStr}; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + + #[repr(C)] + struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, + } + + fn open(parent: &fs::File, name: &OsStr, flags: i32) -> io::Result { + let name = CString::new(name.as_bytes()) + .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + // NO_XDEV catches bind mounts too (st_dev comparisons do not). + // NO_SYMLINKS + O_PATH|O_NOFOLLOW allows reading a final symlink + // descriptor, but never following it to another directory. + let how = OpenHow { + // openat2 rejects O_NONBLOCK with O_PATH (unlike legacy open). + // Other opens remain nonblocking so raced-in FIFOs cannot hang. + flags: (flags + | libc::O_CLOEXEC + | libc::O_NOFOLLOW + | if flags & libc::O_PATH == 0 { + libc::O_NONBLOCK + } else { + 0 + }) as u64, + mode: if flags & libc::O_CREAT != 0 { 0o600 } else { 0 }, + resolve: 0x01 | 0x04 | 0x08, + }; // NO_XDEV | NO_SYMLINKS | BENEATH + // SAFETY: stable NUL-terminated name, valid descriptor and OpenHow ABI. + let fd = unsafe { + libc::syscall( + libc::SYS_openat2, + parent.as_raw_fd(), + name.as_ptr(), + &how, + std::mem::size_of::(), + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: syscall returned a newly owned descriptor. + Ok(unsafe { fs::File::from_raw_fd(fd as i32) }) + } + + pub(super) fn open_directory(parent: &fs::File, name: &OsStr) -> io::Result { + open(parent, name, libc::O_RDONLY | libc::O_DIRECTORY) + } + + fn authority_directory(parent: &fs::File, create: bool) -> io::Result { + if create { + // SAFETY: valid parent descriptor and fixed NUL-terminated name. + let result = + unsafe { libc::mkdirat(parent.as_raw_fd(), c".cleanup-authority".as_ptr(), 0o700) }; + if result < 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::AlreadyExists { + return Err(error); + } + } + } + let directory = open_directory(parent, OsStr::new(".cleanup-authority"))?; + let metadata = directory.metadata()?; + // SAFETY: geteuid has no arguments or memory access preconditions. + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o022 != 0 { + return Err(io::Error::from_raw_os_error(libc::EPERM)); + } + Ok(directory) + } + + pub(super) fn load_authority(parent: &fs::File, id: &str) -> io::Result> { + let read = || -> io::Result { + let directory = authority_directory(parent, false)?; + let file = open( + &directory, + OsStr::new(&format!("{id}.json")), + libc::O_RDONLY, + )?; + if !file.metadata()?.is_file() || file.metadata()?.len() > MAX_AUTHORITY_BYTES as u64 { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + let mut raw = Vec::new(); + file.take(MAX_AUTHORITY_BYTES as u64 + 1) + .read_to_end(&mut raw)?; + if raw.len() > MAX_AUTHORITY_BYTES { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + serde_json::from_slice(&raw).map_err(|_| io::Error::from(io::ErrorKind::InvalidData)) + }; + match read() { + Ok(authority) => Ok(Some(authority)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + + pub(super) fn persist_authority( + parent: &fs::File, + id: &str, + authority: &Authority, + ) -> io::Result<()> { + // Never publish a witness that the next normal retry cannot read. + let raw = bounded_authority_bytes(authority, MAX_AUTHORITY_BYTES)?; + let directory = authority_directory(parent, true)?; + let temporary = CString::new(format!(".{id}-{}.tmp", uuid::Uuid::now_v7())).unwrap(); + let target = CString::new(format!("{id}.json")).unwrap(); + let mut file = open( + &directory, + OsStr::from_bytes(temporary.as_bytes()), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, + )?; + file.write_all(&raw)?; + file.sync_all()?; + // SAFETY: both names are valid C strings relative to the same owned fd. + let result = unsafe { + libc::renameat( + directory.as_raw_fd(), + temporary.as_ptr(), + directory.as_raw_fd(), + target.as_ptr(), + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + directory.sync_all()?; + // Persist the newly created authority directory before any deletion. + parent.sync_all() + } + + pub(super) fn discard_authority(parent: &fs::File, id: &str) -> io::Result<()> { + let directory = authority_directory(parent, false)?; + unlink(&directory, OsStr::new(&format!("{id}.json")), false)?; + directory.sync_all() + } + + pub(super) fn identity(file: &fs::File) -> io::Result<(u64, u64)> { + let metadata = file.metadata()?; + Ok((metadata.dev(), metadata.ino())) + } + + fn names(dir: &fs::File) -> io::Result> { + // /proc gives a descriptor-anchored directory listing; all access to + // listed entries still goes through mount-constrained openat2. + let mut names = fs::read_dir(format!("/proc/self/fd/{}", dir.as_raw_fd()))? + .map(|e| e.map(|e| e.file_name())) + .collect::>>()?; + names.sort(); + Ok(names) + } + + pub(super) fn inspect(dir: &fs::File, name: &OsStr) -> io::Result { + let file = open(dir, name, libc::O_PATH)?; + let metadata = file.metadata()?; + let digest = if metadata.is_file() { + let mut reader = open(dir, name, libc::O_RDONLY)?; + if identity(&reader)? != (metadata.dev(), metadata.ino()) { + return Err(io::Error::from_raw_os_error(libc::EAGAIN)); + } + let mut hash = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + Some( + hash.finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ) + } else if metadata.file_type().is_symlink() { + let mut buffer = vec![0u8; 65536]; + // SAFETY: valid O_PATH symlink descriptor; empty path reads that + // symlink, not its target. The mutable buffer has the stated length. + let count = unsafe { + libc::readlinkat( + file.as_raw_fd(), + c"".as_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if count < 0 { + return Err(io::Error::last_os_error()); + } + buffer.truncate(count as usize); + Some( + Sha256::digest(&buffer) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ) + } else if metadata.is_dir() { + None + } else { + return Err(io::Error::from_raw_os_error(libc::EBUSY)); + }; + Ok(Entry { + identity: (metadata.dev(), metadata.ino()), + // Permission repair is not a content change. Preserve the file type + // and executable bits, which Git treats as user-visible changes. + mode: metadata.mode() & libc::S_IFMT + | if metadata.is_file() { + metadata.mode() & 0o111 + } else { + 0 + }, + digest, + }) + } + + pub(super) fn inventory(dir: &fs::File) -> io::Result> { + fn walk( + dir: &fs::File, + path: &Path, + entries: &mut BTreeMap, + ) -> io::Result<()> { + if path.components().count() > 256 || entries.len() > 1_000_000 { + return Err(io::Error::from_raw_os_error(libc::EFBIG)); + } + for name in names(dir)? { + let entry = inspect(dir, &name)?; + let is_dir = entry.mode & libc::S_IFMT == libc::S_IFDIR; + let path = path.join(&name); + entries.insert(path.clone(), entry); + if is_dir { + walk(&open_directory(dir, &name)?, &path, entries)?; + } + } + Ok(()) + } + let mut entries = BTreeMap::new(); + walk(dir, Path::new(""), &mut entries)?; + Ok(entries) + } + + pub(super) fn rewrite_record( + dir: &fs::File, + bytes: &[u8], + expected: Option<&Entry>, + ) -> io::Result<()> { + let expected = expected.ok_or_else(|| io::Error::from_raw_os_error(libc::EAGAIN))?; + if expected.mode & libc::S_IFMT != libc::S_IFREG { + return Err(io::Error::from_raw_os_error(libc::EAGAIN)); + } + let mut file = open(dir, OsStr::new(MATERIALIZATION_RECORD), libc::O_WRONLY)?; + if identity(&file)? != expected.identity { + return Err(io::Error::from_raw_os_error(libc::EAGAIN)); + } + file.set_len(0)?; + file.write_all(bytes)?; + file.sync_all() + } + + pub(super) fn unlink(dir: &fs::File, name: &OsStr, directory: bool) -> io::Result<()> { + let name = CString::new(name.as_bytes()) + .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + // SAFETY: valid descriptor and NUL-terminated single entry name. + let result = unsafe { + libc::unlinkat( + dir.as_raw_fd(), + name.as_ptr(), + if directory { libc::AT_REMOVEDIR } else { 0 }, + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + // Read-only kernel proof: use existing mounts, never mount/unmount anything. + // Linux /proc and openat2 are production prerequisites. Also exercise every + // accessible same-device mount when present (e.g. a read-only store bind). + #[test] + fn kernel_no_xdev_rejects_existing_cross_device_and_same_device_mounts() { + let root = fs::File::open("/").unwrap(); + let proc_error = open_directory(&root, OsStr::new("proc")).unwrap_err(); + assert_eq!(proc_error.raw_os_error(), Some(libc::EXDEV)); + let root_device = root.metadata().unwrap().dev(); + let mounts = fs::read_to_string("/proc/self/mountinfo").unwrap(); + let mut same_device = 0; + for line in mounts.lines() { + let mount = line.split(' ').nth(4).unwrap(); + if mount == "/" || mount.contains('\\') { + continue; + } + let Ok(metadata) = fs::metadata(mount) else { + continue; + }; + if metadata.is_dir() && metadata.dev() == root_device { + let error = + open_directory(&root, OsStr::new(mount.trim_start_matches('/'))).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(libc::EXDEV), "{mount}: {error}"); + same_device += 1; + } + } + eprintln!( + "NO_XDEV kernel: /proc rejected; {same_device} existing same-device mounts rejected" + ); + } + + pub(super) enum EraseError { + Io(io::Error), + Changed, + } + impl From for EraseError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } + } + + pub(super) fn erase( + dir: &fs::File, + path: &Path, + expected: &BTreeMap, + ) -> Result<(), EraseError> { + let mut children = names(dir)?; + // Keep identity evidence until the rest of the tree has been erased. + if path.as_os_str().is_empty() { + children.sort_by_key(|name| name == MATERIALIZATION_RECORD); + } + for name in children { + let child = path.join(&name); + let entry = inspect(dir, &name)?; + if expected.get(&child) != Some(&entry) { + return Err(EraseError::Changed); + } + let is_dir = entry.mode & libc::S_IFMT == libc::S_IFDIR; + if is_dir { + let directory = open_directory(dir, &name)?; + if identity(&directory)? != entry.identity { + return Err(EraseError::Changed); + } + erase(&directory, &child, expected)?; + if inspect(dir, &name)? != entry { + return Err(EraseError::Changed); + } + } + unlink(dir, &name, is_dir)?; + } + Ok(()) + } +} + +#[cfg(all(test, target_os = "linux"))] +pub(super) mod tests { + use super::super::tests::{create_clean_repo, request}; + use super::*; + + fn fixture() -> ( + tempfile::TempDir, + RuntimeGitMaterializer, + WorkingDirectoryBinding, + ) { + let repo = create_clean_repo(); + let runtime = tempfile::tempdir().unwrap(); + let materializer = RuntimeGitMaterializer::new(runtime.path()); + let binding = materializer.create(&request(repo.path())).unwrap(); + (runtime, materializer, binding) + } + + pub(crate) fn fail_once(materializer: &RuntimeGitMaterializer, id: &str) { + let error = remove_with_eraser(materializer, id, |_, _, _| { + Err(EraseError::Io(io::Error::from_raw_os_error(libc::EACCES))) + }) + .unwrap_err(); + assert_eq!(error.code, "working_directory_cleanup_permission_denied"); + } + + #[test] + fn persisted_removal_witness_must_fit_the_same_readable_limit() { + let (runtime, _, binding) = fixture(); + let authority = Authority { + working_directory: binding.working_directory.clone(), + root_identity: (0, 0), + entries: BTreeMap::new(), + }; + let bytes = bounded_authority_bytes(&authority, MAX_AUTHORITY_BYTES).unwrap(); + assert_eq!( + bounded_authority_bytes(&authority, bytes.len()).unwrap(), + bytes + ); + assert_eq!( + bounded_authority_bytes(&authority, bytes.len() - 1) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + assert!(!runtime.path().join(".cleanup-authority").exists()); + } + + #[test] + fn cleanup_pending_rechecks_current_cleanliness_and_retries_without_state_edit() { + let (_runtime, materializer, binding) = fixture(); + let id = &binding.working_directory.id; + fail_once(&materializer, id); + let status = materializer.working_directory_status(id).unwrap(); + assert_eq!( + status.summary.status, + WorkingDirectoryStatusKind::CleanupPending + ); + assert_eq!(status.summary.cleanliness.as_deref(), Some("clean")); + let removed = materializer.cleanup_working_directory(id).unwrap(); + assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound); + assert!(!binding.working_directory_root.exists()); + } + + #[test] + fn partial_deletion_missing_checkout_and_restart_finish_with_durable_content_authority() { + for removed in [ + "checkout/README.md", + "checkout/.git", + CHECKOUT_DIR, + MATERIALIZATION_RECORD, + ] { + let (runtime, materializer, binding) = fixture(); + let id = &binding.working_directory.id; + let error = remove_with_eraser(&materializer, id, |_, _, _| { + let path = binding.working_directory_root.join(removed); + if path.is_dir() { + fs::remove_dir_all(path).unwrap(); + } else { + fs::remove_file(path).unwrap(); + } + Err(EraseError::Io(io::Error::from_raw_os_error(libc::EBUSY))) + }) + .unwrap_err(); + assert_eq!(error.code, "working_directory_cleanup_resource_busy"); + drop(materializer); + let restarted = RuntimeGitMaterializer::new(runtime.path()); + let retained = restarted.working_directory_status(id).unwrap(); + assert_eq!( + retained.summary.repository_id, + binding.working_directory.repository_id + ); + assert_eq!( + retained.summary.cleanup_target.as_ref(), + Some(&binding.working_directory.cleanup_target) + ); + assert_eq!(retained.summary.cleanliness.as_deref(), Some("clean")); + assert_eq!( + restarted.list_working_directories().unwrap()[0].summary, + retained.summary + ); + let removed_status = restarted.cleanup_working_directory(id).unwrap(); + assert_eq!( + removed_status.summary.status, + WorkingDirectoryStatusKind::NotFound, + "{removed}" + ); + assert!(!binding.working_directory_root.exists()); + } + } + + #[test] + fn missing_checkout_with_only_validated_metadata_can_be_removed_without_witness() { + let (_runtime, materializer, binding) = fixture(); + fs::remove_dir_all(binding.root()).unwrap(); + let removed = materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap(); + assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound); + assert!(!binding.working_directory_root.exists()); + } + + #[test] + fn incomplete_checkout_without_witness_preserves_residue_and_explains_recovery() { + let (_runtime, materializer, binding) = fixture(); + fs::remove_dir_all(binding.root.join(".git")).unwrap(); + let error = materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap_err(); + assert_eq!(error.code, "working_directory_cleanup_changes_unknown"); + assert!(error.message.contains("Preserve residual content")); + assert!(binding.root.join("README.md").exists()); + } + + #[test] + fn failure_then_dirty_then_owner_resolution_renews_witness_on_ordinary_retry() { + use super::super::tests::git; + for resolution in ["commit", "restore"] { + let (runtime, materializer, binding) = fixture(); + let id = &binding.working_directory.id; + fail_once(&materializer, id); + fs::write(binding.root.join("README.md"), "preserved change").unwrap(); + assert_eq!( + materializer + .working_directory_status(id) + .unwrap() + .summary + .cleanliness + .as_deref(), + Some("dirty") + ); + assert_eq!( + materializer.cleanup_working_directory(id).unwrap_err().code, + "working_directory_cleanup_changes_present" + ); + let recovery = materializer.bind_working_directory(id, None).unwrap(); + assert_eq!( + recovery.working_directory.status, + WorkingDirectoryStatusKind::CleanupPending + ); + if resolution == "commit" { + git(binding.root(), &["add", "README.md"]); + git( + binding.root(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "Preserve recovery changes", + ], + ); + } else { + fs::copy( + binding.root.join("README.md"), + runtime.path().join("preserved.txt"), + ) + .unwrap(); + // Replaces the tracked file inode and Git index legitimately. + fs::remove_file(binding.root.join("README.md")).unwrap(); + git(binding.root(), &["restore", "README.md"]); + } + let restarted = RuntimeGitMaterializer::new(runtime.path()); + assert_eq!( + restarted + .working_directory_status(id) + .unwrap() + .summary + .cleanliness + .as_deref(), + Some("clean"), + "{resolution}" + ); + // Renewal itself can fail at unlink. Its new witness must survive a + // later partial deletion/restart, even with no remaining Git. + fail_once(&restarted, id); + fs::remove_dir_all(binding.root.join(".git")).unwrap(); + let restarted = RuntimeGitMaterializer::new(runtime.path()); + restarted.cleanup_working_directory(id).unwrap(); + assert!(!binding.working_directory_root.exists()); + } + } + + #[test] + fn clean_git_retry_preserves_new_or_modified_ignored_and_untracked_content() { + use super::super::tests::git; + for change in ["ignored_new", "ignored_modified", "untracked", "sibling"] { + let (_runtime, materializer, binding) = fixture(); + let id = &binding.working_directory.id; + fs::write(binding.root.join(".git/info/exclude"), "ignored/\n").unwrap(); + fs::create_dir(binding.root.join("ignored")).unwrap(); + fs::write(binding.root.join("ignored/existing"), "initial").unwrap(); + fail_once(&materializer, id); + let file = match change { + "ignored_new" => binding.root.join("ignored/new"), + "ignored_modified" => binding.root.join("ignored/existing"), + "untracked" => binding.root.join("new"), + _ => binding.working_directory_root.join("new"), + }; + fs::write(&file, "preserve me").unwrap(); + git( + binding.root(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "Clean Git metadata changed", + ], + ); + assert_eq!( + materializer.cleanup_working_directory(id).unwrap_err().code, + "working_directory_cleanup_changes_present", + "{change}" + ); + assert_eq!(fs::read_to_string(&file).unwrap(), "preserve me"); + fs::remove_file(&file).unwrap(); + // Removing a pre-existing ignored file is safe; no new content remains. + materializer.cleanup_working_directory(id).unwrap(); + } + } + + #[test] + fn pending_witness_accepts_access_refresh_but_rejects_materialization_substitution() { + for update in ["access", "repository", "creation"] { + let (_runtime, materializer, binding) = fixture(); + let id = &binding.working_directory.id; + fail_once(&materializer, id); + let mut record = materializer.read_binding(id).unwrap(); + match update { + "access" => { + // The same serialized fields changed by authorize_repository_access. + record.working_directory.evidence.operation_id = + Some("reattach-operation".into()); + record.working_directory.evidence.credential_revision = Some(2); + record.working_directory.evidence.host_trust_revision = Some(2); + } + "repository" => { + record.working_directory.repository_id = "substitute".into(); + record.working_directory.evidence.repository_id = "substitute".into(); + record.working_directory.cleanup_target.repository_id = "substitute".into(); + } + _ => record.working_directory.evidence.resolved_commit = "substitute".into(), + } + materializer.write_record(&record).unwrap(); + if update == "access" { + materializer + .preflight_bind_working_directory(id, None) + .unwrap(); + // Read-only preflight does not bypass actual SSH delivery requirements. + assert_eq!( + materializer + .bind_working_directory(id, None) + .unwrap_err() + .code, + "working_directory_remote_repository_access_required" + ); + materializer.cleanup_working_directory(id).unwrap(); + assert!(!binding.working_directory_root.exists()); + } else { + assert_eq!( + materializer + .preflight_bind_working_directory(id, None) + .unwrap_err() + .code, + "working_directory_cleanup_ownership_unknown" + ); + assert_eq!( + materializer.cleanup_working_directory(id).unwrap_err().code, + "working_directory_cleanup_ownership_unknown" + ); + assert!(binding.root.join("README.md").exists()); + } + } + } + + #[test] + fn cleanup_pending_binding_rejects_missing_git_and_replaced_checkout() { + for replacement in [false, true] { + let (_runtime, materializer, binding) = fixture(); + let id = &binding.working_directory.id; + fail_once(&materializer, id); + if replacement { + fs::rename( + binding.root(), + binding.working_directory_root.join("old-checkout"), + ) + .unwrap(); + fs::create_dir(binding.root()).unwrap(); + fs::create_dir(binding.root.join(".git")).unwrap(); + } else { + fs::remove_dir_all(binding.root.join(".git")).unwrap(); + } + assert!(materializer.bind_working_directory(id, None).is_err()); + assert!(binding.working_directory_root.exists()); + } + } + + #[test] + fn retry_protects_new_files_modified_content_and_replaced_directories() { + for change in ["new", "modified", "replacement"] { + let (runtime, materializer, binding) = fixture(); + let id = &binding.working_directory.id; + fail_once(&materializer, id); + match change { + "new" => fs::write(binding.root.join("new.txt"), b"preserve me").unwrap(), + "modified" => fs::write(binding.root.join("README.md"), b"preserve me").unwrap(), + _ => { + fs::rename( + &binding.working_directory_root, + runtime.path().join("original"), + ) + .unwrap(); + fs::create_dir(&binding.working_directory_root).unwrap(); + } + } + let restarted = RuntimeGitMaterializer::new(runtime.path()); + let error = restarted.cleanup_working_directory(id).unwrap_err(); + assert_eq!( + error.code, + if change == "replacement" { + "working_directory_cleanup_ownership_unknown" + } else { + "working_directory_cleanup_changes_present" + }, + "{change}" + ); + assert!(binding.working_directory_root.exists()); + } + } + + #[test] + fn cleanup_does_not_execute_repository_fsmonitor_hooks_under_occupancy_guard() { + use super::super::tests::git; + use std::os::unix::fs::PermissionsExt; + let (runtime, materializer, binding) = fixture(); + let hook = runtime.path().join("fsmonitor"); + let marker = runtime.path().join("hook-called"); + fs::write( + &hook, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), + ) + .unwrap(); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o700)).unwrap(); + git( + binding.root(), + &["config", "core.fsmonitor", hook.to_str().unwrap()], + ); + fail_once(&materializer, &binding.working_directory.id); + fs::write(binding.root.join("README.md"), "dirty").unwrap(); + assert_eq!( + materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap_err() + .code, + "working_directory_cleanup_changes_present" + ); + assert!(!marker.exists()); + } + + #[test] + fn dirty_initial_checkout_is_never_authorized_for_removal() { + let (runtime, materializer, binding) = fixture(); + fs::write(binding.root.join("README.md"), "preserve me").unwrap(); + let error = materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap_err(); + assert_eq!(error.code, "working_directory_cleanup_changes_present"); + assert_eq!( + fs::read_to_string(binding.root.join("README.md")).unwrap(), + "preserve me" + ); + assert!(!runtime.path().join(".cleanup-authority").exists()); + } + + #[test] + fn mounted_or_unknown_resources_are_preserved_then_normal_retry_succeeds() { + let (_runtime, materializer, mut binding) = fixture(); + // Legacy cleanup_pending is an input, not authority to discard content. + binding.working_directory.status = WorkingDirectoryStatusKind::CleanupPending; + materializer.write_record(&binding).unwrap(); + let mount = binding.root.join(".yoi/mount"); + fs::create_dir_all(&mount).unwrap(); + fs::write(mount.join("external.txt"), b"external resource").unwrap(); + // .yoi is ignored, exactly as in the reported mount residue. + fs::write(binding.root.join(".git/info/exclude"), ".yoi/\n").unwrap(); + let raw = format!( + "1 0 8:1 / / rw - ext4 /dev/root rw\n2 1 8:1 / {} rw - ext4 /dev/root rw\n", + mount.display() + ); + let error = remove_with_checks( + &materializer, + &binding.working_directory.id, + || Ok(raw.into_bytes()), + erase, + ) + .unwrap_err(); + assert_eq!(error.code, "working_directory_cleanup_mount_present"); + assert_eq!( + fs::read(mount.join("external.txt")).unwrap(), + b"external resource" + ); + assert!(!error.message.contains(&mount.to_string_lossy().to_string())); + // The mount has been safely released by its owner. There is no state edit. + let removed = materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap(); + assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound); + } + + #[test] + fn mountinfo_unavailable_or_malformed_never_reaches_deletion() { + let (_runtime, materializer, binding) = fixture(); + for raw in [ + Vec::new(), + b"invalid".to_vec(), + b"1 0 8:1 / /bad\\zzz rw - ext4 /dev/root rw\n".to_vec(), + ] { + let error = remove_with_checks( + &materializer, + &binding.working_directory.id, + || Ok(raw), + |_, _, _| panic!("must not unlink without a mount check"), + ) + .unwrap_err(); + assert_eq!( + error.code, + "working_directory_cleanup_mount_check_unavailable" + ); + } + let error = remove_with_checks( + &materializer, + &binding.working_directory.id, + || Err(io::Error::from_raw_os_error(libc::EACCES)), + erase, + ) + .unwrap_err(); + assert_eq!( + error.code, + "working_directory_cleanup_mount_check_unavailable" + ); + assert!(binding.root.exists()); + } + + #[test] + fn mountinfo_decodes_escaped_paths_and_uses_component_boundaries() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("work dir"); + fs::create_dir(&root).unwrap(); + let escaped = root.to_string_lossy().replace(' ', "\\040"); + let raw = format!("1 0 8:1 / {escaped}/mount rw - ext4 /dev/root rw\n"); + assert_eq!( + check_mounts(&root, raw.as_bytes()).unwrap_err().code, + "working_directory_cleanup_mount_present" + ); + let sibling = format!("1 0 8:1 / {escaped}-other/mount rw - ext4 /dev/root rw\n"); + check_mounts(&root, sibling.as_bytes()).unwrap(); + } + + #[test] + fn symlinks_are_unlinked_without_traversing_external_content() { + let (_runtime, materializer, binding) = fixture(); + let external = tempfile::tempdir().unwrap(); + fs::write(external.path().join("keep.txt"), b"keep").unwrap(); + fs::write(binding.root.join(".git/info/exclude"), "link\n").unwrap(); + std::os::unix::fs::symlink(external.path(), binding.root.join("link")).unwrap(); + materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap(); + assert_eq!(fs::read(external.path().join("keep.txt")).unwrap(), b"keep"); + } + + #[test] + fn record_identity_mismatch_and_symlink_roots_never_grant_removal_authority() { + let (runtime, materializer, mut binding) = fixture(); + binding + .working_directory + .cleanup_target + .working_directory_id = "other".to_string(); + materializer.write_record(&binding).unwrap(); + let error = materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap_err(); + assert_eq!(error.code, "working_directory_cleanup_ownership_unknown"); + fs::rename( + &binding.working_directory_root, + runtime.path().join("original"), + ) + .unwrap(); + std::os::unix::fs::symlink( + runtime.path().join("original"), + &binding.working_directory_root, + ) + .unwrap(); + assert!( + materializer + .cleanup_working_directory(&binding.working_directory.id) + .is_err() + ); + assert!(runtime.path().join("original/checkout/README.md").exists()); + } + + #[test] + fn permission_repair_without_content_changes_allows_normal_retry() { + use std::os::unix::fs::PermissionsExt; + let (_runtime, materializer, binding) = fixture(); + fail_once(&materializer, &binding.working_directory.id); + fs::set_permissions( + binding.root.join("README.md"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap(); + assert!(!binding.working_directory_root.exists()); + } + + #[test] + fn malformed_or_external_removal_authority_is_never_used_or_overwritten() { + for external in [false, true] { + let (runtime, materializer, binding) = fixture(); + let private = runtime.path().join(".cleanup-authority"); + let outside = tempfile::tempdir().unwrap(); + let file = outside + .path() + .join(format!("{}.json", binding.working_directory.id)); + fs::write(&file, "do not overwrite").unwrap(); + if external { + std::os::unix::fs::symlink(outside.path(), &private).unwrap(); + } else { + fs::create_dir(&private).unwrap(); + fs::write( + private.join(format!("{}.json", binding.working_directory.id)), + "not json", + ) + .unwrap(); + } + let error = materializer + .cleanup_working_directory(&binding.working_directory.id) + .unwrap_err(); + assert_eq!( + error.code, + if external { + "working_directory_cleanup_storage_unavailable" + } else { + "working_directory_cleanup_ownership_unknown" + } + ); + assert!(binding.root.exists()); + assert_eq!(fs::read_to_string(file).unwrap(), "do not overwrite"); + } + } + + #[test] + fn cleanup_os_error_is_logged_privately_with_workdir_and_action_correlation() { + #[derive(Clone)] + struct Capture(Arc>>); + impl io::Write for Capture { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let output = Arc::new(Mutex::new(Vec::new())); + let writer = Capture(output.clone()); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .without_time() + .with_writer(move || writer.clone()) + .finish(); + let public = tracing::subscriber::with_default(subscriber, || { + // Other parallel cleanup tests can register this callsite while no + // subscriber is installed on their thread. Refresh its interest for + // this thread-local capture; never install a process-global logger. + tracing::callsite::rebuild_interest_cache(); + os_failure( + "workdir-correlated", + "unlink_checkout", + io::Error::from_raw_os_error(libc::EACCES), + ) + }); + let log = String::from_utf8(output.lock().unwrap().clone()).unwrap(); + assert!( + log.contains("workdir-correlated") && log.contains("unlink_checkout"), + "{log}" + ); + assert!(log.contains("raw_os_error=Some(13)"), "{log}"); + assert!(!public.message.contains("os error")); + } + + #[test] + fn authoritative_absence_is_distinct_from_os_failure_and_unknown_changes() { + let runtime = tempfile::tempdir().unwrap(); + let materializer = RuntimeGitMaterializer::new(runtime.path()); + assert_eq!( + materializer + .cleanup_working_directory("absent") + .unwrap_err() + .code, + "working_directory_not_found" + ); + for (errno, code) in [ + (libc::EACCES, "permission_denied"), + (libc::EBUSY, "resource_busy"), + (libc::ENOSPC, "storage_unavailable"), + (libc::EXDEV, "mount_present"), + (libc::ENOSYS, "mount_check_unavailable"), + ] { + let error = os_failure( + "workdir-test", + "unlink", + io::Error::from_raw_os_error(errno), + ); + assert_eq!(error.code, format!("working_directory_cleanup_{code}")); + assert!(error.message.len() < 512); + assert!(!error.message.contains("/")); + } + } +} diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 28867c0ca..498996cf3 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -734,8 +734,14 @@ impl WorkspaceHttpWorkdirBackend { ))?; workdir_output( format!( - "Workdir {workdir_id} removal disposition: {:?}", - response.disposition + "Workdir {workdir_id} removal disposition: {:?}. {}{}", + response.disposition, + removal_guidance(response.failure_category.as_deref()), + if response.retryable { + " After resolving the cause, repeat this WorkdirDelete with the same id and reason; current safety conditions will be rechecked." + } else { + "" + } ), &response, ) @@ -920,6 +926,48 @@ fn decode_error(error: serde_json::Error) -> ToolError { ToolError::ExecutionFailed(format!("decode Workspace Workdir response: {error}")) } +fn removal_guidance(category: Option<&str>) -> &'static str { + match category { + None => "", + Some("mount_present") => { + "A mount remains inside the Workdir. Confirm its owner and usage, and have that owner safely release it. Do not recursively delete mounted data or implicitly unmount external or unknown resources." + } + Some("mount_check_unavailable") => "Restore Runtime mount inspection before deletion.", + Some("changes_present" | "dirty_or_unknown") => { + "Current changes are protected; preserve or resolve them before deletion." + } + Some("changes_unknown") => { + "Restore checkout inspection or preserve remaining content so current changes can be verified." + } + Some( + "ownership_unknown" + | "provider_identity_changed" + | "authority_changed" + | "authority_invalid", + ) => { + "Confirm target identity and ownership with its owner; never remove a replacement or unknown resource." + } + Some("blocked_by_live_authority") => { + "Release current attachments, reservations and holds through their owning authority." + } + Some("permission_denied" | "resource_busy" | "storage_unavailable") => { + "Have the resource owner resolve access, usage or storage safely; do not force-clean or change shared resource permissions." + } + Some("runtime_unavailable" | "provider_unavailable") => { + "Restore the Runtime connection; the registry remains until deletion is confirmed." + } + Some("provider_observation_unknown" | "provider_cleanup_outcome_unknown") => { + "Deletion is not confirmed. Re-observe with normal removal after restoring provider access; the registry is retained." + } + Some("unsupported_target") => { + "Ask the owner for the provider's supported cleanup operation." + } + Some(_) => { + "Inspect correlated Runtime cleanup diagnostics with the owning operator and resolve the current cause." + } + } +} + fn workdir_output(summary: String, value: &T) -> Result { Ok(ToolOutput { summary, @@ -1218,6 +1266,45 @@ mod tests { }) } + #[test] + fn ordinary_workdir_delete_reports_safe_cause_and_exact_retry_guidance() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ + "working_directory_id": "wd-retry", + "disposition": "attention_required", + "retryable": true, + "failure_category": "mount_present" + }))])); + let backend = WorkspaceHttpWorkdirBackend::new(client.clone()); + let output = backend + .delete(WorkdirDeleteInput { + working_directory_id: "wd-retry".into(), + reason: "remove unused Workdir".into(), + }) + .unwrap(); + assert!(output.summary.contains("Confirm its owner")); + assert!( + output + .summary + .contains("Do not recursively delete mounted data") + ); + assert!(output.summary.contains("same id and reason")); + assert!(output.summary.len() <= 600); + let content: serde_json::Value = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(content["failure_category"], "mount_present"); + assert_eq!(content["retryable"], true); + let requests = client.requests(); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete); + assert_eq!( + serde_json::from_str::(requests[0].body.as_deref().unwrap()) + .unwrap()["reason"], + "remove unused Workdir" + ); + let unknown = removal_guidance(Some("secret /private/provider/path")); + assert!(!unknown.contains("/private")); + assert!(!unknown.contains("secret")); + } + #[test] fn workspace_request_failure_is_not_reported_as_session_unavailable() { let error = workspace_workdir_error(WorkspaceClientError::Request( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2c54af1b1..9f1edc803 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -11933,10 +11933,6 @@ fn cleanup_workdir_cleanliness_from_runtime(value: Option<&str>) -> CleanupWorkd .unwrap_or(CleanupWorkdirCleanliness::Unknown) } -fn cleanup_workdir_cleanliness_is_clean(value: CleanupWorkdirCleanliness) -> bool { - matches!(value, CleanupWorkdirCleanliness::Clean) -} - #[derive(Debug, Deserialize)] struct LogQuery { limit: Option, @@ -17793,9 +17789,15 @@ async fn scoped_attach_current_worker_workdir( code: "working_directory_not_found".to_string(), message: format!("unknown Workdir `{workdir_id}`"), })?; - if workdir.materialization_status != "present" { + // A failed removal must not prevent its owner from attaching a still usable + // repository checkout to resolve current blockers. Runtime binding validates + // the actual checkout; durable pending-removal reservations still fence this + // mutation. Other sources and missing/corrupted materializations stay denied. + let repository_retry = workdir.materialization_status == "pending" + && matches!(workdir.source, WorkdirRegistrySource::Repository { .. }); + if workdir.materialization_status != "present" && !repository_retry { return Err(Error::WorkdirAttachmentConflict(format!( - "Workdir `{workdir_id}` is not active ({})", + "Workdir `{workdir_id}` is not available for attachment ({})", workdir.materialization_status )) .into()); @@ -24533,18 +24535,88 @@ fn runtime_reports_workdir_not_found(result: &crate::hosts::RuntimeWorkingDirect fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static str, bool) { match error { RuntimeRegistryError::UnknownRuntime(_) => ("runtime_unavailable", true), - RuntimeRegistryError::RuntimeOperationFailed { code, .. } - if code == "working_directory_unsupported" => - { - ("unsupported_target", false) + RuntimeRegistryError::RuntimeOperationFailed { code, .. } => { + let category = + workdir_cleanup_diagnostic_category(code).unwrap_or("provider_unavailable"); + (category, category != "unsupported_target") } - RuntimeRegistryError::RuntimeOperationFailed { .. } => ("provider_unavailable", true), RuntimeRegistryError::InvalidIdentifier { .. } | RuntimeRegistryError::UnknownHost(_) | RuntimeRegistryError::UnknownWorker { .. } => ("authority_invalid", false), } } +// Only bounded classifications cross the public removal boundary. Provider +// messages may contain OS paths or credentials and are never persisted here. +fn workdir_cleanup_failure_category( + result: &crate::hosts::RuntimeWorkingDirectoryResult, +) -> &'static str { + result + .diagnostics + .iter() + .find_map(|diagnostic| workdir_cleanup_diagnostic_category(&diagnostic.code)) + .unwrap_or("provider_cleanup_failed") +} + +fn workdir_cleanup_diagnostic_category(code: &str) -> Option<&'static str> { + Some(match code { + "working_directory_unsupported" => "unsupported_target", + "working_directory_cleanup_mount_present" => "mount_present", + "working_directory_cleanup_mount_check_unavailable" => "mount_check_unavailable", + "working_directory_cleanup_permission_denied" => "permission_denied", + "working_directory_cleanup_resource_busy" => "resource_busy", + "working_directory_cleanup_storage_unavailable" => "storage_unavailable", + "working_directory_cleanup_ownership_unknown" => "ownership_unknown", + "working_directory_cleanup_changes_present" => "changes_present", + "working_directory_cleanup_changes_unknown" => "changes_unknown", + "working_directory_cleanup_target_invalid" + | "working_directory_cleanup_escape_rejected" => "ownership_unknown", + _ => return None, + }) +} + +fn workdir_removal_failure_guidance(category: &str) -> &'static str { + match category { + "mount_present" => { + "A mount remains inside the Workdir. Confirm its owner and usage, have that owner safely release it, then retry deletion. Yoi does not unmount external resources." + } + "mount_check_unavailable" => { + "Runtime cannot inspect mount boundaries. Restore mount inspection before retrying deletion." + } + "permission_denied" => { + "Runtime lacks permission to remove the Workdir. Have its owner resolve access safely, then retry; do not force-clean or change shared resource permissions." + } + "resource_busy" => { + "A resource is busy. Have its owner stop or release it safely, then retry deletion." + } + "storage_unavailable" => { + "Runtime storage is unavailable. Restore storage access, then retry deletion." + } + "ownership_unknown" | "provider_identity_changed" | "authority_changed" => { + "Workdir identity or ownership could not be confirmed. Verify the target with its owner before retrying." + } + "changes_present" | "dirty_or_unknown" => { + "Current Workdir changes are protected. Preserve or resolve them before retrying deletion." + } + "changes_unknown" => { + "Runtime cannot verify remaining Workdir changes. Restore repository inspection or preserve remaining files before retrying." + } + "blocked_by_live_authority" => { + "Workdir has a current attachment, reservation or retention hold. Release the owning authority before retrying deletion." + } + "runtime_unavailable" | "provider_unavailable" => { + "Runtime is unavailable. Reconnect it, then retry deletion; the Workdir registry is retained." + } + "provider_observation_unknown" | "provider_cleanup_outcome_unknown" => { + "Runtime has not confirmed deletion. Retry to re-observe the target; the Workdir registry is retained." + } + "unsupported_target" => "This provider does not support Workdir deletion.", + _ => { + "Runtime cleanup failed. Inspect correlated Runtime diagnostics, resolve the cause, then retry deletion." + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WorkdirRemovalOwnerObservation { Running { process_start_marker: u64 }, @@ -24747,17 +24819,46 @@ fn execute_reserved_workdir_removal_with_provider( return api.config_store.commit_workdir_removal_removed(&operation); } let Some(status) = observed.working_directory.as_ref() else { + let category = workdir_cleanup_failure_category(&observed); + let category = if category == "provider_cleanup_failed" { + "provider_observation_unknown" + } else { + category + }; return api.config_store.fail_workdir_removal_operation( &operation, - "provider_observation_unknown", - true, + category, + category != "unsupported_target", ); }; - let corrupted = status.summary.status == WorkingDirectoryStatusKind::Corrupted; - if !corrupted - && (status.summary.cleanliness.as_deref() != Some("clean") - || status.summary.status != WorkingDirectoryStatusKind::Active) + // A provider summary is not permission to remove a different materialization. + // Unknown/corrupted metadata is delegated to Runtime's guarded cleanup, never + // repaired into a fabricated identity here. + let summary = &status.summary; + if summary.occupied_by.is_some() { + return api.config_store.complete_workdir_removal_retained( + &operation, + WorkdirRemovalDisposition::Retained, + "blocked_by_live_authority", + ); + } + if summary.working_directory_id != operation.working_directory_id + || (summary.repository_id == "unknown" + && summary.status != WorkingDirectoryStatusKind::Corrupted) + || (summary.repository_id != "unknown" + && crate::workdir_removal::workdir_materialization_fingerprint( + &workdir_record_from_summary(api, &operation.runtime_id, summary), + ) != operation.materialization_fingerprint) { + return api.config_store.fail_workdir_removal_operation( + &operation, + "provider_identity_changed", + true, + ); + } + // Past lifecycle failure is not a current dirtiness observation. Runtime + // must revalidate unknown/partial checkouts at the actual deletion boundary. + if summary.cleanliness.as_deref() == Some("dirty") { return api.config_store.complete_workdir_removal_retained( &operation, WorkdirRemovalDisposition::Retained, @@ -24775,18 +24876,25 @@ fn execute_reserved_workdir_removal_with_provider( .fail_workdir_removal_operation(&operation, category, retryable); } }; - if deleted.state != InternalWorkerOperationState::Accepted - && !runtime_reports_workdir_not_found(&deleted) - { - let category = if deleted - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "working_directory_unsupported") - { - "unsupported_target" + let deletion_confirmed = runtime_reports_workdir_not_found(&deleted) + || (deleted.state == InternalWorkerOperationState::Accepted + && deleted.working_directory.as_ref().is_some_and(|status| { + status.summary.working_directory_id == operation.working_directory_id + && status.summary.status == WorkingDirectoryStatusKind::NotFound + })); + if !deletion_confirmed { + let category = if deleted.state == InternalWorkerOperationState::Accepted { + "provider_cleanup_outcome_unknown" } else { - "provider_cleanup_failed" + workdir_cleanup_failure_category(&deleted) }; + tracing::warn!( + workdir_id = %operation.working_directory_id, + operation_id = %operation.operation_id, + runtime_id = %operation.runtime_id, + category, + "Runtime did not confirm Workdir removal; registry retained" + ); return api.config_store.fail_workdir_removal_operation( &operation, category, @@ -25072,9 +25180,7 @@ fn build_runtime_cleanup_plan( .unwrap_or_else(|| cleanup_workdir_cleanliness_from_registry(&record.cleanliness)); let action = if cleanup_workdir_file_status_is_record_only(file_status) { CleanupTargetKind::WorkdirRecordDelete - } else if cleanup_workdir_file_status_is_corrupted(file_status) - || cleanup_workdir_cleanliness_is_clean(cleanliness) - { + } else if !matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) { CleanupTargetKind::WorkdirCleanCleanup } else { CleanupTargetKind::WorkdirDirtyDiscard @@ -25102,12 +25208,12 @@ fn build_runtime_cleanup_plan( } else if cleanup_workdir_file_status_is_record_only(file_status) { "Not-found Workdir record can be deleted from the Backend registry".to_string() } else if cleanup_workdir_file_status_is_corrupted(file_status) { - "Corrupted Workdir can be deleted from Runtime storage and Backend registry" + "Runtime will verify remaining files and removal authority for this incomplete Workdir" .to_string() } else if matches!(cleanliness, CleanupWorkdirCleanliness::Dirty) { - "Dirty Workdir requires explicit discard confirmation before cleanup".to_string() + "Dirty Workdir is protected; preserve or resolve current changes before retrying".to_string() } else if matches!(cleanliness, CleanupWorkdirCleanliness::Unknown) { - "Workdir clean state is unknown; explicit discard confirmation is required" + "Runtime will recheck current changes, identity and removal blockers before deleting" .to_string() } else { "Clean Workdir can be manually cleaned up".to_string() @@ -25309,7 +25415,12 @@ async fn execute_runtime_cleanup_with_context( target_id: candidate.target_id.clone(), action: candidate.action.clone(), status: status.to_string(), - message: message.to_string(), + message: removal.failure_category.as_deref().map_or_else( + || message.to_string(), + |category| { + format!("{category}: {}", workdir_removal_failure_guidance(category)) + }, + ), }); } CleanupTargetKind::WorkerDelete => { @@ -32794,6 +32905,7 @@ mod tests { mod auth_logging_tests; mod drive_api_tests; mod drive_tests; + mod drive_web_roundtrip_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; mod ticket_evidence_tests; @@ -53055,7 +53167,7 @@ mod tests { }; let source_fingerprint = repository_source_fingerprint(&repository_source); for (workdir_id, _, _) in &fixtures { - runtime + let created = runtime .create_working_directory(WorkingDirectoryRequest { repository: WorkingDirectoryRepository { id: "repo-test".to_string(), @@ -53078,6 +53190,13 @@ mod tests { "present", "clean", ); + api.store + .upsert_workdir_registry(&workdir_record_from_summary( + &api, + provider_runtime_id, + &created.summary, + )) + .unwrap(); } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let provider_base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -53627,6 +53746,91 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + #[tokio::test] + async fn current_worker_can_reattach_usable_pending_checkout_after_failed_removal_but_not_during_attempt() + { + let mut fixture = manual_worker_assignment_fixture().await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) + .unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let api = &fixture.api; + api.store + .detach_worker_workdir( + TEST_WORKSPACE_ID, + &fixture.worker, + Some(&fixture.docs_workdir_id), + "release for removal", + ) + .unwrap(); + let mut record = api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id) + .unwrap() + .unwrap(); + record.materialization_status = "pending".into(); + api.store.upsert_workdir_registry(&record).unwrap(); + let intent = + workdir_removal_intent(&record, "account:owner", "remove docs checkout").unwrap(); + let pending = api + .config_store + .reserve_workdir_removal_operation(&intent) + .unwrap(); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-attachments"); + let request = || { + runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + "POST", + &path, + serde_json::to_vec( + &json!({ "alias": "docs", "working_directory_id": fixture.docs_workdir_id }), + ) + .unwrap(), + ) + }; + let blocked = build_router(api.clone()).oneshot(request()).await.unwrap(); + let blocked_status = blocked.status(); + let blocked_body = to_bytes(blocked.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + blocked_status, + StatusCode::CONFLICT, + "{}", + String::from_utf8_lossy(&blocked_body) + ); + api.config_store + .fail_workdir_removal_operation(&pending, "provider_unavailable", true) + .unwrap(); + let attached = build_router(api.clone()).oneshot(request()).await.unwrap(); + let attached_status = attached.status(); + let attached_body = to_bytes(attached.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + attached_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&attached_body) + ); + let attached: server_api::CurrentWorkerWorkdirAttachmentResponse = + serde_json::from_slice(&attached_body).unwrap(); + assert!(attached.attached); + assert_eq!(attached.working_directory_id, fixture.docs_workdir_id); + assert_eq!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id) + .unwrap() + .unwrap() + .materialization_status, + "pending" + ); + assert!( + api.config_store + .workdir_removal_guards(&pending) + .unwrap() + .iter() + .any(|guard| guard.category == "active_attachment") + ); + } + #[tokio::test] async fn current_worker_attachment_list_and_stale_detach_use_signed_identity() { let mut fixture = manual_worker_assignment_fixture().await; @@ -54308,6 +54512,18 @@ mod tests { } } + fn confirmed_workdir_cleanup_result() -> crate::hosts::RuntimeWorkingDirectoryResult { + workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![RuntimeDiagnostic { + code: "working_directory_not_found".into(), + severity: HostDiagnosticSeverity::Warning, + message: "authoritatively absent".into(), + }], + ) + } + fn reserve_removal_fixture( api: &WorkspaceApi, working_directory_id: &str, @@ -54348,7 +54564,7 @@ mod tests { Some(clean_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let removed = execute_reserved_workdir_removal_with_provider( &api, @@ -54414,7 +54630,7 @@ mod tests { message: "timeout".to_string(), }], ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let unknown = execute_reserved_workdir_removal_with_provider( &api, @@ -54436,7 +54652,7 @@ mod tests { Some(dirty_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let dirty = execute_reserved_workdir_removal_with_provider( &api, @@ -54454,7 +54670,7 @@ mod tests { Some(clean_retry_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let removed_after_retry = execute_reserved_workdir_removal_with_provider( &api, @@ -54480,7 +54696,7 @@ mod tests { Some(corrupted_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ); let corrupted = execute_reserved_workdir_removal_with_provider( &api, @@ -54529,6 +54745,297 @@ mod tests { assert_eq!(unsupported_provider.cleanup_calls(), 1); } + #[test] + fn workdir_cleanup_failure_classification_is_bounded_and_guides_normal_retry() { + for (code, category) in [ + ("working_directory_cleanup_mount_present", "mount_present"), + ( + "working_directory_cleanup_mount_check_unavailable", + "mount_check_unavailable", + ), + ( + "working_directory_cleanup_permission_denied", + "permission_denied", + ), + ("working_directory_cleanup_resource_busy", "resource_busy"), + ( + "working_directory_cleanup_storage_unavailable", + "storage_unavailable", + ), + ( + "working_directory_cleanup_ownership_unknown", + "ownership_unknown", + ), + ( + "working_directory_cleanup_changes_present", + "changes_present", + ), + ( + "working_directory_cleanup_changes_unknown", + "changes_unknown", + ), + ( + "arbitrary provider text /private/path", + "provider_cleanup_failed", + ), + ] { + let result = workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![RuntimeDiagnostic { + code: code.into(), + severity: HostDiagnosticSeverity::Error, + message: "secret /private/path".into(), + }], + ); + assert_eq!(workdir_cleanup_failure_category(&result), category); + let guidance = workdir_removal_failure_guidance(category); + assert!(!guidance.contains("/private/path")); + assert!(!guidance.contains("secret")); + assert!(guidance.len() <= 500); + assert!(guidance.contains("retry")); + } + } + + #[tokio::test] + async fn workdir_removal_retry_rechecks_pending_materialization_changes_and_attachments() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let (operation, mut summary) = reserve_removal_fixture(&api, "cleanup-retry"); + let failed_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary.clone()), + Vec::new(), + ), + workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![RuntimeDiagnostic { + code: "working_directory_cleanup_mount_present".into(), + severity: HostDiagnosticSeverity::Error, + message: "/private/mount credentials must not escape".into(), + }], + ), + ); + let failed = execute_reserved_workdir_removal_with_provider( + &api, + operation, + false, + &failed_provider, + ) + .unwrap(); + assert!(failed.retryable); + assert_eq!(failed.failure_category.as_deref(), Some("mount_present")); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, "cleanup-retry") + .unwrap() + .is_some() + ); + assert!(!serde_json::to_string(&failed).unwrap().contains("/private")); + + summary.status = WorkingDirectoryStatusKind::CleanupPending; + summary.cleanliness = Some("dirty".into()); + let dirty_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary.clone()), + Vec::new(), + ), + confirmed_workdir_cleanup_result(), + ); + let dirty = + execute_reserved_workdir_removal_with_provider(&api, failed, false, &dirty_provider) + .unwrap(); + assert_eq!(dirty.disposition, Some(WorkdirRemovalDisposition::Retained)); + assert!(dirty.retryable); + assert_eq!(dirty_provider.cleanup_calls(), 0); + + let worker = seed_cleanup_worker(&api, 731, "normal"); + seed_cleanup_link(&api, &worker, "cleanup-retry"); + let occupied_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary.clone()), + Vec::new(), + ), + confirmed_workdir_cleanup_result(), + ); + let occupied = + execute_reserved_workdir_removal_with_provider(&api, dirty, false, &occupied_provider) + .unwrap(); + assert_eq!( + occupied.failure_category.as_deref(), + Some("blocked_by_live_authority") + ); + assert!(occupied.retryable); + assert_eq!(occupied_provider.cleanup_calls(), 0); + api.store + .detach_worker_workdir( + TEST_WORKSPACE_ID, + &RuntimeWorkerRef::new("runtime-test", worker), + Some("cleanup-retry"), + "resolve retry attachment", + ) + .unwrap(); + + // Unknown because checkout was partially removed is delegated to Runtime + // for current change/identity protection, not denied by the old state. + summary.cleanliness = Some("unknown".into()); + let retry_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary), + Vec::new(), + ), + confirmed_workdir_cleanup_result(), + ); + let removed = execute_reserved_workdir_removal_with_provider( + &api, + occupied.clone(), + false, + &retry_provider, + ) + .unwrap(); + assert_eq!(removed.operation_id, occupied.operation_id); + assert_eq!(removed.attempt_count, 4); + assert_eq!( + removed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert_eq!(retry_provider.cleanup_calls(), 1); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, "cleanup-retry") + .unwrap() + .is_none() + ); + assert_eq!( + execute_reserved_workdir_removal_with_provider( + &api, + removed.clone(), + false, + &retry_provider + ) + .unwrap(), + removed + ); + } + + #[tokio::test] + async fn workdir_observation_failure_preserves_bounded_os_cause_without_removing_registry() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + for (id, transport_error) in [ + ("observation-denied", false), + ("rpc-observation-denied", true), + ] { + let (operation, _) = reserve_removal_fixture(&api, id); + let provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Rejected, + None, + vec![RuntimeDiagnostic { + code: "working_directory_cleanup_permission_denied".into(), + severity: HostDiagnosticSeverity::Error, + message: "permission denied /private/path".into(), + }], + ), + confirmed_workdir_cleanup_result(), + ); + if transport_error { + *provider.observation.lock().unwrap() = + Some(Err(RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: "runtime-test".into(), + code: "working_directory_cleanup_permission_denied".into(), + message: "permission denied /private/path".into(), + })); + } + let result = + execute_reserved_workdir_removal_with_provider(&api, operation, false, &provider) + .unwrap(); + assert_eq!( + result.failure_category.as_deref(), + Some("permission_denied") + ); + assert!(result.retryable); + assert_eq!(provider.cleanup_calls(), 0); + assert!( + !serde_json::to_string(&result) + .unwrap() + .contains("/private/path") + ); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, id) + .unwrap() + .is_some() + ); + } + } + + #[tokio::test] + async fn workdir_removal_keeps_registry_for_changed_identity_and_unknown_cleanup_outcome() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + for (id, changed_identity) in [("identity-mismatch", true), ("unknown-cleanup", false)] { + let (operation, mut summary) = reserve_removal_fixture(&api, id); + if changed_identity { + summary.repository_id = "another-repository".into(); + } + let provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + InternalWorkerOperationState::Accepted, + Some(summary), + Vec::new(), + ), + workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + ); + let result = + execute_reserved_workdir_removal_with_provider(&api, operation, false, &provider) + .unwrap(); + assert!(result.retryable); + assert_eq!( + result.failure_category.as_deref(), + Some(if changed_identity { + "provider_identity_changed" + } else { + "provider_cleanup_outcome_unknown" + }) + ); + assert_eq!(provider.cleanup_calls(), usize::from(!changed_identity)); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, id) + .unwrap() + .is_some() + ); + } + } + + #[tokio::test] + async fn cleanup_plan_offers_normal_revalidation_for_prior_failure_and_unknown() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + for (id, state) in [ + ("pending-retry", "pending"), + ("unknown-retry", "unknown"), + ("corrupt-retry", "corrupted"), + ] { + seed_cleanup_workdir(&api, id, state, "unknown"); + } + let plan = build_runtime_cleanup_plan(&api, "runtime-test").unwrap(); + for candidate in plan.workdirs { + assert_eq!(candidate.action, CleanupTargetKind::WorkdirCleanCleanup); + assert!(candidate.blocking_reason.is_none()); + } + } + #[tokio::test] async fn concurrent_orphan_recovery_runs_delayed_provider_cleanup_once() { let workspace = tempfile::tempdir().unwrap(); @@ -54554,7 +55061,7 @@ mod tests { Some(clean_summary), Vec::new(), ), - workdir_removal_result(InternalWorkerOperationState::Accepted, None, Vec::new()), + confirmed_workdir_cleanup_result(), ) .with_observation_delay(std::time::Duration::from_millis(100)), ); diff --git a/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs b/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs new file mode 100644 index 000000000..2a97dc16c --- /dev/null +++ b/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs @@ -0,0 +1,442 @@ +//! T-724's real API/SQLite/FeatureStorage/LocalFileSystem boundary, separate +//! from browser fixtures. The Deno child imports the production Web adapter. +use super::*; +use server_api::*; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; + +struct LoopbackServer { + origin: String, + task: tokio::task::JoinHandle<()>, +} +impl LoopbackServer { + async fn start(router: Router) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + Self { origin, task } + } +} +impl Drop for LoopbackServer { + fn drop(&mut self) { + // No detached listener survives success, panic, or child timeout. + self.task.abort(); + } +} + +struct RealDriveFixture { + // TempDir outlives the API and listener; no user's DB/data directory is used. + server: LoopbackServer, + api: WorkspaceApi, + owner: String, + second: String, + _temp: tempfile::TempDir, +} +impl RealDriveFixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let mut config = test_server_config(temp.path()); + config.repositories.clear(); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let owner = seed_test_api_token(store.as_ref(), "drive-web-owner"); + let second = seed_test_api_token(store.as_ref(), "drive-web-second"); + let server_api = WorkspaceServerApi::new(config, store.clone()); + let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await; + // Exercise T-717's real repositoryless Workspace bootstrap, not a + // pre-existing test Workspace with its repository merely hidden in UI. + let response = reqwest::Client::new() + .post(format!("{}/api/workspaces", server.origin)) + .bearer_auth(&owner) + .json(&json!({"operation_key":"drive-web-bootstrap","display_name":"Drive only"})) + .send() + .await + .unwrap(); + let created = http_json(response, StatusCode::CREATED).await; + assert!(created["repository"].is_null(), "{created}"); + let workspace_id = created["workspace"]["workspace_id"].as_str().unwrap(); + assert!(store.list_repositories(workspace_id).unwrap().is_empty()); + let api = server_api + .api_for_workspace(workspace_id) + .await + .unwrap() + .unwrap(); + assert!(api.config.repositories.is_empty()); + // Cache the same production scoped router without starting unrelated + // orchestrator subscription tasks: this fixture tests Drive HTTP, not + // Runtime/Tools/WIP scheduling. The server's ingress/auth still runs. + server_api + .routers + .lock() + .await + .insert(workspace_id.to_owned(), build_inner_router(api.clone())); + Self { + server, + api, + owner, + second, + _temp: temp, + } + } + + fn base(&self) -> String { + format!("/api/w/{}/drive", self.api.workspace_id()) + } + + async fn user( + &self, + method: reqwest::Method, + path: &str, + body: Option, + expected: StatusCode, + ) -> Value { + let mut request = reqwest::Client::new() + .request(method, format!("{}{path}", self.server.origin)) + .bearer_auth(&self.owner); + if let Some(body) = body { + request = request.json(&body); + } + http_json(request.send().await.unwrap(), expected).await + } + + async fn seed_workers(&self) -> (RuntimeIdentityMaterial, Vec) { + let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID; + let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap(); + let binding = WorkspaceRuntimeBinding { + workspace_id: self.api.workspace_id().into(), + runtime_id: runtime_id.into(), + display_name: "Drive fixture runtime".into(), + base_url: "https://runtime.example.invalid".into(), + public_key: identity.public_key.clone(), + public_key_fingerprint: String::new(), + binding_revision: 1, + state: StoredRuntimeBindingState::Verified, + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + workspace_key_id: None, + workspace_key_generation: None, + created_at: "1".into(), + updated_at: "1".into(), + revoked_at: None, + }; + self.api + .store + .upsert_workspace_runtime_binding_record(binding, false) + .await + .unwrap(); + // Keep the production runtime trust gate: its expected snapshot must + // match the just-seeded normalized durable binding, not bypass it. + let current_binding = self + .api + .store + .get_workspace_runtime_binding(self.api.workspace_id(), runtime_id) + .await + .unwrap() + .unwrap(); + self.api + .runtime_binding_expectations + .write() + .unwrap() + .insert( + (self.api.workspace_id().to_owned(), runtime_id.to_owned()), + current_binding, + ); + // Only the runtime process boundary is a deterministic fake. Workspace, + // workers, grants, receipts, Drive nodes and content are real authorities. + let runtime = WorkdirlessFixtureRuntime::default(); + self.api.runtime.register_or_replace(runtime.clone()); + let mut workers = vec![]; + for (name, access) in [ + ("Drive reader", "read_only"), + ("Drive writer", "read_write"), + ] { + let Json(created) = create_workspace_worker( + State(self.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: runtime_id.into(), + display_name: name.into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); + // The reusable fixture defaults to TEST_WORKSPACE_ID. Its runtime + // observation must identify this genuinely bootstrapped Workspace. + { + let mut observed = runtime.workers.lock().unwrap(); + let summary = observed + .iter_mut() + .find(|summary| summary.worker == worker) + .unwrap(); + summary.workspace.identity = self.api.workspace_id().to_owned(); + summary.workspace.workspace_id = Some(self.api.workspace_id().to_owned()); + } + assert!( + self.api + .store + .list_worker_workdir_links(self.api.workspace_id(), &worker) + .unwrap() + .is_empty() + ); + self.user(reqwest::Method::POST, &format!("{}/grants", self.base()), + Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access})), StatusCode::OK).await; + workers.push(worker); + } + (identity, workers) + } + + async fn worker( + &self, + identity: &RuntimeIdentityMaterial, + worker: &RuntimeWorkerRef, + method: &str, + path: &str, + body: Option, + expected: StatusCode, + ) -> Value { + let bytes = body + .map(|v| serde_json::to_vec(&v).unwrap()) + .unwrap_or_default(); + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(identity) + .issue( + self.api.config.backend_base_url.as_deref().unwrap(), + self.api.workspace_id(), + Some(&worker.worker_id), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + method, + path, + &bytes, + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(), + 30, + ) + .unwrap(); + let response = reqwest::Client::new() + .request( + reqwest::Method::from_bytes(method.as_bytes()).unwrap(), + format!("{}{path}", self.server.origin), + ) + .bearer_auth(&self.owner) + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .header(CONTENT_TYPE, "application/json") + .body(bytes) + .send() + .await + .unwrap(); + http_json(response, expected).await + } + + async fn worker_artifact(&self) -> Value { + let (identity, workers) = self.seed_workers().await; + let root = self + .user( + reqwest::Method::GET, + &format!("{}/root", self.base()), + None, + StatusCode::OK, + ) + .await; + let mutation = json!({"request_id":"worker-artifact","mutation":{"operation":"create_text","parent":root["entry"],"name":"worker.md","text":"# Worker artifact\n","content_type":"text/markdown"}}); + let denied = self + .worker( + &identity, + &workers[0], + "POST", + &format!("{}/mutate", self.base()), + Some(mutation.clone()), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(denied["code"], "denied"); + let saved = self + .worker( + &identity, + &workers[1], + "POST", + &format!("{}/mutate", self.base()), + Some(mutation), + StatusCode::OK, + ) + .await; + let read_path = format!( + "{}/read-text?entry_workspace_id={}&id={}", + self.base(), + self.api.workspace_id(), + saved["entry"]["entry"]["node_id"].as_str().unwrap() + ); + let read = self + .worker( + &identity, + &workers[0], + "GET", + &read_path, + None, + StatusCode::OK, + ) + .await; + assert_eq!(read["text"], "# Worker artifact\n"); + // Revocation is observed by the next signed HTTP request, including one + // carrying the owner's bearer token (runtime identity has precedence). + let grants = self + .user( + reqwest::Method::GET, + &format!("{}/grants", self.base()), + None, + StatusCode::OK, + ) + .await; + let grant = grants["grants"] + .as_array() + .unwrap() + .iter() + .find(|grant| grant["worker_id"] == workers[0].worker_id) + .unwrap(); + self.user( + reqwest::Method::DELETE, + &format!( + "{}/grants/{}", + self.base(), + grant["grant_id"].as_str().unwrap() + ), + None, + StatusCode::OK, + ) + .await; + let revoked = self + .worker( + &identity, + &workers[0], + "GET", + &read_path, + None, + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(revoked["code"], "denied"); + let web = self + .user(reqwest::Method::GET, &read_path, None, StatusCode::OK) + .await; + assert_eq!(web["text"], "# Worker artifact\n"); + saved["entry"].clone() + } +} + +async fn http_json(response: reqwest::Response, expected: StatusCode) -> Value { + let status = response.status(); + let bytes = response.bytes().await.unwrap(); + let value: Value = + serde_json::from_slice(&bytes).unwrap_or_else(|_| json!(String::from_utf8_lossy(&bytes))); + assert_eq!(status.as_u16(), expected.as_u16(), "{value}"); + value +} + +#[tokio::test] +async fn repositoryless_drive_shares_worker_bytes_and_enforces_current_read_only_grants_over_http() +{ + let fixture = RealDriveFixture::new().await; + let artifact = fixture.worker_artifact().await; + let root = fixture + .user( + reqwest::Method::GET, + &format!("{}/root", fixture.base()), + None, + StatusCode::OK, + ) + .await; + let page = fixture + .user( + reqwest::Method::GET, + &format!( + "{}/list?entry_workspace_id={}&id={}", + fixture.base(), + fixture.api.workspace_id(), + root["entry"]["node_id"].as_str().unwrap() + ), + None, + StatusCode::OK, + ) + .await; + assert_eq!(page["entries"], json!([artifact])); + // Real LocalFileSystem persisted the Worker bytes; no mock object store. + let blob_root = fixture.api.config.drive_blob_root().unwrap(); + let blobs: Vec<_> = fs::read_dir(blob_root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(blobs.len(), 1); + assert_eq!(fs::read(&blobs[0]).unwrap(), b"# Worker artifact\n"); +} + +#[tokio::test] +#[ignore = "explicit API/Web process roundtrip; requires Deno on PATH (see web/workspace/test/drive-real-api/README.md)"] +async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle() +{ + let fixture = RealDriveFixture::new().await; + let worker_entry = fixture.worker_artifact().await; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let web = root.join("web/workspace"); + let mut child = tokio::process::Command::new("deno") + .current_dir(&web) + .args([ + "run", + "--cached-only", + "--config", + "test/drive-real-api/deno.json", + ]) + .arg(format!( + "--allow-net={}", + fixture.server.origin.trim_start_matches("http://") + )) + .arg("test/drive-real-api/roundtrip.ts") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("Deno must be installed for this explicit process test"); + let wire = serde_json::to_vec(&json!({ + "origin":fixture.server.origin,"workspace_id":fixture.api.workspace_id(), + "owner_token":fixture.owner,"second_token":fixture.second,"worker_entry":worker_entry, + })) + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(&wire).await.unwrap(); + drop(stdin); + // Timeout is only a hang bound; listener readiness is the successful bind + // and child synchronization is completion, never sleeps or polling loops. + let output = tokio::time::timeout(std::time::Duration::from_secs(60), child.wait_with_output()) + .await + .expect("Deno real API roundtrip exceeded 60 seconds") + .unwrap(); + // Defensive redaction: test failure diagnostics must never print credentials. + let redact = |bytes: &[u8]| { + String::from_utf8_lossy(bytes) + .replace(&fixture.owner, "[redacted]") + .replace(&fixture.second, "[redacted]") + }; + assert!( + output.status.success(), + "Deno real API roundtrip failed:\n{}\n{}", + redact(&output.stdout), + redact(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("real Web adapter roundtrip passed")); + assert!( + fixture + .api + .store + .list_repositories(fixture.api.workspace_id()) + .unwrap() + .is_empty() + ); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 9d78665a7..2388611c3 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -10254,6 +10254,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.workdir_id, active.worker.runtime_id, active.worker.worker_id ))); } + // Direct attachments (including ordinary recovery/reconnection) + // must share the same atomic removal fence as reserved attachments. + let removal_pending: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workdir_removal_operations WHERE workspace_id=?1 AND workdir_id=?2 AND state='pending')", + params![record.workspace_id, record.workdir_id], + |row| row.get(0), + )?; + if removal_pending { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir {} has a pending durable removal operation", + record.workdir_id + ))); + } let write = tx.execute( r#"INSERT INTO worker_workdir_links ( workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 0208714fe..9356ebdf0 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -438,7 +438,9 @@ impl SqliteWorkspaceStore { "retained completion cannot use removed disposition".to_string(), )); } - self.finish_workdir_removal_operation(operation, disposition, false, Some(category), false) + // Retention describes this attempt's current blockers, not a permanent + // ban. A subsequent normal request rechecks all live authority. + self.finish_workdir_removal_operation(operation, disposition, true, Some(category), false) } pub fn fail_workdir_removal_operation( diff --git a/docs/design/workspace-drive-web.md b/docs/design/workspace-drive-web.md new file mode 100644 index 000000000..4be980a0e --- /dev/null +++ b/docs/design/workspace-drive-web.md @@ -0,0 +1,37 @@ +# Workspace Web Drive + +The Web Drive is a latest-only shared Workspace resource, independent of Git, repositories, Worker sessions and Workdir occupancy. The Server's DB node tree, revision, receipt and current authorization are authoritative. The UI never uses physical blob keys/paths or stores a second persistent hierarchy. + +## References and authorization + +`/w//drive/` is a human-facing stable latest URL. Its node ID and revisions remain canonical positive decimal **strings** (including values above JavaScript's safe-integer range). Rename/move retain the URL; deleting and recreating a name does not revive the deleted ID. Copy latest URL emits this Web route, not the API's safe binary download URL. Ticket/Objective/comment Markdown can link to this route. Possessing any URL/ID grants no access: metadata, content, mutations, download and receipt queries all use the current authenticated API. + +Ordinary Drive usage retains the existing member-facing API policy, not owner-only settings policy. Worker grants are owner-controlled on the existing Workers page, using its current authoritative Worker catalog. Read-only/read-write are explicit Backend grants, not consequences of enabling a Profile tool. The UI does not invent a Web read-only ACL: Backend denial is displayed and suppresses mutations. Worker read-only denial and revoke are separately exercised against the real API. + +## Drafts, revision and delayed operations + +The optional node route reuses one controller across Workspace/entry switches. Drafts and mutation receipts are keyed by Workspace/node identity in this page session. Reads/writes/status/list/search/ancestor/image completion are generation fenced, including A→B→A. No delayed response can select a new target or change its draft/save destination. Leaving the Drive page prompts before destroying a dirty draft or unresolved receipt; they are not claimed to survive a browser reload. + +Text edits use the read revision as `expected_revision`. A conflict preserves the draft and that revision, displays an explicit conflict and never silently rebases, automerges or resubmits. Refresh can show the latest saved text alongside the draft. Discard/load-latest is an explicit destructive user decision. Partial previews cannot be saved. Save acknowledgments retain edits made while saving. + +## Transfer and media policy + +- Markdown/plain text: 64 KiB UTF-8 read/edit limit. Markdown uses the existing raw HTML-rejecting safe document renderer. Other active/binary media are download-only. +- Raster image preview: PNG/JPEG/GIF/WebP, authenticated revision-bound fetch, 256 KiB bounded bytes/Blob; decode/load failure is explicit. Object URLs are released on switching/destruction. Larger images remain downloadable. +- Download: native same-origin authenticated binary endpoint, pinned revision; Server's attachment/nosniff/sandbox/no-store headers remain authoritative. +- Markdown inline images never automatically fetch external/cross-Workspace/API resources. They display alternative text and require explicit navigation to an authorized Drive URL. Same-Workspace resource links navigate normally; external HTTP(S) links have no userinfo, open a new tab with `noreferrer`; script/data/file, protocol-relative, arbitrary API/relative paths are not links. +- Upload/replacement: at most 16 MiB raw Blob request, not JSON/base64 or DOM bytes. SHA-256 needs one bounded ArrayBuffer; there is no constant-memory browser hashing claim. Transfer/pending is distinct from the DB-published acknowledgment. +- A request ID is allocated before a mutation. Transport/response loss or abort after transmission is conservatively unknown, not success/failure. Its current authorized receipt endpoint is queried; no new request or automatic reupload is generated. `uncommitted` means no commit observed yet, not proof that an in-flight publication cannot still finish. Unknown requests block new writes to that entry. + +Name conflict, revision conflict, limit, denied, missing node, storage/I/O failure and outcome unknown are not translated into empty contents/empty Drive/success. Initial folder deletion is empty-only with explicit confirmation; no recursive operation or blob-copy rename is introduced. + +## Validation + +Browser API fixtures and real authority evidence are intentionally separate: + +- `web/workspace/src/lib/workspace/drive/*test.ts`: malformed payload/ID and transport/state races at the client/controller boundary. +- `drive-ui`/`drive-grants` component tests: actual safe rendering/interaction. +- `web/workspace/test/drive-browser`: production static shell + synthetic API fixtures, pinned Chromium interactions and visual evidence. +- `crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs` and `web/workspace/test/drive-real-api`: hermetic real Server HTTP, SQLite, FeatureStorage and LocalFileSystem, repositoryless bootstrap and production Web adapter. Only the Runtime process boundary is fake; no live dogfood data is used. + +See the test READMEs and T-724 validation record for exact commands/results, artifact paths, source revisions and any deliberate failing fixture state. diff --git a/docs/development/workdir-removal.md b/docs/development/workdir-removal.md new file mode 100644 index 000000000..450d0ac5f --- /dev/null +++ b/docs/development/workdir-removal.md @@ -0,0 +1,73 @@ +# Workdir removal and ordinary retry + +Use the Runtime **Workdirs** page or the normal typed Workdir removal tool to +remove an unused repository Workdir. Deletion is authorized by the Backend's +existing durable removal operation; it is not a registry-only hide operation. + +A failed attempt does not require a new recovery state or a manual edit of +`materialization.json`. Retry the same removal request after resolving the +reported cause. Each attempt rechecks current attachments, attachment +reservations, retention holds, materialization identity and current file changes. +A previous clean observation is not permission to discard new changes. A dirty +Workdir must be preserved or its changes resolved by its owner before deletion. +When the same repository checkout is still usable, an authorized Worker may +reattach it through normal `WorkdirAttach` to preserve or resolve changes, then +detach and retry removal. An executing removal fences new attachments; a failed +attempt does not permanently prevent reconnection. Missing or replaced checkout +identity is not made usable by this permission. + +## Interpreting a retained Workdir + +- **Mount remains:** Git status being clean does not mean mounted resources are + released. Confirm the mount's owner, users and data destination. Have that + owner safely release the resource, then retry normal deletion. Yoi does not + recursively delete mounted data or implicitly unmount external/unknown + resources. Do not use force-clean, recursive chmod or raw recursive deletion + to override this boundary. +- **Permission denied / resource busy / storage unavailable:** have the owning + operator resolve access, release the resource or restore storage. Retry the + same normal action; registry retention is intentional until cleanup is proven. +- **Changes present / changes cannot be verified:** preserve current files and + restore repository inspection where possible. Do not treat a previous cleanup + failure or missing checkout metadata as proof that all remaining files are + expendable. +- **Identity or ownership unavailable:** verify the Workdir target with its + owner. Do not rewrite registry or materialization identity to bypass checks. +- **Runtime unavailable / outcome unknown:** reconnect the Runtime and retry. + Absence of a provider response is not authoritative not-found evidence. +- **Attachment, reservation or hold:** release the current owning authority + through its normal API before retrying. A stale failure does not freeze these + conditions; newly attached Workers remain protected. + +The Web page refreshes authoritative inventory after each attempt. Successful +removal commits registry deletion only after Runtime confirms physical deletion +or authoritative not-found. An unknown result leaves the registry in place so +retry can reconcile the provider's current state. + +## Runtime safety boundary + +The Runtime keeps removal content/identity evidence outside the tree being +removed. This is a factual witness for partial deletion, not a new recovery +state or cleanup stage. Surviving files are rechecked against it; an intact, +currently clean checkout can renew obsolete evidence without discarding newly +ignored/untracked content. Do not edit this witness manually. + +Mount-safe removal currently requires Linux `openat2` and `/proc` inspection. +Unavailable inspection fails closed with `mount_check_unavailable`; there is no +fallback to recursive deletion across an unchecked filesystem boundary. +Removal retains the Runtime occupancy mutex through its filesystem effect. +Unrelated Runtime state operations can therefore wait during hashing/unlink; +releasing it would require equivalent exclusion on all attachment paths. + +## Diagnostics + +Public responses expose a bounded cause category and safe guidance, not arbitrary +provider errors or host paths. For an OS failure, correlate Backend removal logs +(`operation_id`, `workdir_id`, Runtime) with Runtime cleanup logs for that Workdir +and cleanup action. Runtime logs retain the concrete OS error/errno for the +owning operator. These private diagnostics must not be copied unredacted into +public responses or Tickets. + +This procedure is not authorization to manipulate a live mount or another +Worker's storage. If ownership or required operator authority is unclear, stop +and request that specific owner/operator's decision. diff --git a/docs/report/2026-10-09-subworker-workdir-recovery-ergonomics.md b/docs/report/2026-10-09-subworker-workdir-recovery-ergonomics.md new file mode 100644 index 000000000..ae3a0d3ec --- /dev/null +++ b/docs/report/2026-10-09-subworker-workdir-recovery-ergonomics.md @@ -0,0 +1,34 @@ +# SubWorker / Workdir execution ergonomics during T-730 + +Observed during Workdir removal implementation in a dedicated clean checkout: + +- Children using `profile: builtin:default` were given explicit writable scope + and command permission, but their available tools/context did not support the + requested implementation. They performed read-only investigation and reported + the missing capabilities. Recreating coding children with `profile: inherit` + exposed the needed implementation/Ticket tools. No rejected capability was + bypassed. +- Concurrent Workdir reads sometimes returned `filesystem provider busy`. + This is transient provider contention, not an authorization rejection. A + child initially treated it as a stop condition; a subsequent parent turn + clarified the difference and sequential retries proceeded normally. +- After a user interruption, repository changes and parent tasks remained, but + `WorkerList` no longer listed the previously active children. Their work was + not treated as approved or complete: the parent inspected concrete diffs, + reran Web/browser validation and delegated a fresh bounded Runtime follow-up. + +Potential improvements: + +1. Show effective child tool capabilities together with filesystem/command + grants before spawn, so a writable scope is not mistaken for an available + implementation tool surface. +2. Distinguish retryable provider contention from capability rejection in typed + error metadata and recommended recovery text. +3. Make interruption disposition and retained child-session observation grants + explicit. Preserved files are useful evidence, but they do not replace a + committed final child report or verification of tests. + +These observations concern development-tool ergonomics, not product removal +requirements or permission to operate on live Workdirs/mounts. The T-730 work +uses temporary fixtures and ordinary Git publication; no live cleanup, unmount, +registry edits or runtime deployment were performed. diff --git a/docs/report/t724-subworker-root-binding.md b/docs/report/t724-subworker-root-binding.md new file mode 100644 index 000000000..404e8fc56 --- /dev/null +++ b/docs/report/t724-subworker-root-binding.md @@ -0,0 +1,7 @@ +# T-724: omitted SubWorker cwd can omit the checkout attachment + +During T-724, children created with read-only `.` and restricted writable logical paths but no explicit `cwd` had inconsistent startup contexts. The API child could read the checkout; the browser and real-roundtrip children only saw their transient bash-output directory and reported no repository Workdir/command attachment. Neither blocked child edited source or ran validation. Their committed sessions were inspected before stopping them. + +Recreating those two children with **the same readable/writable rules** and explicit `cwd: "."` at the checkout root (using the inherited Coder profile) restored repository tools. There was no denied-write bypass, broadened filesystem scope, absolute-path fallback or Backend storage mutation. The recreated children successfully ran production-shell visual captures and a real isolated SQLite/LocalFileSystem/Server/Web-adapter test respectively. + +This repeats the omitted-cwd case described in `t718-subworker-path-context.md`. The spawn response should expose actual attachment aliases/root/tool cwd, and reject or clearly report a scope whose attachment could not be bound. The filesystem logical root should remain invariant; cwd is documented as a command default, not an authority grant. This report records tooling friction, not a new Drive requirement or an execution prohibition. diff --git a/docs/report/t724-validation.md b/docs/report/t724-validation.md new file mode 100644 index 000000000..37a575935 --- /dev/null +++ b/docs/report/t724-validation.md @@ -0,0 +1,132 @@ +# T-724 validation evidence + +Scope: `T-724`, authoritative item `00001M4D5ZN6P:4`, resume instruction thread +sequence 7. Baseline: `c79506b60f1abcb3d8698d01491f2b6e644170d0` (clean detached +checkout). Source branch: `work/T-724-workspace-drive`. T-717/T-722 are integrated; +T-723 Tools/WIP and T-714 implementation are not modified. + +## Requirements → observable proof + +| Requirement | Implementation / proof | +| --- | --- | +| Shared Workspace asset, no Repository/Git prerequisite | Workspace sidebar Drive; optional-node route; real `/api/workspaces` repositoryless bootstrap, zero repository records/config, folder/Markdown/PNG/empty-file and Worker artifact roundtrip. | +| DB hierarchy, stable string node URL | `driveHref`, strict decimal/ref/metadata parsers; root/list/parent-chain APIs only; rename/move/delete/recreate lifecycle in real adapter and production browser. No blob path or second persisted tree. | +| Paged folder list, metadata and mutations | Service cursors, bounded pages, breadcrumb, search, creation, native binary download, replace, relocate, empty-folder delete; real and fixture roundtrips. | +| CAS and draft retention | Per-Workspace/node draft with read revision; 2-account real concurrent CAS (one success/one conflict), 2-tab browser drafts, explicit refresh vs discard; component regression proves open rename form does not silently rebase on refresh. | +| Identity/revision fences | Controller selection/write/page epochs; saved tuple, scoped receipts and separate cancel generation; delayed old read/save/status/workspace browser gates; keyed image decode events and URL revocation component regression. | +| Safe Markdown/raster/binary | Existing raw-HTML-rejecting renderer; same-Workspace explicit resource navigation and credential-free external HTTP(S), no automatic inline image requests; real revision-bound PNG transfer, 256 KiB raster bound, HTML/SVG download-only, failed image/truncated/empty browser states. | +| Upload publication / errors / OutcomeUnknown | Raw Blob ≤16 MiB + SHA-256; transfer receipt vs DB-published acknowledgment; cancellation/loss preserve request ID and query current-authorized result, no unconditional reupload; typed name/CAS/limit/denied/storage failures. | +| Backend current permission and Worker grants | Existing authoritative Worker catalog; owner setting flag; explicit read_only/read_write, separate Profile enablement, complete paged grants, revoke; real signed read-only Worker denial/revoke and exact Web grant-adapter owner/member HTTP boundaries; component and browser grant interactions. | +| Latest only / no added workflow | Explicit replacement/history warning; no versions, approval/publication workflow, public share links or recursive folder delete. | + +## Real API/DB/LocalFileSystem evidence (not fixture responses) + +`crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs` creates a +fresh temporary SQLite/FeatureStorage/LocalFileSystem Workspace over real loopback +HTTP. It starts no live dogfood Server or Runtime; the Runtime Worker-spawn process +boundary is fake, while Workspace/Worker registry/grants/nodes/bytes/receipts and +request authentication are production authorities. + +The explicit Deno child imports the **production** content and grant adapters. +Fresh credentials arrive via stdin; origin/auth resolution is loopback-bound and +responses are never mocked. This proves authenticated HTTP adapters, **not** a +Passkey/cookie browser login or complete live Runtime process E2E. + +- Ordinary real HTTP test: **1 passed / 1 explicitly ignored** process test. +- Explicit process test (`--ignored`): **1 passed**, including owner grant + read_only/read_write create/list/revoke/re-read and non-owner administration + rejection after parent integration. +- Full changed Server crate, replayed by the parent before commit: **693 library + + 11 binary passed**, 1 explicitly ignored; doc-tests passed + (`target/t724-server-final.log`). +- Parent real adapter replay: `target/t724-real-adapter-final.log` (**1 passed**). + Ordinary HTTP coverage is also part of the full Server crate run. + +Reproduce using `web/workspace/test/drive-real-api/README.md` or +`deno task test:drive:real-api` from `web/workspace`. + +## Compile, generated contracts and Web semantic validation + +- Workspace root `cargo check`: pass (`target/t724-root-check-final.log`). +- `cargo test -p server-api --features typescript`: **91 passed**, doc-tests pass. +- Canonical OpenAPI `export_openapi -- --check openapi/server-api.json`: current. +- Regenerated Drive TypeScript, Deno-formatted to the checked-in style: byte equal + to `web/workspace/src/lib/generated/drive-api.ts`; no generated contract edits. +- `generate_repository_openapi_types`, `generate_runtime_api_types`, + `generate_ticket_api_types`, `generate_companion_api_types`, + `generate_worker_launch_api_types` with `--check`: all current. +- `cargo fmt --all -- --check`, Deno format/lint of new typed boundaries, and + `git diff --check HEAD`: pass. +- Web `deno task check`: **0 errors / 0 warnings**. +- Web `deno task test`: **566 passed**, including 36 content/controller boundary + tests and 2 preview/link-policy tests. +- Web `deno task test:component`: **302 passed / 32 files**, including actual + Markdown rendering/alt labels, late decode fences, pinned form revision, grant + administration, and dependent existing Worker/Console/Markdown surfaces. +- Separate fixture-client compatibility tests: **4 passed**. These prove mock + wire compatibility, not real storage or authorization. + +Web logs: `.svelte-kit/t724-{web-check-final,unit-final,components-final}.log`, +plus narrow `.svelte-kit/t724-{drive-state,ui-fence,markdown-tests}.log`. + +## Production browser and visual review + +Production rebuild and implementer-owned visual acceptance completed on +2026-10-09 before the implementation commit: + +- `deno task build`: pass (`.svelte-kit/t724/final-build.log`). +- Production browser scenarios: **9 passed** (`.svelte-kit/t724/browser-final.log`). +- Responsive/keyboard/raster audit: **1 passed**, all 1440/768/390/320 widths in + both themes (`.svelte-kit/t724/responsive-final.{log,json}`). +- Official after bundles: `.svelte-kit/t724/visual/t724-after-{light,dark}-committed-candidate/`, + **108 captures per theme**. The same baseline Home/Workers personas and widths + are compared, with additional Drive/grant states. +- Parent opened before and after screenshot overview sheets through `ViewImage` + (`target/t724-final-visual/{before,after}-{light,dark}-*.png`), plus original + 320px grant and 390px denied-write captures. Reviewed grouping, separators, + spacing, focus, wrapping, page vs table scroll, and permission/recovery copy. + Home/Workers composition is preserved except the intended shared Drive entry, + grant section and contained Worker-table scrolling. New states remain distinct + and narrow layouts retain accessible controls without document overflow. +- Parent inspected official `review-context.json`: HTTP document status 200, + accessibility snapshots and screenshot hashes present; diagnostic dispositions + retain intentional 403/503 fixtures and superseded metadata aborts. Both + `diagnostic-disposition.json` files have **no unexpected diagnostics**. The + runner correctly reports `completed-with-errors` for injected failures; visual + acceptance is the implementer's review, not that exit/status label. +- Visual result: **pass**, no unresolved new visual regression. Artifacts use + baseline source revision plus dirty context; production source is frozen by + the subsequent commit (only test formatting/documentation changed afterward). + +A final grant regression also proves catalog readiness toggles cannot abandon an +already transmitted mutation. Readiness gates new actions but is not Worker +identity. All **22 grant component tests** pass; pending/unknown operations remain +fenced until their result is reconciled. Worst-case 64 KiB NUL text additionally +proves the separate 512 KiB JSON response bound without relaxing the raster bound. +These changes are included in the final Web check/test/build/captures above. + +The production static Web shell is exercised against synthetic loopback APIs; +this is separate from the real authority tests above. The fixture has a single +Worker catalog reused by REST/subscription projections, not a second UI registry. +See `web/workspace/test/drive-browser/README.md` for reproducible scenarios, gates, +owned-process cleanup and the browser/visual commands. + +Before sources are preserved under `.svelte-kit/t724/before-build/`; baseline JS +has no Drive/grant controls. Official before bundles use +`.svelte-kit/t724/visual/t724-before-{light,dark}-representative/`. The current +implementation was never overwritten to produce the final static before build. + +Intermediate visual review found and fixed: +- Narrow Worker table widening the page grid and clipping grant controls: contain + horizontal table scrolling and constrain the page grid; full identities remain + available in a disclosure. +- Markdown image placeholders losing alternate text: use the renderer library's + actual `text` prop; retain no automatic image loading. Component and production + browser regressions protect this. +- Parent semantic review additionally fixed late image-decode event identity and + open rename/replacement revision pinning, with focused component tests. + +Injected permission/storage/list errors and superseded aborted breadcrumb reads +remain in official context with explicit dispositions. A successful capture exit +or HTTP200 alone is not visual acceptance. No screenshots, credential state or +real response secrets are committed. diff --git a/tools/web-ux/browser-tests/workdir_fixture.ts b/tools/web-ux/browser-tests/workdir_fixture.ts new file mode 100644 index 000000000..4e3597870 --- /dev/null +++ b/tools/web-ux/browser-tests/workdir_fixture.ts @@ -0,0 +1,140 @@ +// Synthetic API boundary only; the production route and shell own all rendering. +export function workdirFixture() { + let mountReleased = false; + const deleted = new Set(); + const requests: { + workspace: string; + method: string; + path: string; + body?: unknown; + }[] = []; + const summary = ( + id: string, + status = "active", + cleanliness = "clean", + occupied = false, + ) => ({ + working_directory_id: id, + display_name: id === "retry-dir" ? "Cleanup retry — distributed development checkout" : id, + source: { kind: "repository", repository_key: "main" }, + materializer_kind: "runtime_git_clone", + status, + cleanliness, + current_ref: "a".repeat(40), + current_selector: "develop", + occupied_by: occupied + ? { + runtime_id: "fixture-runtime", + worker_id: "worker-1", + display_name: "Active Worker", + linked_at: "2026-01-01T00:00:00Z", + } + : null, + }); + const items = (workspace: string) => + workspace === "home-empty" ? [] : [ + ...(deleted.has(workspace) ? [] : [summary("retry-dir", "cleanup_pending", "unknown")]), + summary("dirty-dir", "active", "dirty"), + summary("occupied-dir", "active", "clean", true), + ]; + const plan = (workspace: string) => ({ + workspace_id: workspace, + runtime_id: "fixture-runtime", + generated_at: "2026-01-01T00:00:00Z", + revision: "plan-1", + digest: "digest-1", + workers: [], + diagnostics: [], + workdirs: items(workspace).map((item) => ({ + target_id: `workdir:${item.working_directory_id}`, + action: item.cleanliness === "dirty" ? "workdir_dirty_discard" : "workdir_clean_cleanup", + workdir_id: item.working_directory_id, + runtime_id: "fixture-runtime", + repository_key: "main", + reason: "Ordinary cleanup rechecks current conditions", + linked_worker_ids: item.occupied_by ? ["worker-1"] : [], + linked_running_worker_ids: item.occupied_by ? ["worker-1"] : [], + running_linked: Boolean(item.occupied_by), + pinned_linked: false, + file_status: item.status === "cleanup_pending" ? "pending" : "present", + cleanliness: item.cleanliness, + blocking_reason: item.occupied_by ? "Workdir is linked to a running Worker" : null, + })), + }); + return async (request: Request): Promise => { + const url = new URL(request.url); + if ( + url.pathname === "/fixture-workdirs-control" && request.method === "POST" + ) { + const control = await request.json(); + mountReleased = control.mountReleased === true; + if (control.reset) { + deleted.clear(); + requests.length = 0; + } + return Response.json({ ok: true }); + } + if (url.pathname === "/fixture-workdirs") { + return Response.json({ requests }); + } + const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/runtimes.*)$/); + if (!match) return null; + const [, workspace, path] = match; + if (path === "/runtimes") { + return Response.json({ + workspace_id: workspace, + limit: 100, + source: "fixture", + diagnostics: [], + items: [], + }); + } + if (!path.startsWith("/runtimes/fixture-runtime/")) return null; + const body = request.method !== "GET" ? await request.json() : undefined; + requests.push({ workspace, method: request.method, path, body }); + if (path.endsWith("/working-directories")) { + return Response.json({ + workspace_id: workspace, + items: items(workspace), + diagnostics: [], + }); + } + if (path.endsWith("/cleanup-plan")) return Response.json(plan(workspace)); + // Baseline route uses cleanup-executions; same retained outcome for before captures. + if (path.endsWith("/cleanup-executions")) { + return Response.json({ + workspace_id: workspace, + runtime_id: "fixture-runtime", + executed_at: "2026-01-01T00:00:00Z", + diagnostics: [], + plan_after: plan(workspace), + results: [{ + target_id: "workdir:retry-dir", + action: "workdir_clean_cleanup", + status: "retained", + message: "Provider cleanup failed", + }], + }); + } + if ( + path.endsWith("/working-directories/retry-dir") && + request.method === "DELETE" + ) { + if (workspace === "home-error") { + return Response.json({ + message: "Synthetic private host path must not render", + }, { status: 503 }); + } + if (mountReleased) deleted.add(workspace); + return Response.json({ + working_directory_id: "retry-dir", + disposition: mountReleased ? "removed" : "attention_required", + retryable: !mountReleased, + failure_category: mountReleased ? null : "mount_present", + }); + } + return Response.json({ error: "Unexpected fixture Workdir request" }, { + status: 404, + }); + }; +} diff --git a/tools/web-ux/browser-tests/workdir_removal_shell_test.ts b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts new file mode 100644 index 000000000..e48f0724e --- /dev/null +++ b/tools/web-ux/browser-tests/workdir_removal_shell_test.ts @@ -0,0 +1,456 @@ +/// +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const evidence = join(root, "target/web-ux/t730-browser"); + +async function noOverflow(page: Page) { + assertEquals( + await page.locator("main").evaluate((main) => ({ + page: document.documentElement.scrollWidth > innerWidth, + main: main.scrollWidth > main.clientWidth, + })), + { page: false, main: false }, + ); +} + +Deno.test("ordinary removal refreshes failed and successful inventory, supports exact retry, and preserves permission and live guards across viewports and themes", async () => { + await Deno.mkdir(evidence, { recursive: true }); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const url = `http://127.0.0.1:${port}`; + const server = await startOwnedProcesses( + [{ + id: "workdirs", + command: Deno.execPath(), + cwd: root, + args: [ + "run", + "--allow-net", + "--allow-read", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + String(port), + "web/workspace/build", + ], + readyUrl: `${url}/health`, + }], + join(root, "tools/web-ux/scenarios/workdir-removal-light.json"), + join(evidence, "process-logs"), + [], + ); + const observations: unknown[] = []; + const injectedConsoleErrors: string[] = []; + const injectedUnexpectedErrors: string[] = []; + const observeInjectedFailure = (page: Page) => { + page.on("console", (message) => { + if (message.type() === "error") { + injectedConsoleErrors.push(message.text()); + } + }); + page.on( + "pageerror", + (error) => injectedUnexpectedErrors.push(String(error)), + ); + page.on( + "requestfailed", + (request) => injectedUnexpectedErrors.push(request.url()), + ); + }; + const control = async (value: unknown) => { + const response = await fetch(`${url}/fixture-workdirs-control`, { + method: "POST", + body: JSON.stringify(value), + }); + assert(response.ok); + await response.body?.cancel(); + }; + const state = async () => await (await fetch(`${url}/fixture-workdirs`)).json(); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + await control({ reset: true }); + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => errors.push(request.url())); + const route = `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`; + await page.goto(route); + const deletion = page.getByRole("button", { + name: "Delete retry-dir", + exact: true, + }); + await deletion.waitFor(); + assert( + await deletion.isEnabled(), + "past pending/unknown state permanently disabled ordinary removal", + ); + assert( + await page.getByRole("button", { + name: "Delete dirty-dir", + exact: true, + }).isDisabled(), + ); + assert( + await page.getByRole("button", { + name: "Delete occupied-dir", + exact: true, + }).isDisabled(), + ); + await noOverflow(page); + assert( + await deletion.evaluate((button) => { + const r = button.getBoundingClientRect(); + return r.left >= 0 && r.right <= innerWidth; + }), + "Delete is outside initial viewport", + ); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-initial.png`), + }); + // Hold the actual request boundary to verify duplicate gestures cannot dispatch another DELETE. + let release!: () => void; + const gate = new Promise((resolve) => release = resolve); + let observed!: () => void; + const entered = new Promise((resolve) => observed = resolve); + let deletes = 0; + await page.route( + "**/working-directories/retry-dir", + async (intercept) => { + deletes++; + observed(); + await gate; + await intercept.continue(); + }, + ); + await deletion.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("button", { + name: "Delete retry-dir", + exact: true, + }).filter({ hasText: "Deleting…" }).waitFor(); + assert(await deletion.isDisabled()); + await page.keyboard.press("Enter"); + await entered; + assertEquals(deletes, 1); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-deleting.png`), + }); + release(); + await page.getByRole("alert").filter({ + hasText: "mounted filesystem", + }).waitFor(); + const retry = page.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }); + await retry.waitFor(); + await page.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Retry Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + const failed = await state(); + const beforeGets = failed.requests.filter(( + r: { workspace: string; method: string }, + ) => r.workspace === "home-owner" && r.method === "GET"); + assertEquals( + beforeGets.filter((r: { path: string }) => r.path.endsWith("/working-directories")) + .length, + 2, + ); + assertEquals( + beforeGets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length, + 2, + ); + assertEquals(await page.locator("tbody tr").count(), 3); + await noOverflow(page); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-mount-failure.png`), + }); + await control({ mountReleased: true }); + await retry.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("status").filter({ + hasText: "Deletion confirmed", + }).waitFor(); + await page.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2); + assertEquals( + await page.getByRole("button", { name: /retry-dir/ }).count(), + 0, + ); + assert( + await page.getByRole("button", { + name: "Delete dirty-dir", + exact: true, + }).isDisabled(), + ); + const final = await state(); + const mutations = final.requests.filter((r: { method: string }) => r.method === "DELETE"); + assertEquals(mutations.length, 2); + assertEquals( + mutations[0], + mutations[1], + "ordinary retry changed its request", + ); + const gets = final.requests.filter(( + r: { workspace: string; method: string }, + ) => r.workspace === "home-owner" && r.method === "GET"); + assertEquals( + gets.filter((r: { path: string }) => r.path.endsWith("/working-directories")).length, + 3, + ); + assertEquals( + gets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length, + 3, + ); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-removed.png`), + }); + await page.goto( + `${url}/w/home-member/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.locator("tbody tr").first().waitFor(); + assertEquals( + await page.getByRole("button", { name: /Delete/ }).count(), + 0, + ); + await noOverflow(page); + await page.screenshot({ + path: join(evidence, `${colorScheme}-${width}-member.png`), + }); + await page.goto( + `${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.getByText("No workdirs are visible for this Runtime.") + .waitFor(); + await noOverflow(page); + assertEquals(errors, []); + observations.push({ + colorScheme, + width, + requests: final.requests, + errors, + }); + } finally { + await context.close(); + } + } + } + + // Lost/HTTP failure responses still trigger fresh inventory and eligibility. Never echo provider bodies. + await control({ reset: true }); + const page = await browser.newPage(); + observeInjectedFailure(page); + await page.goto( + `${url}/w/home-error/settings/runtimes/fixture-runtime/workdirs`, + ); + await page.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await page.getByRole("alert").filter({ hasText: "provider is unavailable" }) + .waitFor(); + await page.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + assert( + !(await page.locator("main").innerText()).includes("Synthetic private"), + ); + const failed = await state(); + assertEquals( + failed.requests.filter((r: { method: string; path: string }) => + r.method === "GET" && r.path.endsWith("/working-directories") + ).length, + 2, + ); + assertEquals( + failed.requests.filter((r: { method: string; path: string }) => + r.method === "GET" && r.path.endsWith("/cleanup-plan") + ).length, + 2, + ); + await page.screenshot({ path: join(evidence, "http-failure.png") }); + await page.close(); + + // Plan refresh failure cannot leave stale delete authority enabled; manual Refresh recovers it. + await control({ reset: true }); + const stale = await browser.newPage(); + observeInjectedFailure(stale); + let planGets = 0; + let planFails = true; + await stale.route("**/cleanup-plan", async (route) => { + planGets++; + if (planGets > 1 && planFails) { + await route.fulfill({ + status: 503, + json: { message: "Synthetic private path" }, + }); + } else await route.continue(); + }); + await stale.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await stale.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await stale.getByRole("alert").filter({ hasText: "could not be refreshed" }) + .waitFor(); + assert( + await stale.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).isDisabled(), + ); + assert( + !(await stale.locator("main").innerText()).includes("Synthetic private"), + ); + await stale.screenshot({ path: join(evidence, "refresh-failure.png") }); + planFails = false; + await stale.getByRole("button", { name: "Refresh", exact: true }).click(); + await stale.waitForFunction(() => + !(document.querySelector( + "button[aria-label='Retry Delete retry-dir']", + ) as HTMLButtonElement)?.disabled + ); + await stale.close(); + + // Current dirty changes after a failure override a retryable historical result. + await control({ reset: true }); + const changed = await browser.newPage(); + let changedGets = 0; + await changed.route("**/cleanup-plan", async (route) => { + const response = await route.fetch(); + const plan = await response.json(); + if (++changedGets > 1) plan.workdirs[0].action = "workdir_dirty_discard"; + await route.fulfill({ response, json: plan }); + }); + await changed.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await changed.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await changed.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).waitFor(); + await changed.waitForFunction(() => + document.querySelector(".workdir-identity .removal-guard")?.textContent + ?.includes("Changes are protected") + ); + assert( + await changed.getByRole("button", { + name: "Retry Delete retry-dir", + exact: true, + }).isDisabled(), + ); + assertStringIncludes( + await changed.locator("main").innerText(), + "Changes are protected", + ); + await changed.screenshot({ + path: join(evidence, "new-changes-protected.png"), + }); + await changed.close(); + // A malformed response may follow a real side effect. Inventory still removes the now-absent row. + await control({ reset: true, mountReleased: true }); + const lost = await browser.newPage(); + observeInjectedFailure(lost); + await lost.route("**/working-directories/retry-dir", async (route) => { + await route.fetch(); + await route.fulfill({ + status: 200, + json: { + message: "Synthetic private path", + failure_category: "/private/path token=synthetic", + }, + }); + }); + await lost.goto( + `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`, + ); + await lost.getByRole("button", { name: "Delete retry-dir", exact: true }) + .click(); + await lost.getByRole("alert").filter({ + hasText: "Removal could not be confirmed", + }).waitFor(); + await lost.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2); + assert( + !(await lost.locator("main").innerText()).includes("Synthetic private"), + ); + assert(!(await lost.locator("main").innerText()).includes("/private/path")); + await lost.screenshot({ + path: join(evidence, "malformed-response-refreshed.png"), + }); + await lost.close(); + + // Even a failed initial inventory can recover into a confirmed empty list with manual Refresh. + const emptyRecovery = await browser.newPage(); + observeInjectedFailure(emptyRecovery); + let inventoryFails = true; + await emptyRecovery.route( + "**/runtimes/fixture-runtime/working-directories", + async (route) => { + if (inventoryFails) { + await route.fulfill({ + status: 503, + json: { message: "Synthetic private path" }, + }); + } else await route.continue(); + }, + ); + await emptyRecovery.goto( + `${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`, + ); + await emptyRecovery.getByText("Workdir inventory is unavailable.") + .waitFor(); + inventoryFails = false; + await emptyRecovery.getByRole("button", { name: "Refresh", exact: true }) + .click(); + await emptyRecovery.getByText("No workdirs are visible for this Runtime.") + .waitFor(); + await emptyRecovery.close(); + assertEquals(injectedUnexpectedErrors, []); + assert( + injectedConsoleErrors.every((message) => message.includes("503")), + "unexpected injected failure console diagnostic", + ); + await Deno.writeTextFile( + join(evidence, "evidence.json"), + JSON.stringify( + { + observations, + injectedConsoleErrors, + injectedUnexpectedErrors, + failureRefresh: failed.requests, + refreshFailureRecovered: true, + newChangesProtected: true, + malformedResponseReconciled: true, + emptyInventoryRecovered: true, + expectedFailureDiagnostics: [ + "Synthetic DELETE HTTP 503 on home-error", + "Synthetic cleanup-plan HTTP 503 after removal", + "Malformed DELETE result after fixture physical removal", + "Synthetic initial inventory HTTP 503", + ], + }, + null, + 2, + ), + ); + } finally { + await browser.close(); + assertEquals(await stopOwnedProcesses(server), []); + } +}); diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index c80c4f697..5d478044f 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -2,6 +2,8 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; import { settingsFixtureHandler } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts"; +import { workdirFixture } from "./workdir_fixture.ts"; +const workdirsFixture = workdirFixture(); const settingsFixture = settingsFixtureHandler(); const port = Number(Deno.args[0]); @@ -30,6 +32,8 @@ const mime: Record = { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + const workdirsResponse = await workdirsFixture(request); + if (workdirsResponse) return workdirsResponse; const settingsResponse = await settingsFixture(request); if (settingsResponse) return settingsResponse; if (url.pathname === "/api/workspaces") { diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index e3e278be5..0ffbee433 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/drive-fixture/api.ts b/tools/web-ux/drive-fixture/api.ts new file mode 100644 index 000000000..2eef04cd1 --- /dev/null +++ b/tools/web-ux/drive-fixture/api.ts @@ -0,0 +1,396 @@ +// Browser-only, in-memory API fixture. This is NOT Backend authorization/storage evidence. +import type { + DriveEntry, + DriveEntryRef, + DriveGrantResponse, + DriveMutation, + DriveMutationResponse, +} from "../../../web/workspace/src/lib/generated/drive-api.ts"; +import { fixturePng } from "./raster.ts"; + +type Node = { entry: DriveEntry; bytes: Uint8Array }; +type Workspace = { + nodes: Map; + receipts: Map; + grants: DriveGrantResponse[]; + next: bigint; +}; +export type FixtureLog = { + workspace: string; + path: string; + method: string; + requestId: string | null; + nodeId: string | null; + expectedRevision: string | null; +}; +const encoder = new TextEncoder(); +const rootId = "9007199254740993"; +export const fixtureRootId = rootId; +const instant = "2026-01-02T00:00:00Z"; +const errors = { + denied: 403, + not_found: 404, + conflict: 409, + invalid: 400, + limit: 413, + storage_unavailable: 503, + outcome_unknown: 503, +} as const; +function failure(code: keyof typeof errors): Response { + return Response.json({ + code, + classification: code === "outcome_unknown" ? "unknown" : "not_committed", + message: `Drive fixture ${code}`, + }, { status: errors[code] }); +} +export function createDriveFixture() { + const workspaces = new Map(); + const log: FixtureLog[] = []; + const faults = new Map(); + function ws(id: string): Workspace { + let state = workspaces.get(id); + if (state) return state; + state = { nodes: new Map(), receipts: new Map(), grants: [], next: 100n }; + workspaces.set(id, state); + const add = ( + nodeId: string, + name: string, + kind: "folder" | "file", + parent: string | null, + contentType: string | null, + bytes = new Uint8Array(), + ) => { + const ref = (n: string): DriveEntryRef => ({ workspace_id: id, node_id: n }); + state!.nodes.set(nodeId, { + bytes, + entry: { + entry: ref(nodeId), + parent: parent === null ? null : ref(parent), + name, + kind, + revision: "1", + size: kind === "file" ? bytes.length : null, + content_type: contentType, + updated_by: "fixture-worker", + updated_at: instant, + latest_url: `/api/w/${encodeURIComponent(id)}/drive/${ + kind === "file" ? "download" : "metadata" + }?entry_workspace_id=${encodeURIComponent(id)}&id=${nodeId}`, + }, + }); + }; + add(rootId, "Drive", "folder", null, null); + if (id.includes("empty")) return state; + add("2", "資料", "folder", rootId, null); + add( + "3", + "README.md", + "file", + rootId, + "text/markdown", + encoder.encode( + `# Shared notes\n\nWorker-created latest-only document.\n\n[Local folder](/w/${ + encodeURIComponent(id) + }/drive/2)\n\n[Unsafe](javascript:alert(1))\n\n\n\n![External](https://example.invalid/credential-trap.png)\n\n![Image](/w/${ + encodeURIComponent(id) + }/drive/5)\n`, + ), + ); + add("4", "notes.txt", "file", "2", "text/plain", encoder.encode("Second document — 日本語")); + add("5", "landscape.png", "file", rootId, "image/png", fixturePng); + add("6", "empty.txt", "file", rootId, "text/plain"); + add("7", "broken.png", "file", rootId, "image/png", encoder.encode("not a valid PNG")); + add( + "8", + "unsafe.svg", + "file", + rootId, + "image/svg+xml", + encoder.encode(''), + ); + add( + "9", + "large.txt", + "file", + rootId, + "text/plain", + encoder.encode("bounded preview\n".repeat(5000)), + ); + add( + "10", + "長い名前 — 国際化された資料と分散開発の成果物 — ".repeat(2) + ".md", + "file", + rootId, + "text/markdown", + encoder.encode("# Long name"), + ); + add("11", "escaped-64k.txt", "file", rootId, "text/plain", new Uint8Array(65536)); + if (id.includes("paged")) { + for (let n = 20; n < 260; n++) { + add( + String(n), + `Document ${n}.txt`, + "file", + rootId, + "text/plain", + encoder.encode(String(n)), + ); + } + } + state.next = id.includes("paged") ? 300n : 100n; + return state; + } + function bump(node: Node) { + node.entry.revision = String(BigInt(node.entry.revision) + 1n); + } + function mutate(id: string, mutation: DriveMutation): DriveEntry | null { + const state = ws(id); + const target = "id" in mutation ? state.nodes.get(mutation.id.node_id) : null; + if ("id" in mutation && (mutation.id.workspace_id !== id || !target)) throw "not_found"; + if ( + target && "expected_revision" in mutation && + target.entry.revision !== mutation.expected_revision + ) throw "conflict"; + if ("parent" in mutation) { + const parent = state.nodes.get(mutation.parent.node_id); + if (mutation.parent.workspace_id !== id || parent?.entry.kind !== "folder") throw "not_found"; + } + if ("name" in mutation) { + if ( + !mutation.name || mutation.name.includes("/") || encoder.encode(mutation.name).length > 255 + ) throw "invalid"; + if ( + [...state.nodes.values()].some((n) => + n !== target && n.entry.parent?.node_id === mutation.parent.node_id && + n.entry.name === mutation.name + ) + ) throw "conflict"; + } + if (mutation.operation === "delete") { + if ( + target!.entry.parent === null || + [...state.nodes.values()].some((n) => n.entry.parent?.node_id === mutation.id.node_id) + ) throw "conflict"; + state.nodes.delete(mutation.id.node_id); + return null; + } + if (mutation.operation === "relocate") { + target!.entry.parent = mutation.parent; + target!.entry.name = mutation.name; + bump(target!); + return target!.entry; + } + if (mutation.operation === "update_text") { + target!.bytes = encoder.encode(mutation.text); + target!.entry.size = target!.bytes.length; + target!.entry.content_type = mutation.content_type; + bump(target!); + return target!.entry; + } + const nodeId = String(state.next++); + const bytes = mutation.operation === "create_text" + ? encoder.encode(mutation.text) + : new Uint8Array(); + const entry: DriveEntry = { + entry: { workspace_id: id, node_id: nodeId }, + parent: mutation.parent, + name: mutation.name, + kind: mutation.operation === "create_folder" ? "folder" : "file", + revision: "1", + size: mutation.operation === "create_folder" ? null : bytes.length, + content_type: mutation.operation === "create_text" ? mutation.content_type : null, + updated_by: "fixture-user", + updated_at: instant, + latest_url: `/api/w/${encodeURIComponent(id)}/drive/${ + mutation.operation === "create_folder" ? "metadata" : "download" + }?entry_workspace_id=${encodeURIComponent(id)}&id=${nodeId}`, + }; + state.nodes.set(nodeId, { entry, bytes }); + return entry; + } + async function handler(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/__fixture/log") return Response.json(log); + if (url.pathname === "/__fixture/reset" && request.method === "POST") { + workspaces.clear(); + faults.clear(); + log.length = 0; + return Response.json({ ok: true }); + } + if (url.pathname === "/__fixture/fault" && request.method === "POST") { + const body = await request.json(); + faults.set(body.workspace, body.mode); + return Response.json({ ok: true }); + } + const match = url.pathname.match(/^\/api\/w\/([^/]+)\/drive(\/.*)$/); + if (!match) return null; + const id = decodeURIComponent(match[1]), path = match[2]; + const state = ws(id); + const mutationRoute = path === "/mutate" || path === "/upload"; + const event: FixtureLog = { + workspace: id, + path, + method: request.method, + requestId: url.searchParams.get("request_id"), + nodeId: url.searchParams.get("id") ?? url.searchParams.get("parent_id"), + expectedRevision: url.searchParams.get("expected_revision"), + }; + log.push(event); + if (id.includes("denied")) return failure("denied"); + if (id.includes("offline") || id === "home-error") return failure("storage_unavailable"); + if (mutationRoute && (id.includes("readonly") || id === "home-member")) { + return failure("denied"); + } + if (path.startsWith("/requests/")) { + const requestId = decodeURIComponent(path.slice(10)); + const response = state.receipts.get(requestId) ?? null; + return Response.json({ + request_id: requestId, + state: response ? "committed" : "uncommitted", + response, + }); + } + if (path === "/grants") { + if (id === "home-member") return failure("denied"); + if (request.method === "POST") { + const input = await request.json(); + const grant: DriveGrantResponse = { + ...input, + grant_id: String(state.grants.length + 1), + workspace_id: id, + revoked: false, + created_by: "fixture-owner", + created_at: instant, + revoked_by: null, + revoked_at: null, + }; + state.grants.push(grant); + return Response.json(grant); + } + return Response.json({ grants: state.grants, next_after: null }); + } + if (/^\/grants\/\d+$/.test(path) && request.method === "DELETE") { + if (id === "home-member") return failure("denied"); + const grant = state.grants.find((g) => g.grant_id === path.split("/")[2]); + if (!grant) return failure("not_found"); + grant.revoked = true; + grant.revoked_at = instant; + grant.revoked_by = "fixture-owner"; + return Response.json(grant); + } + if (mutationRoute) { + try { + const payload = path === "/mutate" ? await request.json() : null; + const requestId = path === "/upload" + ? url.searchParams.get("request_id")! + : payload.request_id; + event.requestId = requestId; + if (payload) { + event.nodeId = payload.mutation.id?.node_id ?? payload.mutation.parent?.node_id ?? null; + event.expectedRevision = payload.mutation.expected_revision ?? null; + } + if (state.receipts.has(requestId)) return Response.json(state.receipts.get(requestId)); + const fault = faults.get(id); + if (fault === "conflict") { + faults.delete(id); + return failure("conflict"); + } + let entry: DriveEntry | null; + if (path === "/mutate") entry = mutate(id, payload.mutation); + else { + const bytes = new Uint8Array(await request.arrayBuffer()); + if (bytes.length > 16 * 1024 * 1024) return failure("limit"); + if (bytes.length !== Number(url.searchParams.get("size"))) return failure("invalid"); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + if ( + [...digest].map((n) => n.toString(16).padStart(2, "0")).join("") !== + url.searchParams.get("sha256") + ) return failure("invalid"); + const content_type = url.searchParams.get("content_type")!; + if (url.searchParams.get("operation") === "create") { + entry = mutate(id, { + operation: "create_text", + parent: { workspace_id: id, node_id: url.searchParams.get("parent_id")! }, + name: url.searchParams.get("name")!, + text: "", + content_type, + }); + } else {entry = mutate(id, { + operation: "update_text", + id: { workspace_id: id, node_id: url.searchParams.get("id")! }, + expected_revision: url.searchParams.get("expected_revision")!, + text: "", + content_type, + });} + const node = state.nodes.get(entry!.entry.node_id)!; + node.bytes = bytes; + node.entry.size = bytes.length; + } + const response = structuredClone({ request_id: requestId, entry }); + state.receipts.set(requestId, response); + if (fault === "unknown") { + faults.delete(id); + return failure("outcome_unknown"); + } + return Response.json(response); + } catch (error) { + return failure( + typeof error === "string" && error in errors ? error as keyof typeof errors : "invalid", + ); + } + } + const nodeId = path === "/root" ? rootId : url.searchParams.get("id"); + if (nodeId !== null && path !== "/root" && url.searchParams.get("entry_workspace_id") !== id) { + return failure("invalid"); + } + const node = nodeId ? state.nodes.get(nodeId) : null; + if (path === "/root" || path === "/metadata") { + return node ? Response.json(node.entry) : failure("not_found"); + } + if (path === "/list" || path === "/search") { + if (path === "/list" && node?.entry.kind !== "folder") return failure("not_found"); + const all = [...state.nodes.values()].filter((n) => + path === "/search" + ? n.entry.name.includes(url.searchParams.get("query") ?? "") + : n.entry.parent?.node_id === nodeId + ).sort((a, b) => a.entry.name.localeCompare(b.entry.name)); + const after = url.searchParams.get("after"); + const start = after ? all.findIndex((n) => n.entry.entry.node_id === after) + 1 : 0; + const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200); + const entries = all.slice(start, start + limit).map((n) => n.entry); + return Response.json({ + entries, + next_after: start + limit < all.length ? entries.at(-1)!.entry.node_id : null, + }); + } + if (!node || node.entry.kind !== "file") return failure("not_found"); + if (path === "/read-text") { + const text = new TextDecoder().decode(node.bytes.slice(0, 65536)); + return Response.json({ entry: node.entry, text, truncated: node.bytes.length > 65536 }); + } + if (path === "/download" || path === "/read-chunk") { + if ( + url.searchParams.has("expected_revision") && + url.searchParams.get("expected_revision") !== node.entry.revision + ) return failure("conflict"); + if (faults.get(id) === "image_error" && node.entry.content_type?.startsWith("image/")) { + return failure("storage_unavailable"); + } + const bytes = path === "/read-chunk" + ? node.bytes.slice( + Number(url.searchParams.get("offset")), + Number(url.searchParams.get("offset")) + Number(url.searchParams.get("length")), + ) + : node.bytes; + return new Response(new Uint8Array(bytes).buffer, { + headers: { + "content-type": node.entry.content_type!, + "content-disposition": "attachment", + "x-content-type-options": "nosniff", + }, + }); + } + return failure("not_found"); + } + return { handler, log }; +} diff --git a/tools/web-ux/drive-fixture/raster.ts b/tools/web-ux/drive-fixture/raster.ts new file mode 100644 index 000000000..c58bcd88f --- /dev/null +++ b/tools/web-ux/drive-fixture/raster.ts @@ -0,0 +1,54 @@ +// Synthetic raster bytes only; no fixture CSS, DOM renderer, image URL or UI branch. +// A real PNG with 1200×800 natural dimensions and simple colored checker/landscape shapes. +import { deflateSync } from "node:zlib"; +export const fixtureImageWidth = 1200; +export const fixtureImageHeight = 800; +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(type: string, data: Uint8Array): Uint8Array { + const bytes = new Uint8Array(data.length + 12); + const view = new DataView(bytes.buffer); + view.setUint32(0, data.length); + bytes.set(new TextEncoder().encode(type), 4); + bytes.set(data, 8); + view.setUint32(data.length + 8, crc32(bytes.subarray(4, data.length + 8))); + return bytes; +} +const ihdr = new Uint8Array(13); +const dimensions = new DataView(ihdr.buffer); +dimensions.setUint32(0, fixtureImageWidth); +dimensions.setUint32(4, fixtureImageHeight); +ihdr[8] = 8; +ihdr[9] = 2; // 8-bit RGB, no interlacing. +const rowLength = fixtureImageWidth * 3 + 1; +const pixels = new Uint8Array(rowLength * fixtureImageHeight); +for (let y = 0; y < fixtureImageHeight; y++) { + for (let x = 0; x < fixtureImageWidth; x++) { + const checker = (Math.floor(x / 100) + Math.floor(y / 100)) % 2; + let rgb = checker ? [196, 221, 229] : [231, 240, 244]; + if ((x - 930) ** 2 + (y - 170) ** 2 < 105 ** 2) rgb = [238, 175, 72]; + const ridge = 480 - 150 * Math.sin(x / 210); + if (y > ridge) rgb = checker ? [42, 112, 123] : [52, 135, 141]; + if (y > 650 + 55 * Math.cos(x / 140)) rgb = checker ? [25, 64, 83] : [34, 82, 100]; + pixels.set(rgb, y * rowLength + 1 + x * 3); + } +} +const parts = [ + new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", ihdr), + chunk("IDAT", new Uint8Array(deflateSync(pixels, { level: 9 }))), + chunk("IEND", new Uint8Array()), +]; +export const fixturePng = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); +let offset = 0; +for (const part of parts) { + fixturePng.set(part, offset); + offset += part.length; +} +if (fixturePng.length >= 256 * 1024) throw new Error("Synthetic PNG exceeds raster byte cap"); diff --git a/tools/web-ux/drive-fixture/server.ts b/tools/web-ux/drive-fixture/server.ts new file mode 100644 index 000000000..c77368c02 --- /dev/null +++ b/tools/web-ux/drive-fixture/server.ts @@ -0,0 +1,115 @@ +// Serves the real static production shell. Drive is an in-memory fake API; +// other routes reuse the shared Home/settings fixtures via a loopback proxy. +import { dirname, fromFileUrl, join } from "jsr:@std/path@1.1.4"; +import { createDriveFixture } from "./api.ts"; +import { cleanupPlan, workerCatalog, workerSnapshot } from "./workers.ts"; +const port = Number(Deno.args[0]); +const buildRoot = Deno.args[1]; +if (!Number.isInteger(port) || !buildRoot) { + throw new Error("usage: server "); +} +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); +const homePort = (listener.addr as Deno.NetAddr).port; +listener.close(); +const home = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"), + String(homePort), + buildRoot, + ], + stdout: "null", + stderr: "inherit", +}).spawn(); +let stopped = false; +function cleanup() { + if (!stopped) { + stopped = true; + try { + home.kill("SIGTERM"); + } catch { /* already exited */ } + } +} +Deno.addSignalListener("SIGTERM", () => { + cleanup(); + Deno.exit(0); +}); +Deno.addSignalListener("SIGINT", () => { + cleanup(); + Deno.exit(0); +}); +const fixture = createDriveFixture(); +const deadline = Date.now() + 10000; +while (true) { + try { + const response = await fetch(`http://127.0.0.1:${homePort}/health`); + await response.body?.cancel(); + if (response.ok) break; + } catch { /* startup */ } + if (Date.now() > deadline) { + cleanup(); + throw new Error("shared Home fixture failed to start"); + } + await new Promise((resolve) => setTimeout(resolve, 30)); +} +Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") { + return Response.json({ authority: "fixture, not actual Backend", ready: true }); + } + const drive = await fixture.handler(request); + if (drive) return drive; + const api = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/); + if (api?.[2] === "/workers") { + if (api[1] === "workers-error") { + return Response.json({ error: "Worker fixture unavailable" }, { status: 503 }); + } + return Response.json(workerCatalog(decodeURIComponent(api[1]))); + } + if (api?.[2].match(/^\/runtimes\/[^/]+\/cleanup-plan$/)) { + return Response.json(cleanupPlan(decodeURIComponent(api[1]), api[2].split("/")[2])); + } + // Home fixture keeps the production WebSocket subscription flow intact. + if (api?.[2] === "/protocol/ws" && request.headers.get("upgrade") === "websocket") { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame?.message?.method !== "subscribe_events") return; + socket.send(JSON.stringify({ + protocol_version: 2, + frame: "response", + message: { + result: "subscribed", + payload: { + request_id: frame.message.params.request_id, + subscription_id: "fixture-workers", + selector: { topic: "workspace_workers" }, + snapshot: { + topic: "workers", + data: { workers: workerSnapshot(decodeURIComponent(api[1])) }, + }, + }, + }, + })); + }; + return response; + } + const proxy = new URL(url.pathname + url.search, `http://127.0.0.1:${homePort}`); + const response = await fetch(proxy, { + method: request.method, + headers: request.headers, + body: request.method === "GET" || request.method === "HEAD" + ? undefined + : await request.arrayBuffer(), + redirect: "manual", + }); + if (api?.[2] === "/workspace") { + const body = await response.json(); + body.display_name ??= `Drive ${decodeURIComponent(api[1])}`; + return Response.json(body, { status: response.status }); + } + return response; +}); diff --git a/tools/web-ux/drive-fixture/workers.ts b/tools/web-ux/drive-fixture/workers.ts new file mode 100644 index 000000000..7ec1504b5 --- /dev/null +++ b/tools/web-ux/drive-fixture/workers.ts @@ -0,0 +1,74 @@ +// One synthetic authoritative Worker catalog. Both REST and protocol projections +// derive from these same records; no grant-only Worker list or production override. +import type { WorkerListResponse } from "../../../web/workspace/src/lib/generated/worker-launch-api.ts"; +import type { SubscriptionWorker } from "../../../web/workspace/src/lib/generated/protocol.ts"; +import type { RuntimeCleanupPlanResponse } from "../../../web/workspace/src/lib/generated/runtime-api.ts"; +export function workerCatalog(workspaceId: string): WorkerListResponse { + const representative = ["home-owner", "home-member", "home-long", "home-error"].includes( + workspaceId, + ); + return { + workspace_id: workspaceId, + limit: 100, + source: "fixture", + diagnostics: [], + items: representative + ? ["a", "b"].map((suffix, index) => ({ + runtime_id: workspaceId === "home-long" + ? `fixture-runtime-${"r".repeat(100)}` + : "fixture-runtime", + worker_id: workspaceId === "home-long" + ? `fixture-worker-${suffix}-${"w".repeat(100)}` + : `fixture-worker-${suffix}`, + resource_key: `W-${801 + index}`, + host_id: "fixture-host", + display_name: workspaceId === "home-long" + ? "Documentation and distributed development Worker — 国際化されたドキュメントを整理する長い表示名 — " + .repeat(2) + : index === 0 + ? "Documentation Worker" + : "画像・資料を整理する Worker — 長い名前", + label: `Fixture Worker ${suffix}`, + profile: "default", + workspace: { workspace_id: workspaceId, visibility: "workspace", identity: workspaceId }, + availability: "observed", + state: "idle", + worker_state: { last_command_id: 0, state: { kind: "idle" } }, + pinned: false, + retention_state: "active", + implementation: { kind: "builtin", display_hint: "fixture" }, + tags: [], + diagnostics: [], + workdir_attachments: [], + last_seen_at: "2026-01-02T00:00:00Z", + })) + : [], + }; +} +export function workerSnapshot(workspaceId: string): SubscriptionWorker[] { + return workerCatalog(workspaceId).items.map((worker) => ({ + worker_id: worker.worker_id, + runtime_id: worker.runtime_id, + resource_key: worker.resource_key, + availability: worker.availability, + state: "idle", + worker_state: worker.worker_state, + workspace_id: workspaceId, + display_name: worker.display_name, + profile: worker.profile, + has_running_internal_workers: false, + workdir_attachments: [], + })); +} +export function cleanupPlan(workspaceId: string, runtimeId: string): RuntimeCleanupPlanResponse { + return { + workspace_id: workspaceId, + runtime_id: runtimeId, + diagnostics: [], + digest: "fixture-cleanup", + generated_at: "2026-01-02T00:00:00Z", + revision: "1", + workers: [], + workdirs: [], + }; +} diff --git a/tools/web-ux/drive-grants/visual-review.ts b/tools/web-ux/drive-grants/visual-review.ts new file mode 100644 index 000000000..da580dc37 --- /dev/null +++ b/tools/web-ux/drive-grants/visual-review.ts @@ -0,0 +1,347 @@ +// Development-only visual evidence over the real Workers route and loopback +// HTTP fixtures. No Backend/Runtime/account/grant state is mutated. +// Run from root: deno run --config tools/web-ux/deno.json -A tools/web-ux/drive-grants/visual-review.ts after +import { chromium } from "npm:playwright@1.59.1"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +const root = new URL("../../../", import.meta.url).pathname; +const run = Deno.args[0] ?? "after"; +const before = run === "before"; +const out = `${root}target/web-ux/t724-drive-grants/${run}`; +const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); +const port = (listener.addr as Deno.NetAddr).port; +listener.close(); +const baseUrl = `http://127.0.0.1:${port}`; +const processes = await startOwnedProcesses( + [{ + id: "vite", + command: "deno", + args: ["task", "dev", "--host", "127.0.0.1", "--port", String(port)], + cwd: `${root}web/workspace`, + readyUrl: baseUrl, + }], + import.meta.url, + `${out}/logs`, + [], +); +const worker = { + runtime_id: "runtime-a", + worker_id: "worker-a", + resource_key: "W-1", + host_id: "host-a", + display_name: "Documentation Worker", + label: "Worker", + profile: "builtin:coder", + tags: [], + workspace: { + visibility: "workspace", + identity: "space", + workspace_id: "space", + }, + availability: "observed", + state: "stopped", + pinned: false, + retention_state: "normal", + implementation: { kind: "runtime", display_hint: "Runtime Worker" }, + workdir_attachments: [], + diagnostics: [], + restore_observation_token: "observation-1", +}; +const grant = { + grant_id: "1", + workspace_id: "space", + runtime_id: "runtime-a", + worker_id: "worker-a", + access: "read_only", + revoked: false, + created_by: "fixture-owner", + created_at: "2026-10-09", + revoked_by: null, + revoked_at: null, +}; +const browser = await chromium.launch({ headless: true }); +const longWorker = { + ...worker, + runtime_id: `runtime-${"r".repeat(140)}`, + worker_id: `worker-${"w".repeat(140)}`, + display_name: + "Documentation and distributed development Worker with a long display name for international documentation review", +}; +const captures: unknown[] = []; +try { + for (const persona of ["owner", "non-owner"]) { + for (const theme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme: theme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => + errors.push( + `${request.method()} ${ + new URL(request.url()).pathname + }: ${request.failure()?.errorText}`, + )); + let state = "active"; + await page.routeWebSocket("**/api/w/space/protocol/ws", () => {}); + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()), path = url.pathname; + if (!path.startsWith("/api/")) { + return await route.continue(); + } + const owner = persona === "owner"; + let body: unknown; + if (path === "/api/workspaces") { + body = [{ + workspace_id: "space", + display_name: "Drive review", + owner_account_id: "fixture-owner", + state: "active", + created_at: "2026-01-01", + updated_at: "2026-01-01", + }]; + } else if (path.endsWith("/workspace")) { + body = { + workspace_id: "space", + display_name: "Drive review", + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: baseUrl, + public_base_url: baseUrl, + cookie_name: "fixture", + }, + }, + permissions: { + manage_repositories: owner, + manage_secrets: owner, + manage_runtimes: owner, + delete_workspace: owner, + }, + extension_points: { + store: "fixture", + event_stream: { + status: "ready", + note: "fixture", + diagnostics: [], + }, + host_worker_bridge: { + status: "ready", + note: "fixture", + diagnostics: [], + }, + companion_console: { + status: "ready", + note: "fixture", + diagnostics: [], + }, + }, + }; + } else if (path.endsWith("/workers")) { + body = { + workspace_id: "space", + limit: 100, + source: "fixture", + diagnostics: [], + items: [state === "long" ? longWorker : worker], + }; + } else if (path.endsWith("/repositories")) { + body = { + workspace_id: "space", + items: [], + source: "fixture", + diagnostics: [], + }; + } else if (path.endsWith("/cleanup-plan")) { + body = { + workspace_id: "space", + generated_at: "2026-10-09", + runtime_id: "runtime-a", + revision: "plan-1", + digest: "digest-1", + diagnostics: [], + workdirs: [], + workers: [], + }; + } else if (path.endsWith("/drive/grants")) { + if (state === "loading") { + await new Promise((resolve) => { + release = resolve; + }); + } + if (state === "error") { + await route.fulfill({ + status: 503, + json: { + code: "storage_unavailable", + classification: "not_committed", + message: `Drive storage unavailable. ${ + "A long diagnostic requiring readable wrapping and an explicit refresh action. " + .repeat(12) + }`, + }, + }); + return; + } + body = { + grants: state === "empty" ? [] : [{ + ...grant, + runtime_id: state === "long" ? longWorker.runtime_id : worker.runtime_id, + worker_id: state === "long" ? longWorker.worker_id : worker.worker_id, + }], + next_after: null, + }; + } else if (path.endsWith("/working-directories")) { + body = { workspace_id: "space", items: [], diagnostics: [] }; + } else { + errors.push(`Unmatched fixture ${path}`); + body = {}; + } + await route.fulfill({ json: body }); + }); + let release: (() => void) | undefined; + const response = await page.goto(`${baseUrl}/w/space/workers`); + await page.getByRole("heading", { name: "Workers", exact: true }) + .waitFor().catch(async (cause) => { + console.log(errors, await page.locator("body").innerText()); + throw cause; + }); + await page.getByText("Documentation Worker", { exact: true }).first() + .waitFor().catch(async (cause) => { + console.log(errors, await page.locator("body").innerText()); + throw cause; + }); + async function capture(name: string) { + await Deno.mkdir(out, { recursive: true }); + const filename = `${persona}-${theme}-${width}-${name}.png`; + const screenshot = await page.screenshot({ + path: `${out}/${filename}`, + fullPage: true, + }); + if (!before && persona === "owner") { + const clipped = await page.locator(".drive-grants").evaluate(( + element, + ) => + [...element.querySelectorAll("button, select, summary")].some( + (control) => { + const box = control.getBoundingClientRect(); + return box.left < 0 || + box.right > element.ownerDocument.defaultView!.innerWidth; + }, + ) + ); + if (clipped) { + throw new Error( + `Grant control clipped: ${persona} ${theme} ${width} ${name}`, + ); + } + await page.locator(".drive-grants").screenshot({ + path: `${out}/grants-${filename}`, + }); + } + captures.push({ + persona, + theme, + width, + state: name, + path: filename, + sha256: await sha256(screenshot), + documentStatus: response?.status(), + errors: [...errors], + visibleErrors: await page.locator("[role=alert]") + .allTextContents(), + overflow: await page.evaluate("document.documentElement.scrollWidth > innerWidth"), + accessibility: await page.locator("main").ariaSnapshot(), + }); + } + if (before || !ownerPersona(persona)) await capture("workers"); + else { + await page.getByRole("combobox", { name: "Worker", exact: true }) + .selectOption(JSON.stringify(["runtime-a", "worker-a"])); + await page.getByRole("button", { name: "Revoke Drive grant 1" }) + .waitFor(); + await capture("active"); + state = "empty"; + await page.getByRole("button", { name: "Refresh grants" }).click(); + await page.getByText("No active Drive grants for this Worker.") + .waitFor(); + await page.getByRole("button", { name: "Add Drive grant" }).click(); + await capture("empty-form"); + state = "error"; + await page.getByRole("button", { name: "Refresh grants" }).click(); + await page.getByRole("alert").waitFor(); + await capture("error"); + state = "loading"; + await page.getByRole("button", { name: "Refresh grants" }).click(); + await page.getByText("Loading all grant pages…").waitFor(); + await capture("loading"); + release?.(); + } + state = "long"; + await page.reload(); + await page.getByText(longWorker.display_name, { exact: true }).first() + .waitFor(); + if (!before && persona === "owner") { + await page.getByRole("combobox", { name: "Worker", exact: true }) + .selectOption( + JSON.stringify([longWorker.runtime_id, longWorker.worker_id]), + ); + await page.getByRole("button", { name: "Revoke Drive grant 1" }) + .waitFor(); + await page.getByText("Worker identity", { exact: true }).click(); + } + await capture("long-values"); + } finally { + await context.close(); + } + } + } + } + const pageSource = await Deno.readFile( + `${root}web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte`, + ); + const componentSource = await Deno.readFile( + `${root}web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.svelte`, + ); + const apiSource = await Deno.readFile( + `${root}web/workspace/src/lib/workspace/drive-grants/api.ts`, + ); + await Deno.writeTextFile( + `${out}/review-context.json`, + JSON.stringify( + { + scenario: "real Workers route, loopback authoritative API projections", + run, + browserVersion: browser.version(), + workersPageSha256: await sha256(pageSource), + componentSha256: await sha256(componentSource), + apiSha256: await sha256(apiSource), + captures, + }, + null, + 2, + ), + ); + console.log(`Visual evidence: ${out}`); +} finally { + await browser.close(); + await stopOwnedProcesses(processes); +} +function ownerPersona(persona: string) { + return persona === "owner"; +} +async function sha256(bytes: Uint8Array) { + return [...new Uint8Array(await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer))] + .map(( + byte, + ) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/tools/web-ux/scenarios/workdir-removal-dark.json b/tools/web-ux/scenarios/workdir-removal-dark.json new file mode 100644 index 000000000..b689c72d1 --- /dev/null +++ b/tools/web-ux/scenarios/workdir-removal-dark.json @@ -0,0 +1,117 @@ +{ + "schemaVersion": 1, + "id": "workdir-removal-dark", + "title": "Ordinary Workdir removal and guarded retry", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic permission projections", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "owner", + "label": "Owner removal", + "path": "/w/home-owner/settings/runtimes/fixture-runtime/workdirs", + "goal": "Remove a failed-cleanup Workdir using ordinary retry without enabling dirty or occupied removal.", + "dataState": "Pending/unknown retry plus dirty and occupied guards. First attempt finds a mount.", + "ready": { + "kind": "selector", + "selector": "main table tbody tr" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Current inventory" + }, + { + "id": "failed-attempt", + "label": "Removal blocked by mount", + "interaction": [ + { + "action": "click", + "selector": "button[aria-label='Delete retry-dir']" + } + ], + "ready": { + "kind": "selector", + "selector": "[role='alert']" + } + } + ] + }, + { + "id": "member", + "label": "No Runtime management permission", + "path": "/w/home-member/settings/runtimes/fixture-runtime/workdirs", + "goal": "Read inventory without owner-only removal controls.", + "dataState": "Management permission false.", + "ready": { + "kind": "selector", + "selector": "main table tbody tr" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read only inventory" + } + ] + }, + { + "id": "empty", + "label": "Empty inventory", + "path": "/w/home-empty/settings/runtimes/fixture-runtime/workdirs", + "goal": "Recognize no remaining Workdirs.", + "dataState": "Empty list.", + "ready": { + "kind": "selector", + "selector": "main:has-text('No workdirs')" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Empty inventory" + } + ] + } + ], + "processes": [ + { + "id": "workdir-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/tools/web-ux/scenarios/workdir-removal-light.json b/tools/web-ux/scenarios/workdir-removal-light.json new file mode 100644 index 000000000..2fd178d19 --- /dev/null +++ b/tools/web-ux/scenarios/workdir-removal-light.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": 1, + "id": "workdir-removal-light", + "title": "Ordinary Workdir removal and guarded retry", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic permission projections", + "auth": { "kind": "anonymous" } + } + ], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 } + ], + "routes": [ + { + "id": "owner", + "label": "Owner removal", + "path": "/w/home-owner/settings/runtimes/fixture-runtime/workdirs", + "goal": "Remove a failed-cleanup Workdir using ordinary retry without enabling dirty or occupied removal.", + "dataState": "Pending/unknown retry plus dirty and occupied guards. First attempt finds a mount.", + "ready": { "kind": "selector", "selector": "main table tbody tr" }, + "capturePoints": [ + { "id": "initial", "label": "Current inventory" }, + { + "id": "failed-attempt", + "label": "Removal blocked by mount", + "interaction": [ + { + "action": "click", + "selector": "button[aria-label='Delete retry-dir']" + } + ], + "ready": { "kind": "selector", "selector": "[role='alert']" } + } + ] + }, + { + "id": "member", + "label": "No Runtime management permission", + "path": "/w/home-member/settings/runtimes/fixture-runtime/workdirs", + "goal": "Read inventory without owner-only removal controls.", + "dataState": "Management permission false.", + "ready": { "kind": "selector", "selector": "main table tbody tr" }, + "capturePoints": [{ "id": "initial", "label": "Read only inventory" }] + }, + { + "id": "empty", + "label": "Empty inventory", + "path": "/w/home-empty/settings/runtimes/fixture-runtime/workdirs", + "goal": "Recognize no remaining Workdirs.", + "dataState": "Empty list.", + "ready": { + "kind": "selector", + "selector": "main:has-text('No workdirs')" + }, + "capturePoints": [{ "id": "initial", "label": "Empty inventory" }] + } + ], + "processes": [ + { + "id": "workdir-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index ac4ca6e82..73ab2ea92 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,9 +6,11 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", + "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", + "test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored", "build": "deno run -A npm:vite@8.3.2 build", "preview": "deno run -A npm:vite@8.3.2 preview", "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index dbfedcfb7..15619fe84 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -7,6 +7,7 @@ import type { WorkingDirectoryDetailResponse, WorkingDirectoryListResponse, WorkingDirectoryOccupancy, + WorkingDirectoryRemovalResponse, WorkingDirectorySource, WorkingDirectorySummary, } from "../../generated/workdir-api"; @@ -63,6 +64,40 @@ export function parseWorkingDirectoryListResponse( }; } +export function parseWorkingDirectoryRemovalResponse( + value: unknown, +): WorkingDirectoryRemovalResponse { + const record = exactRecord( + value, + new Set([ + "working_directory_id", + "disposition", + "retryable", + "failure_category", + ]), + "Workdir removal response", + ); + const working_directory_id = stringField(record, "working_directory_id"); + const category = record.failure_category; + if ( + working_directory_id.length > 128 || + (category != null && + (typeof category !== "string" || category.length > 128)) + ) { + throw new Error("Invalid Workdir removal response"); + } + return { + working_directory_id, + disposition: enumField(record, "disposition", [ + "removed", + "retained", + "attention_required", + ]), + retryable: booleanField(record, "retryable", "Workdir removal response"), + failure_category: category as string | null | undefined, + }; +} + export function parseWorkingDirectoryDetailResponse( value: unknown, ): WorkingDirectoryDetailResponse { @@ -178,13 +213,26 @@ export function parseWorkingDirectorySummary( function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { const source = exactRecord( value, - new Set(["kind", "repository_key", "grant_id", "grant_permissions", "access", "content_path", "purpose"]), + new Set([ + "kind", + "repository_key", + "grant_id", + "grant_permissions", + "access", + "content_path", + "purpose", + ]), "Workdir source", ); const kind = stringField(source, "kind"); if (kind === "workspace_config") { - if (source.repository_key !== undefined || source.grant_id !== undefined || source.grant_permissions !== undefined) { - throw new Error("Logical config source must not contain repository or External authority"); + if ( + source.repository_key !== undefined || source.grant_id !== undefined || + source.grant_permissions !== undefined + ) { + throw new Error( + "Logical config source must not contain repository or External authority", + ); } const access = stringField(source, "access"); if (access !== "read_only" && access !== "read_write") { @@ -194,10 +242,20 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { if (content_path !== "/workspace-config") { throw new Error("Logical config must reference its WIP content entrance"); } - return { kind, access, content_path, purpose: stringField(source, "purpose") }; + return { + kind, + access, + content_path, + purpose: stringField(source, "purpose"), + }; } - if (source.access !== undefined || source.content_path !== undefined || source.purpose !== undefined) { - throw new Error("Filesystem Workdir source must not contain logical config metadata"); + if ( + source.access !== undefined || source.content_path !== undefined || + source.purpose !== undefined + ) { + throw new Error( + "Filesystem Workdir source must not contain logical config metadata", + ); } if (kind === "repository") { if ( diff --git a/web/workspace/src/lib/workspace/drive-grants/README.md b/web/workspace/src/lib/workspace/drive-grants/README.md new file mode 100644 index 000000000..ca97c0670 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-grants/README.md @@ -0,0 +1,60 @@ +# Worker Drive grant controls + +`WorkerDriveGrants.svelte` is embedded in the existing Workers page. The page supplies its current `workspaceWorkersStore().catalogWorkers` projection; this component neither discovers Workers nor maintains a registry. + +## Integration interface + +```ts +{ + workspaceId: string; + workers: Pick[]; + canManage: boolean; + workersReady?: boolean; // defaults to true for standalone use +} +``` + +The page maps `canManage` to `workspace.permissions.manage_runtimes` (the current Backend owner settings permission) and `workersReady` to `catalogReady && !catalogRefreshing`. Non-owners have no grant surface or grant requests. Backend owner authorization remains authoritative on every request. Profile Drive tool enablement is separate and is not edited here. + +`api.ts` is the owner-controlled grant transport adapter using generated `drive-api.ts` DTOs. It keeps grant administration separate from ordinary content access: + +- `listDriveGrants(workspaceId): Promise` — fetch **all** grant pages (200/page, bounded to 100 pages); reject incomplete, foreign-Workspace, duplicate, unordered or cyclic results instead of silently truncating. +- `createDriveGrant(workspaceId, DriveGrantCreateRequest): Promise`. +- `revokeDriveGrant(workspaceId, DriveGrantResponse): Promise`. +- `DriveGrantError` with `outcome: "not_committed" | "unknown"`. +- Generated `DriveAccess` / `DriveGrantResponse` type exports. + +Mutation success must validate Workspace/Runtime/Worker identity and the requested resulting state. HTTP bodies are bounded; transport loss, malformed success and unclassified failures are unknown mutation outcomes. No Worker credentials or transport identity headers are sent; requests use current browser same-origin authentication and bypass caching. + +Workspace, selected Worker, catalog membership/readiness, permission and component lifetime fence asynchronous results. Workspace switches clear selection; unchanged catalog identities do not clear pending mutations. Active grants match **both** Runtime and Worker IDs. An existing active grant must be revoked before another access level is created. Unknown outcomes remove actionable state until an explicit authority refresh, never automatic mutation retry. + +## Narrow validation + +From `web/workspace`: + +```sh +deno task test:component src/lib/workspace/drive-grants/WorkerDriveGrants.browser.spec.ts 'src/routes/w/[workspaceId]/workers/workers-page.browser.spec.ts' 'src/routes/w/[workspaceId]/workers/workers-restore-integration.browser.spec.ts' +deno run -A npm:svelte-check@4.7.6 --tsconfig ./src/lib/workspace/drive-grants/tsconfig.check.json +# Full integration check (also includes sibling-owned work): +deno task check +``` + +Recorded result: 21 grant tests plus 29 existing Workers tests passed. Final parent integration `deno task check` reported zero errors/warnings, and the complete component suite passed 301 tests in 32 files. Intermediate test-only `ByRoleOptions` errors were corrected before final validation. + +The component tests exercise actual rendered controls with HTTP-boundary fixtures, including pagination, same Worker ID on different Runtimes, owner gating, scope switches, removal, single-submit behavior, read-only/read-write creation, revocation, denial and unknown outcomes. The existing Workers tests cover retention/restore/catalog regressions. + +## Visual evidence + +The development-only `tools/web-ux/drive-grants/visual-review.ts` runner uses the real Workers route and application shell with loopback browser API fixtures. It creates no real Backend, Runtime, Worker, account or grant state. It lives outside production Web source and is independently Deno-checked: + +```sh +# From repository root, in the dev shell: +deno run --check --config tools/web-ux/deno.json -A tools/web-ux/drive-grants/visual-review.ts after +``` + +`before` mode must run against the original Workers page source. For the recorded baseline, the original page was temporarily restored from the read-only Git source and the implemented page restored in `finally`; Git/index/history were not changed. + +Evidence: `target/web-ux/t724-drive-grants/{before,after}/review-context.json` and sibling PNGs. Contexts record screenshot hashes, Workers page source hash, browser version, accessibility, visible/browser/request errors and document overflow. The after context also records component/API source hashes and the runner checks grant controls for viewport clipping. + +Reviewed owner/non-owner, light/dark, 1440/768/390 px; active, empty/create form, loading, deliberately failed list, long labels/identifiers/errors. The before bundle has 24 captures and the after bundle has 42. `compare-{theme}-{width}.png` and `states-{theme}-{width}.png` contact sheets in the same artifact root were opened by the implementer, along with individual captures. + +Finding fixed: the pre-existing Worker table widened the page's grid track and clipped new grant controls. The page now constrains its grid and gives the table a named keyboard-accessible local scroll region. Long native-select labels have a full identity disclosure. No unexpected browser errors or document overflow were observed; intentional HTTP 503 console errors belong to the error fixture (and remain in the cumulative diagnostics of its subsequent loading capture). Other pre-existing Worker table presentation is unchanged. diff --git a/web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.browser.spec.ts b/web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.browser.spec.ts new file mode 100644 index 000000000..f2eae411b --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.browser.spec.ts @@ -0,0 +1,471 @@ +// @vitest-environment happy-dom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { tick } from "svelte"; +import WorkerDriveGrants from "./WorkerDriveGrants.svelte"; +import { + createDriveGrant, + listDriveGrants, + parseDriveGrantListResponse, +} from "./api.ts"; +import type { DriveGrantResponse } from "#lib/generated/drive-api.ts"; + +const workers = [ + { + runtime_id: "runtime-a", + worker_id: "worker", + display_name: "Alpha", + label: "Alpha", + }, + { + runtime_id: "runtime-b", + worker_id: "worker", + display_name: "Beta", + label: "Beta", + }, +]; +function grant( + overrides: Partial = {}, +): DriveGrantResponse { + return { + grant_id: "1", + workspace_id: "space", + runtime_id: "runtime-a", + worker_id: "worker", + access: "read_only", + revoked: false, + created_by: "owner", + created_at: "now", + revoked_by: null, + revoked_at: null, + ...overrides, + }; +} +const page = ( + grants: DriveGrantResponse[] = [], + next_after: string | null = null, +) => Response.json({ grants, next_after }); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +async function select(index = 0) { + const combo = screen.getByRole("combobox", { + name: "Worker", + }) as HTMLSelectElement; + await fireEvent.change(combo, { + target: { + value: JSON.stringify([ + workers[index].runtime_id, + workers[index].worker_id, + ]), + }, + }); + expect(combo.value).toBe( + JSON.stringify([workers[index].runtime_id, workers[index].worker_id]), + ); +} +const props = { workspaceId: "space", workers, canManage: true }; +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +test("non-owner has no grant surface and makes no grant requests", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + render(WorkerDriveGrants, { ...props, canManage: false }); + await tick(); + expect(screen.queryByRole("region", { name: "Worker Drive grants" })) + .toBeNull(); + expect(fetch).not.toHaveBeenCalled(); +}); + +test("catalog readiness gates selection and removed Workers cannot retain grants", async () => { + const fetch = vi.fn(async () => page([grant()])); + vi.stubGlobal("fetch", fetch); + const view = render(WorkerDriveGrants, { ...props, workersReady: false }); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + await view.rerender({ workersReady: true }); + await select(); + expect(await screen.findByRole("button", { name: "Revoke Drive grant 1" })) + .toBeTruthy(); + await view.rerender({ workers: [workers[1]] }); + expect(screen.queryByRole("button", { name: /Revoke/ })).toBeNull(); + expect( + (screen.getByRole("combobox", { name: "Worker" }) as HTMLSelectElement) + .value, + ).toBe(""); +}); + +test("all pages are loaded and active grants match both runtime and Worker identities", async () => { + const second = deferred(); + const fetch = vi.fn().mockResolvedValueOnce( + page([grant({ runtime_id: "runtime-b" })], "1"), + ).mockReturnValueOnce(second.promise); + vi.stubGlobal("fetch", fetch); + render(WorkerDriveGrants, props); + await select(); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(fetch.mock.calls[1][0]).toBe( + "/api/w/space/drive/grants?limit=200&after=1", + ); + expect(screen.queryByText("No active Drive grants for this Worker.")) + .toBeNull(); + second.resolve( + page([ + grant({ grant_id: "2" }), + grant({ grant_id: "3", worker_id: "another" }), + grant({ grant_id: "4", revoked: true }), + ]), + ); + expect(await screen.findByRole("button", { name: "Revoke Drive grant 2" })) + .toBeTruthy(); + expect(screen.getAllByRole("button", { name: /Revoke/ })).toHaveLength(1); + expect(screen.queryByRole("button", { name: "Add Drive grant" })).toBeNull(); +}); + +test.each(["read_only", "read_write"] as const)( + "create %s and revoke use current Backend authentication and confirmed state", + async (access) => { + const created = grant({ access }); + const fetch = vi.fn().mockResolvedValueOnce(page()).mockResolvedValueOnce( + Response.json(created), + ).mockResolvedValueOnce(page([created])).mockResolvedValueOnce( + Response.json({ + ...created, + revoked: true, + revoked_at: "now", + revoked_by: "owner", + }), + ).mockResolvedValueOnce(page()); + vi.stubGlobal("fetch", fetch); + render(WorkerDriveGrants, props); + await select(); + await fireEvent.click( + await screen.findByRole("button", { name: "Add Drive grant" }), + ); + await fireEvent.change(screen.getByRole("combobox", { name: "Access" }), { + target: { value: access }, + }); + await fireEvent.click( + screen.getByRole("button", { name: "Create Drive grant" }), + ); + const revoke = await screen.findByRole("button", { + name: "Revoke Drive grant 1", + }); + await waitFor(() => + expect((revoke as HTMLButtonElement).disabled).toBe(false) + ); + expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ + runtime_id: "runtime-a", + worker_id: "worker", + access, + }); + expect(fetch.mock.calls[1][1].credentials).toBe("same-origin"); + expect(fetch.mock.calls[1][1].headers).toEqual({ + "content-type": "application/json", + }); + await fireEvent.click(revoke); + await waitFor(() => + expect(screen.queryByRole("button", { name: /Revoke/ })).toBeNull() + ); + expect(fetch.mock.calls[3][0]).toBe("/api/w/space/drive/grants/1"); + expect(fetch.mock.calls[3][1].method).toBe("DELETE"); + }, +); + +test.each(["typed unknown", "transport loss", "malformed success"])( + "%s blocks mutation retry and offers only an explicit authority refresh", + async (kind) => { + const fetch = vi.fn().mockResolvedValueOnce(page()); + if (kind === "transport loss") { + fetch.mockRejectedValueOnce(new Error("lost")); + } else {fetch.mockResolvedValueOnce( + kind === "typed unknown" + ? Response.json({ + code: "outcome_unknown", + classification: "unknown", + message: "unknown", + }, { status: 503 }) + : Response.json({}), + );} + fetch.mockResolvedValueOnce(page([grant()])); + vi.stubGlobal("fetch", fetch); + render(WorkerDriveGrants, props); + await select(); + await fireEvent.click( + await screen.findByRole("button", { name: "Add Drive grant" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Create Drive grant" }), + ); + expect((await screen.findByRole("alert")).textContent).toContain( + "outcome unknown", + ); + expect(screen.queryByRole("button", { name: "Create Drive grant" })) + .toBeNull(); + expect(screen.queryByText("No active Drive grants for this Worker.")) + .toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + await fireEvent.click( + screen.getByRole("button", { name: "Refresh grants" }), + ); + expect(await screen.findByRole("button", { name: "Revoke Drive grant 1" })) + .toBeTruthy(); + expect(fetch.mock.calls.filter((call) => call[1]?.method === "POST")) + .toHaveLength(1); + }, +); + +test("typed denial is not reported as an unknown or successful mutation", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce(page()).mockResolvedValueOnce( + Response.json({ + code: "denied", + classification: "not_committed", + message: "Drive access denied", + }, { status: 403 }), + ), + ); + render(WorkerDriveGrants, props); + await select(); + await fireEvent.click( + await screen.findByRole("button", { name: "Add Drive grant" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Create Drive grant" }), + ); + expect((await screen.findByRole("alert")).textContent).toBe( + "Drive access denied", + ); + expect(screen.queryByText("Drive grant created.")).toBeNull(); +}); + +test.each(["Worker", "Workspace", "permission", "removed Worker"])( + "late mutation response cannot affect a changed %s scope", + async (change) => { + const pending = deferred(); + const fetch = vi.fn().mockResolvedValueOnce(page()).mockReturnValueOnce( + pending.promise, + ).mockResolvedValue(page()); + vi.stubGlobal("fetch", fetch); + const view = render(WorkerDriveGrants, props); + await select(); + await fireEvent.click( + await screen.findByRole("button", { name: "Add Drive grant" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Create Drive grant" }), + ); + if (change === "Worker") await select(1); + if (change === "Workspace") await view.rerender({ workspaceId: "other" }); + if (change === "permission") await view.rerender({ canManage: false }); + if (change === "removed Worker") { + await view.rerender({ + workers: [workers[1]], + }); + } + pending.resolve(Response.json(grant())); + await pending.promise; + await tick(); + await tick(); + expect(screen.queryByText("Drive grant created.")).toBeNull(); + expect(screen.queryByRole("button", { name: "Revoke Drive grant 1" })) + .toBeNull(); + }, +); + +test("late list response cannot replace the selected Worker state", async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValueOnce( + page([grant({ runtime_id: "runtime-b", grant_id: "2" })]), + ), + ); + render(WorkerDriveGrants, props); + await select(); + await select(1); + expect(await screen.findByRole("button", { name: "Revoke Drive grant 2" })) + .toBeTruthy(); + pending.resolve(page([grant()])); + await pending.promise; + await tick(); + await tick(); + expect(screen.queryByRole("button", { name: "Revoke Drive grant 1" })) + .toBeNull(); + expect(screen.getByRole("button", { name: "Revoke Drive grant 2" })) + .toBeTruthy(); +}); + +test("unchanged catalog identities do not clear an in-flight mutation", async () => { + const pending = deferred(); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce(page()).mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(page([grant()])), + ); + const view = render(WorkerDriveGrants, props); + await select(); + await fireEvent.click( + await screen.findByRole("button", { name: "Add Drive grant" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Create Drive grant" }), + ); + await view.rerender({ + workers: workers.map((worker) => ({ + ...worker, + display_name: `${worker.label} updated`, + })), + }); + expect( + (screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + pending.resolve(Response.json(grant())); + expect(await screen.findByText("Drive grant created.")).toBeTruthy(); +}); + +test("pending create accepts only one submission", async () => { + const pending = deferred(); + const fetch = vi.fn().mockResolvedValueOnce(page()).mockReturnValueOnce( + pending.promise, + ).mockResolvedValueOnce(page([grant()])); + vi.stubGlobal("fetch", fetch); + render(WorkerDriveGrants, props); + await select(); + await fireEvent.click( + await screen.findByRole("button", { name: "Add Drive grant" }), + ); + const button = screen.getByRole("button", { name: "Create Drive grant" }); + await fireEvent.click(button); + await fireEvent.submit(button.closest("form")!); + expect(fetch.mock.calls.filter((call) => call[1]?.method === "POST")) + .toHaveLength(1); + pending.resolve(Response.json(grant())); + expect(await screen.findByText("Drive grant created.")).toBeTruthy(); +}); + +test("an uncertain revoke cannot claim removal or permit a blind retry", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce(page([grant()])).mockRejectedValueOnce( + new Error("transport lost"), + ), + ); + render(WorkerDriveGrants, props); + await select(); + await fireEvent.click( + await screen.findByRole("button", { name: "Revoke Drive grant 1" }), + ); + expect((await screen.findByRole("alert")).textContent).toContain( + "outcome unknown", + ); + expect(screen.queryByText("Drive grant revoked.")).toBeNull(); + expect(screen.queryByRole("button", { name: /Revoke/ })).toBeNull(); +}); + +test("complete Backend Worker identifiers are available in a disclosure", async () => { + const item = { + ...workers[0], + runtime_id: `runtime-${"r".repeat(140)}`, + worker_id: `worker-${"w".repeat(140)}`, + display_name: "Long Worker name ".repeat(12), + }; + vi.stubGlobal("fetch", vi.fn(async () => page())); + render(WorkerDriveGrants, { ...props, workers: [item] }); + await fireEvent.change(screen.getByRole("combobox", { name: "Worker" }), { + target: { value: JSON.stringify([item.runtime_id, item.worker_id]) }, + }); + await screen.findByRole("button", { name: "Add Drive grant" }); + const identity = screen.getByText("Worker identity").closest("details"); + expect(screen.getByText(item.runtime_id).closest("details")).toBe(identity); + expect(screen.getByText(item.worker_id).closest("details")).toBe(identity); +}); + +test("parser rejects malformed access, missing revocation state and noncanonical grant cursors", () => { + for ( + const value of [ + { grants: [grant({ access: "admin" as never })], next_after: null }, + { grants: [{ ...grant(), revoked: undefined }], next_after: null }, + { grants: [], next_after: "01" }, + ] + ) expect(() => parseDriveGrantListResponse(value)).toThrow(); +}); + +test("foreign Workspace pages and cyclic cursors never become a complete grant list", async () => { + for ( + const result of [page([grant({ workspace_id: "other" })]), page([], "1")] + ) { + vi.stubGlobal("fetch", vi.fn(async () => result)); + await expect(listDriveGrants("space")).rejects.toThrow(); + } +}); + +test("successful response with wrong Worker identity is an unknown mutation outcome", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(grant({ runtime_id: "runtime-b" }))), + ); + await expect( + createDriveGrant("space", { + runtime_id: "runtime-a", + worker_id: "worker", + access: "read_only", + }), + ).rejects.toMatchObject({ outcome: "unknown" }); +}); + +test("Catalog readiness refresh does not unlock or abandon a pending grant on the same Worker", async () => { + const pending = deferred(); + let committed = false; + const fetch = vi.fn((_: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "POST") return pending.promise; + return Promise.resolve(page(committed ? [grant()] : [])); + }); + vi.stubGlobal("fetch", fetch); + const view = render(WorkerDriveGrants, { ...props, workersReady: true }); + await select(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Add Drive grant" })) + .toBeDefined() + ); + await fireEvent.click( + screen.getByRole("button", { name: "Add Drive grant" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Create Drive grant" }), + ); + await view.rerender({ ...props, workersReady: false }); + await view.rerender({ ...props, workersReady: true }); + await tick(); + const add = screen.queryByRole("button", { name: "Add Drive grant" }) as + | HTMLButtonElement + | null; + if (add) expect(add.disabled).toBe(true); + const saving = screen.getByRole("button", { + name: "Saving…", + }) as HTMLButtonElement; + expect(saving.disabled).toBe(true); + committed = true; + pending.resolve(Response.json(grant())); + await waitFor(() => + expect(screen.getByRole("button", { name: "Revoke Drive grant 1" })) + .toBeDefined() + ); + expect(fetch.mock.calls.filter(([, init]) => init?.method === "POST")) + .toHaveLength(1); +}); diff --git a/web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.svelte b/web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.svelte new file mode 100644 index 000000000..0bf37d87a --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-grants/WorkerDriveGrants.svelte @@ -0,0 +1,184 @@ + + +{#if canManage} +
+

Drive grants

+

Grants allow access to this Workspace’s Drive. Drive tool enablement in the Worker’s Profile is separate.

+ {#if !workersReady} +

Waiting for the current Worker catalog…

+ {:else if workers.length === 0} +

No Workers are available for a Drive grant.

+ {:else} + + {#if selectedWorker} + {#key identityScope} +
+ Worker identity +
+
Runtime
{selectedWorker.runtime_id}
+
Worker
{selectedWorker.worker_id}
+
+
+ {/key} +
+ + {#if ready && !unresolved && active.length === 0} + + {/if} +
+ {#if loading}

Loading all grant pages…

{/if} + {#if error}{/if} + {#if notice}

{notice}

{/if} + {#if ready && active.length === 0}

No active Drive grants for this Worker.

{/if} + {#if ready && active.length > 0} +

Revoke the current grant before changing access.

+
    + {#each active as grant (grant.grant_id)} +
  • + {grant.access === 'read_only' ? 'Read only' : 'Read and write'} Grant {grant.grant_id} + +
  • + {/each} +
+ {/if} + {#if showCreate && ready && !unresolved && active.length === 0} +
{ event.preventDefault(); void mutate(); }}> + + +
+ {/if} + {/if} + {/if} +
+{/if} + + diff --git a/web/workspace/src/lib/workspace/drive-grants/api.ts b/web/workspace/src/lib/workspace/drive-grants/api.ts new file mode 100644 index 000000000..cb6993638 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-grants/api.ts @@ -0,0 +1,227 @@ +import type { + DriveAccess, + DriveApiError, + DriveGrantCreateRequest, + DriveGrantListResponse, + DriveGrantResponse, +} from "#lib/generated/drive-api.ts"; +import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; + +// Owner-controlled grant transport, separate from ordinary Drive content access. +export type { DriveAccess, DriveGrantCreateRequest, DriveGrantResponse }; +export class DriveGrantError extends Error { + constructor(message: string, readonly outcome: "not_committed" | "unknown") { + super(message); + this.name = "DriveGrantError"; + } +} +function object( + value: unknown, + keys: readonly string[], +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid Drive grant response"); + } + if (Object.keys(value).some((key) => !keys.includes(key))) { + throw new Error("Unexpected Drive grant response field"); + } + return value as Record; +} +function text(value: unknown): string { + if (typeof value !== "string" || value.length > 4096) { + throw new Error("Invalid Drive grant string"); + } + return value; +} +function nullableText(value: unknown): string | null { + return value === null ? null : text(value); +} +function decimal(value: unknown): string { + const id = text(value); + if (!/^[1-9][0-9]*$/.test(id) || BigInt(id) > 9223372036854775807n) { + throw new Error("Invalid Drive grant ID"); + } + return id; +} +export function parseDriveGrantResponse(value: unknown): DriveGrantResponse { + const v = object(value, [ + "grant_id", + "workspace_id", + "runtime_id", + "worker_id", + "access", + "revoked", + "created_by", + "created_at", + "revoked_by", + "revoked_at", + ]); + if ( + (v.access !== "read_only" && v.access !== "read_write") || + typeof v.revoked !== "boolean" + ) throw new Error("Invalid Drive grant access or state"); + return { + grant_id: decimal(v.grant_id), + workspace_id: text(v.workspace_id), + runtime_id: text(v.runtime_id), + worker_id: text(v.worker_id), + access: v.access, + revoked: v.revoked, + created_by: text(v.created_by), + created_at: text(v.created_at), + revoked_by: nullableText(v.revoked_by), + revoked_at: nullableText(v.revoked_at), + }; +} +export function parseDriveGrantListResponse( + value: unknown, +): DriveGrantListResponse { + const v = object(value, ["grants", "next_after"]); + if (!Array.isArray(v.grants) || v.grants.length > 200) { + throw new Error("Invalid Drive grant page"); + } + return { + grants: v.grants.map(parseDriveGrantResponse), + next_after: v.next_after === null ? null : decimal(v.next_after), + }; +} +export function parseDriveGrantError(value: unknown): DriveApiError { + const v = object(value, ["code", "classification", "message"]); + const codes = [ + "denied", + "not_found", + "conflict", + "invalid", + "limit", + "storage_unavailable", + "outcome_unknown", + ] as const; + const code = codes.find((code) => code === v.code); + if ( + !code || + (v.classification !== "not_committed" && v.classification !== "unknown") || + (code === "outcome_unknown" && v.classification !== "unknown") + ) throw new Error("Invalid Drive error"); + return { code, classification: v.classification, message: text(v.message) }; +} +async function request( + workspaceId: string, + path: string, + init: RequestInit, + parse: (value: unknown) => T, + mutation = false, +): Promise { + try { + // Browser same-origin authentication only; Backend rechecks current authority. + const response = await fetch(workspaceApiPath(workspaceId, path), { + ...init, + credentials: "same-origin", + cache: "no-store", + redirect: "error", + }); + const payload = await readBoundedJson(response, 1024 * 1024); + if (!response.ok) { + const error = parseDriveGrantError(payload); + throw new DriveGrantError(error.message, error.classification); + } + return parse(payload); + } catch (error) { + if (error instanceof DriveGrantError) throw error; + throw new DriveGrantError( + mutation + ? "Drive grant outcome unknown. Refresh grants before deciding on another action; do not blindly retry." + : "Drive grants could not be loaded. Refresh to try reading them again.", + mutation ? "unknown" : "not_committed", + ); + } +} +export async function listDriveGrants( + workspaceId: string, +): Promise { + const grants: DriveGrantResponse[] = []; + let after: string | null = null; + const ids = new Set(); + // Fetch every page before claiming that a Worker has no active grant. Bound + // malformed/cyclic cursors and unexpectedly large collections, never truncate. + for (let page = 0; page < 100; page++) { + const query = new URLSearchParams({ limit: "200" }); + if (after !== null) query.set("after", after); + const result = await request( + workspaceId, + `/drive/grants?${query}`, + {}, + parseDriveGrantListResponse, + ); + let previousId = after; + for (const grant of result.grants) { + if ( + grant.workspace_id !== workspaceId || ids.has(grant.grant_id) || + (previousId !== null && BigInt(grant.grant_id) <= BigInt(previousId)) + ) { + throw new DriveGrantError( + "Invalid Drive grant page identity or order. Refresh grants.", + "not_committed", + ); + } + previousId = grant.grant_id; + ids.add(grant.grant_id); + grants.push(grant); + } + if (result.next_after === null) return grants; + if ( + !result.grants.length || + result.next_after !== result.grants.at(-1)?.grant_id || + (after !== null && BigInt(result.next_after) <= BigInt(after)) + ) { + throw new DriveGrantError( + "Invalid Drive grant cursor. Refresh grants.", + "not_committed", + ); + } + after = result.next_after; + } + throw new DriveGrantError( + "Drive grant list is too large to load completely. No complete grant state is available.", + "not_committed", + ); +} +export function createDriveGrant( + workspaceId: string, + input: DriveGrantCreateRequest, +): Promise { + return request(workspaceId, "/drive/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, (payload) => { + const result = parseDriveGrantResponse(payload); + if ( + result.workspace_id !== workspaceId || + result.runtime_id !== input.runtime_id || + result.worker_id !== input.worker_id || result.access !== input.access || + result.revoked + ) throw new Error("Drive grant identity mismatch"); + return result; + }, true); +} +export function revokeDriveGrant( + workspaceId: string, + grant: DriveGrantResponse, +): Promise { + return request( + workspaceId, + `/drive/grants/${encodeURIComponent(grant.grant_id)}`, + { method: "DELETE" }, + (payload) => { + const result = parseDriveGrantResponse(payload); + if ( + result.workspace_id !== workspaceId || + result.grant_id !== grant.grant_id || + result.runtime_id !== grant.runtime_id || + result.worker_id !== grant.worker_id || !result.revoked + ) throw new Error("Drive grant identity mismatch"); + return result; + }, + true, + ); +} diff --git a/web/workspace/src/lib/workspace/drive-grants/tsconfig.check.json b/web/workspace/src/lib/workspace/drive-grants/tsconfig.check.json new file mode 100644 index 000000000..25e8c82da --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-grants/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.json", + "include": [ + "WorkerDriveGrants.svelte", + "api.ts", + "WorkerDriveGrants.browser.spec.ts", + "../../../routes/w/[workspaceId]/workers/+page.svelte" + ] +} diff --git a/web/workspace/src/lib/workspace/drive-ui/DriveDocument.browser.spec.ts b/web/workspace/src/lib/workspace/drive-ui/DriveDocument.browser.spec.ts new file mode 100644 index 000000000..49fb8f308 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/DriveDocument.browser.spec.ts @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom +import { cleanup, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import DriveDocument from "./DriveDocument.svelte"; +afterEach(cleanup); +test("Drive Markdown never automatically loads external or authenticated images, scripts or cross Workspace resources", async () => { + const { container } = render(DriveDocument, { + workspaceId: "ws", + text: [ + "# Shared", + "[latest](/w/ws/drive/9007199254740993)", + "[other](/w/other/drive/2)", + "[external](https://example.test)", + "[credentials](https://user:secret@evil.test)", + "![tracker](https://example.test/pixel.png)", + "![API](/api/w/ws/drive/download?id=2)", + "", + ].join("\n\n"), + }); + await waitFor(() => + expect(container.querySelector("h1")?.textContent).toBe("Shared") + ); + expect(container.querySelectorAll("img,script,iframe,object,embed")) + .toHaveLength(0); + expect(container.querySelectorAll("a")).toHaveLength(2); + const latest = container.querySelector( + 'a[href="/w/ws/drive/9007199254740993"]', + ); + expect(latest).not.toBeNull(); + expect(latest?.getAttribute("target")).toBeNull(); + const external = container.querySelector('a[href="https://example.test"]'); + expect(external?.getAttribute("rel")).toContain("noreferrer"); + expect(external?.getAttribute("target")).toBe("_blank"); + expect(container.textContent).toContain("[Image: tracker"); + expect(container.textContent).toContain("[Image: API"); + expect(container.textContent).not.toContain("Image: unnamed"); + expect(container.textContent).toContain("open its Drive URL to view"); +}); diff --git a/web/workspace/src/lib/workspace/drive-ui/DriveDocument.svelte b/web/workspace/src/lib/workspace/drive-ui/DriveDocument.svelte new file mode 100644 index 000000000..8211df6bc --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/DriveDocument.svelte @@ -0,0 +1,9 @@ + + diff --git a/web/workspace/src/lib/workspace/drive-ui/DriveMarkdownImage.svelte b/web/workspace/src/lib/workspace/drive-ui/DriveMarkdownImage.svelte new file mode 100644 index 000000000..e3199e098 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/DriveMarkdownImage.svelte @@ -0,0 +1,10 @@ + +[Image: {text || 'unnamed'} — open its Drive URL to view] + diff --git a/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts b/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts new file mode 100644 index 000000000..c2cd43f1f --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/DrivePage.browser.spec.ts @@ -0,0 +1,128 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { DriveMutationRequest } from "#lib/generated/drive-api.ts"; +import DrivePage from "./DrivePage.svelte"; +import { entry } from "../drive/test-fixtures.ts"; +vi.mock("$app/navigation", () => ({ beforeNavigate: vi.fn() })); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test("A late image decode error cannot replace the newly selected image preview", async () => { + let sequence = 0; + const create = vi.spyOn(URL, "createObjectURL").mockImplementation(() => + `blob:http://localhost/image-${++sequence}` + ); + const revoke = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + const id = url.searchParams.get("id") ?? "1"; + if (url.pathname.endsWith("/download")) { + return new Response(new Uint8Array(8)); + } + if (id === "1") return Response.json(entry("1", "alpha", "1", "folder")); + return Response.json({ + ...entry(id), + name: `image-${id}.png`, + content_type: "image/png", + size: 8, + }); + }), + ); + const view = render(DrivePage, { workspaceId: "alpha", nodeId: "2" }); + await waitFor(() => + expect(view.container.querySelector("img")?.getAttribute("src")).toBe( + "blob:http://localhost/image-1", + ) + ); + const oldImage = view.container.querySelector("img")!; + await view.rerender({ workspaceId: "alpha", nodeId: "3" }); + await waitFor(() => + expect(view.container.querySelector("img")?.getAttribute("src")).toBe( + "blob:http://localhost/image-2", + ) + ); + expect(view.container.querySelector("img")).not.toBe(oldImage); + await fireEvent.error(oldImage); + expect(view.container.textContent).not.toContain( + "Image could not be decoded", + ); + await fireEvent.error(view.container.querySelector("img")!); + expect(view.container.textContent).toContain("Image could not be decoded"); + expect(create).toHaveBeenCalledTimes(2); + expect(revoke).toHaveBeenCalledWith("blob:http://localhost/image-1"); +}); + +test("Refreshing metadata never silently rebases an open rename form onto another writer revision", async () => { + let revision = "1"; + const mutations: DriveMutationRequest[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const id = url.searchParams.get("id") ?? "1"; + if (url.pathname.endsWith("/mutate")) { + mutations.push(JSON.parse(String(init?.body))); + return Response.json({ + code: "conflict", + classification: "not_committed", + message: "Revision conflict", + }, { status: 409 }); + } + if (url.pathname.endsWith("/read-text")) { + return Response.json({ + entry: entry("2", "alpha", revision), + text: "new", + truncated: false, + }); + } + return Response.json( + entry( + id, + "alpha", + id === "1" ? "1" : revision, + id === "1" ? "folder" : "file", + ), + ); + }), + ); + const view = render(DrivePage, { workspaceId: "alpha", nodeId: "2" }); + await waitFor(() => + expect(view.getByRole("button", { name: "Rename / move" })).toBeDefined() + ); + await fireEvent.click(view.getByRole("button", { name: "Rename / move" })); + await fireEvent.input(view.getByLabelText("Name"), { + target: { value: "my-name.txt" }, + }); + revision = "2"; + await fireEvent.click( + view.getByRole("button", { name: "Refresh" }), + ); + await waitFor(() => + expect( + view.getByRole("button", { name: "Refresh" }).hasAttribute( + "disabled", + ), + ).toBe(false) + ); + expect(view.container.textContent).toContain("Expected revision 1."); + await fireEvent.click( + view.getByRole("button", { name: "Apply rename / move" }), + ); + await waitFor(() => expect(mutations).toHaveLength(1)); + expect(mutations[0].mutation).toMatchObject({ + operation: "relocate", + expected_revision: "1", + name: "my-name.txt", + id: { workspace_id: "alpha", node_id: "2" }, + }); + await waitFor(() => expect(view.container.textContent).toContain("conflict")); + expect((view.getByLabelText("Name") as HTMLInputElement).value).toBe( + "my-name.txt", + ); +}); diff --git a/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte b/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte new file mode 100644 index 000000000..df5ad5d74 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/DrivePage.svelte @@ -0,0 +1,337 @@ + + +
+
+
+

{entry?.parent ? entry.name : 'Drive'}

+

Shared files · latest only. Saving replaces the current content; there is no version history.

+
+ +
+ + {#if breadcrumbError}

{breadcrumbError}

{/if} + {#if localError}{/if} + {#if $controller.error}{/if} + {#if denied}

Read only / access denied by Backend. Editing and upload are unavailable.

{/if} + {#if $controller.loading}

Loading Drive…

{/if} + {#if entry} + + {#if entry.parent} +
+ {#if entry.kind === 'file'}Download{/if} + + {#if canEdit}{/if} + {#if canWrite} + + {#if entry.kind === 'file'}{/if} + + {/if} +
+

{driveHref(entry.entry)} {copyState}

+ {/if} + {#if kind === 'folder'} +
+ {#if canWrite} + + + + {/if} +
+ + {#if $controller.search}

Search results across this Drive: {$controller.search.query}

{/if} + {#if !$controller.loading && !$controller.error && !$controller.entries.length}

{$controller.search ? 'No matching files.' : 'This folder is empty.'}

{/if} + {#if $controller.entries.length} + +
+ + + + {#each $controller.entries as item (item.entry.node_id)} + + {/each} + +
NameKind / sizeUpdated
{item.name}{item.kind} · {formatBytes(item.size)}{formatDate(item.updated_at)}
{item.updated_by}
+
+ {/if} + {#if $controller.nextAfter}{/if} + {:else if kind === 'markdown' || kind === 'text'} + {#if $controller.truncated}

Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.

{/if} + {#if draft?.conflict} + + {/if} + {#if editing && draft} +
+ +
+ {#if canWrite}{/if} + + {dirty ? 'Unsaved draft' : 'Saved content'} · {new TextEncoder().encode(draft.text).length} / 65536 bytes +
+
+ {/if} + {#if $controller.text !== null} +
+ {#if $controller.text === ''}

Empty file.

{:else if kind === 'markdown'}{:else}
{$controller.text}
{/if} +
+ {/if} + {:else if kind === 'image'} + {#if (entry.size ?? Infinity) > DRIVE_RESPONSE_MAX_BYTES}

Image exceeds the 256 KiB preview limit. Download to view.

+ {:else if imageFailed} + {:else if imageUrl} + {#key imageUrl}{entry.name} { if (event.currentTarget.getAttribute('src') === imageUrl) imageFailed = true; }} />{/key} + {/if} + {:else if kind === 'download'}

This file type is download-only. HTML, SVG and other active content are never rendered here.

{/if} + {/if} + {#if form && entry} +
{ event.preventDefault(); void submitForm(); }}> +

{form === 'relocate' ? 'Rename / move' : form === 'replace' ? 'Replace file' : form === 'upload' ? 'Upload file' : form === 'folder' ? 'New folder' : 'New Markdown'}

+ {#if form === 'replace' || form === 'relocate'}

Expected revision {formEntry?.revision}. Refresh does not retarget this form to a newer revision.

{/if} + {#if form === 'upload' || form === 'replace'} + + {#if file}

{file.name} · {formatBytes(file.size)}

{/if} + {:else} + + {#if form === 'relocate'} + + +

Use the folder's stable URL ID. Rename / move updates DB metadata, not a copy of the file.

+ {/if} + {#if form === 'text'}{/if} + {/if} +
+ + {#if pending}{/if} + +
+
+ {/if} + {#if $controller.receipts.length} +
+ {#each $controller.receipts as receipt (receipt.requestId)} +
+ {receipt.operation}: {receipt.state === 'committed' ? 'Published — DB commit confirmed' : receipt.state === 'pending' ? 'Transferring / awaiting DB publication' : receipt.state === 'unknown' ? 'Outcome unknown — do not reupload' : 'Not committed'} + {#if receipt.error}

{receipt.error}

{/if} + {#if receipt.state === 'unknown'}{/if} +
Request ID{receipt.requestId}
+
+ {/each} +
+ {/if} +
+ + diff --git a/web/workspace/src/lib/workspace/drive-ui/policy.test.ts b/web/workspace/src/lib/workspace/drive-ui/policy.test.ts new file mode 100644 index 000000000..48b8678c4 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/policy.test.ts @@ -0,0 +1,66 @@ +import { markdownLinkTarget } from "../markdown/link-policy.ts"; +import { formatBytes, previewKind } from "./preview-policy.ts"; +import type { DriveEntry } from "#lib/generated/drive-api.ts"; +declare const Deno: { test(name: string, fn: () => void): void }; + +Deno.test("Markdown links admit explicit same Workspace navigation and credential-free external URLs only", () => { + const accepted = [ + "/w/ws/drive/9007199254740993", + "/w/ws/tickets/T-1-title", + "/w/ws/objectives/O-2", + ]; + for (const href of accepted) { + if (markdownLinkTarget(href, "ws")?.external !== false) { + throw new Error(href); + } + } + for ( + const href of [ + "//evil.test", + "/api/w/ws/drive/download", + "/w/other/drive/1", + "/w/ws/drive/0", + "/w/ws/drive/1?token=secret", + "/w/ws/drive/%2fetc", + "/w/ws/drive/..", + "javascript:alert(1)", + "data:text/html,test", + "https://user:secret@evil.test", + "file:///etc/passwd", + ] + ) { + if (markdownLinkTarget(href, "ws") !== null) throw new Error(href); + } + if (!markdownLinkTarget("https://example.test/docs", "ws")?.external) { + throw new Error("external link missing"); + } +}); +Deno.test("Drive preview never infers executable HTML or SVG from a Markdown filename", () => { + const entry = { name: "file.md", kind: "file" } as DriveEntry; + for ( + const content_type of [ + "text/html", + "image/svg+xml", + "application/xhtml+xml", + "application/octet-stream", + "application/javascript", + ] + ) { + if (previewKind({ ...entry, content_type }) !== "download") { + throw new Error(content_type); + } + } + for ( + const content_type of ["image/png", "image/jpeg", "image/gif", "image/webp"] + ) { + if (previewKind({ ...entry, content_type }) !== "image") { + throw new Error(content_type); + } + } + if (previewKind({ ...entry, content_type: "text/markdown" }) !== "markdown") { + throw new Error("Markdown not supported"); + } + if (formatBytes(0) !== "0 B") { + throw new Error("empty file confused with folder"); + } +}); diff --git a/web/workspace/src/lib/workspace/drive-ui/preview-policy.ts b/web/workspace/src/lib/workspace/drive-ui/preview-policy.ts new file mode 100644 index 000000000..16bf7079d --- /dev/null +++ b/web/workspace/src/lib/workspace/drive-ui/preview-policy.ts @@ -0,0 +1,26 @@ +import type { DriveEntry } from "#lib/generated/drive-api.ts"; + +export { DRIVE_TEXT_MAX_BYTES } from "#lib/workspace/drive/api.ts"; +const RASTER_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]); +export function previewKind( + entry: DriveEntry, +): "folder" | "markdown" | "text" | "image" | "download" { + if (entry.kind === "folder") return "folder"; + const media = (entry.content_type ?? "").split(";")[0].trim().toLowerCase(); + if (RASTER_TYPES.has(media)) return "image"; + // Never infer a preview from an extension, including .md files declared as HTML/SVG. + if (media === "text/markdown") return "markdown"; + if (media === "text/plain") return "text"; + return "download"; +} +export function formatBytes(size: number | null): string { + if (size === null) return "Folder"; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`; + return `${(size / (1024 * 1024)).toFixed(1)} MiB`; +} diff --git a/web/workspace/src/lib/workspace/drive/api.test.ts b/web/workspace/src/lib/workspace/drive/api.test.ts new file mode 100644 index 000000000..d4fdfb33c --- /dev/null +++ b/web/workspace/src/lib/workspace/drive/api.test.ts @@ -0,0 +1,510 @@ +import { + createDriveClient, + DRIVE_FILE_MAX_BYTES, + DRIVE_RESPONSE_MAX_BYTES, + driveDownloadUrl, + DriveRequestError, + parseDriveApiError, + parseDriveDecimal, + parseDriveEntry, + parseDriveListResponse, + parseDriveMutationResponse, + parseDriveReadTextResponse, + parseDriveRequestStatusResponse, + readBoundedDriveBytes, +} from "./api.ts"; +import { assert, entry, equal, ref, rejects, throws } from "./test-fixtures.ts"; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +Deno.test("Drive decimal boundary rejects noncanonical or overflow IDs without losing precision", () => { + for ( + const value of [ + 1, + null, + "", + "0", + "01", + "+1", + "-1", + "1e3", + "1.0", + " 1", + "١", + "9223372036854775808", + "999999999999999999999", + ] + ) throws(() => parseDriveDecimal(value)); + equal(parseDriveDecimal("9007199254740993"), "9007199254740993"); + equal(parseDriveDecimal("9223372036854775807"), "9223372036854775807"); +}); +Deno.test("Drive metadata rejects cross-workspace refs and unsafe latest links", () => { + const good = entry(); + const parsed = parseDriveEntry(good, "alpha"); + equal(parsed.entry, good.entry); + equal(parsed.revision, good.revision); + equal(parsed.latest_url, good.latest_url); + for ( + const patch of [ + { entry: ref("2", "beta") }, + { parent: ref("1", "beta") }, + { revision: 4 }, + { kind: "directory" }, + { size: -1 }, + { size: DRIVE_FILE_MAX_BYTES + 1 }, + { content_type: "text/plain\r\nx:bad" }, + { name: null }, + { size: null }, + ...[ + "https://evil.test/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2", + "//evil.test/path", + "javascript:alert(1)", + "/api/w/beta/drive/download?entry_workspace_id=alpha&id=2", + "/api/w/alpha/drive/download?entry_workspace_id=beta&id=2", + "/api/w/alpha/drive/download?entry_workspace_id=alpha&id=3", + "/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2&token=secret", + "/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2&id=2", + "/api/w/alpha/drive/download?entry_workspace_id=alpha&id=2#fragment", + "/api/w/alpha/drive/../drive/download?entry_workspace_id=alpha&id=2", + ].map((latest_url) => ({ latest_url })), + ] + ) throws(() => parseDriveEntry({ ...good, ...patch }, "alpha")); + throws(() => + parseDriveEntry({ ...entry("1", "alpha", "1", "folder"), size: 0 }, "alpha") + ); +}); +Deno.test("Drive pages and text reject malformed elements and multibyte overflow", () => { + for ( + const value of [ + null, + { entries: [entry(), null], next_after: null }, + { entries: [entry(), entry()], next_after: null }, + { entries: [], next_after: 2 }, + { + entries: Array.from({ length: 201 }, (_, i) => entry(String(i + 2))), + next_after: null, + }, + ] + ) throws(() => parseDriveListResponse(value, "alpha")); + for ( + const patch of [{ truncated: "false" }, { text: "é".repeat(32769) }, { + entry: entry("1", "alpha", "1", "folder"), + }] + ) { + throws(() => + parseDriveReadTextResponse({ + entry: entry(), + text: "abc", + truncated: false, + ...patch, + }, "alpha") + ); + } +}); +Deno.test("Drive receipts reject mismatched request ids and impossible committed states", () => { + const response = { request_id: "req", entry: entry() }; + throws(() => parseDriveMutationResponse(response, "alpha", "other")); + for ( + const v of [ + { request_id: "req", state: "committed", response: null }, + { request_id: "req", state: "uncommitted", response }, + { + request_id: "req", + state: "committed", + response: { ...response, request_id: "other" }, + }, + { request_id: "other", state: "uncommitted", response: null }, + ] + ) throws(() => parseDriveRequestStatusResponse(v, "alpha", "req")); + throws(() => + parseDriveApiError({ + code: "conflict", + classification: "unknown", + message: "bad", + }) + ); + throws(() => + parseDriveApiError({ + code: "outcome_unknown", + classification: "not_committed", + message: "bad", + }) + ); +}); +Deno.test("Drive read bounds reject announced and observed bodies above 256 KiB", async () => { + equal( + (await readBoundedDriveBytes( + new Response(new Uint8Array(DRIVE_RESPONSE_MAX_BYTES)), + )).length, + DRIVE_RESPONSE_MAX_BYTES, + ); + await rejects(() => + readBoundedDriveBytes( + new Response("{}", { + headers: { "content-length": String(DRIVE_RESPONSE_MAX_BYTES + 1) }, + }), + ) + ); + await rejects(() => + readBoundedDriveBytes( + new Response(new Uint8Array(DRIVE_RESPONSE_MAX_BYTES + 1), { + headers: { "content-length": "1" }, + }), + ) + ); +}); +Deno.test("Drive client uses current routes with same-origin authentication and binary SHA256 upload", async () => { + const blob = new Blob([new Uint8Array([0, 255, 8])]); + const digest = await crypto.subtle.digest( + "SHA-256", + await blob.arrayBuffer(), + ); + const expectedHash = Array.from( + new Uint8Array(digest), + (b) => b.toString(16).padStart(2, "0"), + ).join(""); + let called = false; + const client = createDriveClient((input, init) => { + called = true; + const url = new URL(String(input), "https://local.test"); + equal(url.pathname, "/api/w/alpha/drive/upload"); + equal(url.searchParams.get("parent_id"), "9007199254740993"); + equal(url.searchParams.get("size"), "3"); + equal(url.searchParams.get("sha256"), expectedHash); + equal((init as RequestInit | undefined)?.credentials, "same-origin"); + equal((init as RequestInit | undefined)?.cache, "no-store"); + equal((init as RequestInit | undefined)?.redirect, "error"); + equal((init as RequestInit | undefined)?.method, "PUT"); + assert((init as RequestInit | undefined)?.body === blob); + equal( + new Headers((init as RequestInit | undefined)?.headers).get( + "content-type", + ), + "application/octet-stream", + ); + return Promise.resolve( + Response.json({ + request_id: "req", + entry: { ...entry(), name: "raw.bin", parent: ref("9007199254740993") }, + }), + ); + }); + await client.upload("alpha", "req", blob, { + operation: "create", + parent: ref("9007199254740993"), + name: "raw.bin", + content_type: "application/octet-stream", + }); + assert(called); + equal( + driveDownloadUrl("alpha", ref("9007199254740993"), "9007199254740995"), + "/api/w/alpha/drive/download?entry_workspace_id=alpha&id=9007199254740993&expected_revision=9007199254740995", + ); +}); +Deno.test("Drive upload over 16 MiB is rejected before fetching", async () => { + let calls = 0; + const client = createDriveClient(() => { + ++calls; + throw new Error("Must not transmit"); + }); + await rejects(() => + client.upload( + "alpha", + "req", + new Blob([new Uint8Array(DRIVE_FILE_MAX_BYTES + 1)]), + { + operation: "create", + parent: ref("1"), + name: "raw.bin", + content_type: "application/octet-stream", + }, + ) + ); + equal(calls, 0); +}); +Deno.test("Drive mutation network abort or malformed reply remains unknown after fetch invocation", async () => { + for ( + const fetchFn of [ + () => Promise.reject(new TypeError("Network failure")), + () => Promise.reject(new DOMException("Aborted", "AbortError")), + () => Promise.resolve(new Response("not JSON", { status: 502 })), + () => + Promise.resolve(Response.json({ request_id: "wrong", entry: entry() })), + () => + Promise.resolve( + new Response(new Uint8Array(DRIVE_RESPONSE_MAX_BYTES + 1)), + ), + ] + ) { + const client = createDriveClient(fetchFn); + await rejects( + () => + client.mutate("alpha", "req", { + operation: "delete", + id: ref(), + expected_revision: "9007199254740993", + }), + (error) => { + assert(error instanceof DriveRequestError); + equal(error.classification, "unknown"); + }, + ); + } +}); +Deno.test("Drive typed conflict proves not committed and read responses cannot target another node", async () => { + const client = createDriveClient(() => + Promise.resolve( + Response.json({ + code: "conflict", + classification: "not_committed", + message: "conflict", + }, { status: 409 }), + ) + ); + await rejects( + () => + client.mutate("alpha", "req", { + operation: "delete", + id: ref(), + expected_revision: "1", + }), + (error) => { + assert(error instanceof DriveRequestError); + equal(error.classification, "not_committed"); + }, + ); + const wrong = createDriveClient(() => + Promise.resolve( + Response.json({ entry: entry("3"), text: "abc", truncated: false }), + ) + ); + await rejects(() => wrong.readText("alpha", ref())); +}); +Deno.test("Drive image preview fetches authenticated bounded binary rather than metadata URL", async () => { + const image = { ...entry(), content_type: "image/png" }; + const client = createDriveClient((url, init) => { + equal(String(url), driveDownloadUrl("alpha", ref(), image.revision)); + equal((init as RequestInit | undefined)?.credentials, "same-origin"); + equal((init as RequestInit | undefined)?.cache, "no-store"); + return Promise.resolve(new Response(new Uint8Array([0, 1, 2]))); + }); + equal((await client.image("alpha", image)).size, 3); + await rejects(() => + client.image("alpha", { ...image, content_type: "image/svg+xml" }) + ); + await rejects(() => + client.image("alpha", { ...image, size: DRIVE_RESPONSE_MAX_BYTES + 1 }) + ); + await rejects(() => + client.image("alpha", { ...image, content_type: "image/avif" }) + ); +}); +Deno.test("Drive malformed mutation response target parent name or operation kind remains unknown", async () => { + const cases: { + mutation: import("./api.ts").DriveMutation; + response: unknown; + }[] = [ + { + mutation: { + operation: "update_text", + id: ref(), + expected_revision: "1", + text: "abc", + content_type: "text/plain", + }, + response: entry("3"), + }, + { + mutation: { + operation: "update_text", + id: ref(), + expected_revision: "1", + text: "abc", + content_type: "text/plain", + }, + response: entry("2", "alpha", "2", "folder"), + }, + { + mutation: { + operation: "relocate", + id: ref(), + expected_revision: "1", + parent: ref("4"), + name: "moved.txt", + }, + response: { ...entry(), parent: ref("4"), name: "wrong.txt" }, + }, + { + mutation: { + operation: "relocate", + id: ref(), + expected_revision: "1", + parent: ref("4"), + name: "moved.txt", + }, + response: { ...entry(), name: "moved.txt" }, + }, + { + mutation: { + operation: "relocate", + id: ref(), + expected_revision: "1", + parent: ref("4"), + name: "moved.txt", + }, + response: { ...entry("3"), parent: ref("4"), name: "moved.txt" }, + }, + { + mutation: { operation: "create_folder", parent: ref("4"), name: "new" }, + response: { ...entry("3", "alpha", "1", "folder"), name: "new" }, + }, + { + mutation: { + operation: "create_folder", + parent: ref("1"), + name: "note.txt", + }, + response: entry("3"), + }, + { + mutation: { + operation: "create_text", + parent: ref("1"), + name: "note.txt", + text: "abc", + content_type: "text/plain", + }, + response: entry("3", "alpha", "1", "folder"), + }, + { + mutation: { + operation: "create_text", + parent: ref("1"), + name: "other.txt", + text: "abc", + content_type: "text/plain", + }, + response: entry("3"), + }, + { + mutation: { + operation: "update_text", + id: ref(), + expected_revision: "1", + text: "abc", + content_type: "text/plain", + }, + response: null, + }, + { + mutation: { operation: "delete", id: ref(), expected_revision: "1" }, + response: entry(), + }, + ]; + for (const { mutation, response } of cases) { + const client = createDriveClient(() => + Promise.resolve(Response.json({ request_id: "req", entry: response })) + ); + await rejects(() => client.mutate("alpha", "req", mutation), (error) => { + assert(error instanceof DriveRequestError); + equal(error.classification, "unknown"); + }); + } +}); +Deno.test("Drive malformed upload update and create identity remains unknown after transmission", async () => { + const cases: { + target: import("./api.ts").DriveUploadTarget; + response: unknown; + }[] = [ + { + target: { + operation: "update", + id: ref(), + expected_revision: "1", + content_type: "text/plain", + }, + response: entry("3"), + }, + { + target: { + operation: "update", + id: ref(), + expected_revision: "1", + content_type: "text/plain", + }, + response: entry("2", "alpha", "2", "folder"), + }, + { + target: { + operation: "create", + parent: ref("4"), + name: "note.txt", + content_type: "text/plain", + }, + response: entry("3"), + }, + { + target: { + operation: "create", + parent: ref("1"), + name: "other.txt", + content_type: "text/plain", + }, + response: entry("3"), + }, + { + target: { + operation: "create", + parent: ref("1"), + name: "note.txt", + content_type: "text/plain", + }, + response: entry("3", "alpha", "1", "folder"), + }, + { + target: { + operation: "update", + id: ref(), + expected_revision: "1", + content_type: "text/plain", + }, + response: null, + }, + ]; + for (const { target, response } of cases) { + const client = createDriveClient(() => + Promise.resolve(Response.json({ request_id: "req", entry: response })) + ); + await rejects( + () => client.upload("alpha", "req", new Blob(["abc"]), target), + (error) => { + assert(error instanceof DriveRequestError); + equal(error.classification, "unknown"); + }, + ); + } +}); + +Deno.test("Drive bounded text roundtrip accepts worst-case JSON escaping without increasing raster bytes", async () => { + const text = "\u0000".repeat(64 * 1024); + const payload = JSON.stringify({ + entry: { ...entry(), size: 64 * 1024 }, + text, + truncated: false, + }); + assert( + new TextEncoder().encode(payload).length > DRIVE_RESPONSE_MAX_BYTES, + "fixture must cross the old binary-sized wire bound", + ); + const client = createDriveClient(() => + Promise.resolve(new Response(payload)) + ); + const result = await client.readText("alpha", ref()); + assert( + result.text === text && !result.truncated, + "valid 64 KiB UTF-8 text must survive JSON escapes", + ); + equal(result.entry.size, 64 * 1024); +}); diff --git a/web/workspace/src/lib/workspace/drive/api.ts b/web/workspace/src/lib/workspace/drive/api.ts new file mode 100644 index 000000000..4387aab51 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive/api.ts @@ -0,0 +1,637 @@ +import type { + DriveApiError, + DriveEntry, + DriveEntryRef, + DriveListResponse, + DriveMutation, + DriveMutationResponse, + DriveReadTextResponse, + DriveRequestStatusResponse, +} from "#lib/generated/drive-api.ts"; +import { workspaceApiPath } from "#lib/workspace/api/http.ts"; + +export type { + DriveEntry, + DriveEntryRef, + DriveMutation, +} from "#lib/generated/drive-api.ts"; +export const DRIVE_RESPONSE_MAX_BYTES = 256 * 1024; +// A 64 KiB UTF-8 text can expand to six times its length in JSON escapes. +// Keep the JSON wire bound separate from the raster preview byte limit. +export const DRIVE_JSON_MAX_BYTES = 512 * 1024; +export const DRIVE_TEXT_MAX_BYTES = 64 * 1024; +export const DRIVE_FILE_MAX_BYTES = 16 * 1024 * 1024; +const MAX_DECIMAL = "9223372036854775807"; + +export class DriveContractError extends Error { + constructor() { + super("Invalid Drive payload"); + } +} +export class DriveRequestError extends Error { + constructor( + public readonly code: DriveApiError["code"], + public readonly classification: DriveApiError["classification"], + ) { + super( + classification === "unknown" + ? "Drive outcome unknown; check request status" + : `Drive request failed: ${code}`, + ); + } +} +function invalid(): never { + throw new DriveContractError(); +} +function record(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + invalid(); + } + return value as Record; +} +function string(value: unknown, maximum = 4096): string { + if (typeof value !== "string" || value.length > maximum) invalid(); + return value; +} +function boolean(value: unknown): boolean { + if (typeof value !== "boolean") invalid(); + return value; +} +function nullableString(value: unknown): string | null { + return value === null ? null : string(value); +} +export function parseDriveDecimal(value: unknown): string { + const result = string(value, 19); + if ( + !/^[1-9][0-9]*$/.test(result) || + (result.length === 19 && result > MAX_DECIMAL) + ) invalid(); + return result; +} +function hasControl(value: string, includeSpace = false): boolean { + return Array.from(value).some((char) => + char.charCodeAt(0) <= (includeSpace ? 32 : 31) + ); +} +function workspace(value: string): string { + if ( + !value || value === "." || value === ".." || hasControl(value, true) || + /[/\\?#]/.test(value) + ) invalid(); + return string(value, 256); +} +export function parseDriveEntryRef( + value: unknown, + workspaceId: string, +): DriveEntryRef { + const v = record(value); + if (string(v.workspace_id) !== workspace(workspaceId)) invalid(); + return { workspace_id: workspaceId, node_id: parseDriveDecimal(v.node_id) }; +} +function base(workspaceId: string): string { + return workspaceApiPath(workspace(workspaceId), "/drive"); +} +function query( + values: Record, +): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value !== null && value !== undefined) params.set(key, String(value)); + } + return `?${params}`; +} +function refQuery( + ref: DriveEntryRef, + workspaceId: string, +): { entry_workspace_id: string; id: string } { + const parsed = parseDriveEntryRef(ref, workspaceId); + return { entry_workspace_id: parsed.workspace_id, id: parsed.node_id }; +} +/** Only service latest links for this exact entry, without credentials or extra query keys. */ +export function parseDriveLatestUrl( + value: unknown, + entry: DriveEntryRef, + kind: DriveEntry["kind"], +): string { + const path = string(value); + const endpoint = `${base(entry.workspace_id)}/${ + kind === "file" ? "download" : "metadata" + }`; + if ( + !path.startsWith(`${endpoint}?`) || hasControl(path, true) || + /[\\#]/.test(path) + ) { + invalid(); + } + const params = new URLSearchParams(path.slice(endpoint.length + 1)); + if ( + [...params].length !== 2 || + params.get("entry_workspace_id") !== entry.workspace_id || + params.get("id") !== entry.node_id + ) invalid(); + return path; +} +function mime(value: unknown): string { + const result = string(value, 128); + if (!/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(result)) invalid(); + return result.toLowerCase(); +} +export function parseDriveEntry( + value: unknown, + workspaceId: string, +): DriveEntry { + const v = record(value); + const entry = parseDriveEntryRef(v.entry, workspaceId); + const parent = v.parent === null + ? null + : parseDriveEntryRef(v.parent, workspaceId); + if (v.kind !== "folder" && v.kind !== "file") invalid(); + const size = v.size; + if (v.kind === "file") { + if ( + typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || + size > DRIVE_FILE_MAX_BYTES || parent === null + ) invalid(); + } else if (size !== null || v.content_type !== null) invalid(); + return { + entry, + parent, + kind: v.kind, + name: string(v.name, 255), + revision: parseDriveDecimal(v.revision), + size: size as number | null, + content_type: v.content_type === null ? null : mime(v.content_type), + updated_by: string(v.updated_by), + updated_at: string(v.updated_at, 128), + latest_url: parseDriveLatestUrl(v.latest_url, entry, v.kind), + }; +} +export function parseDriveListResponse( + value: unknown, + workspaceId: string, + maximum = 200, +): DriveListResponse { + const v = record(value); + if (!Array.isArray(v.entries) || v.entries.length > maximum) invalid(); + const entries = v.entries.map((entry) => parseDriveEntry(entry, workspaceId)); + if ( + new Set(entries.map((entry) => entry.entry.node_id)).size !== entries.length + ) invalid(); + return { entries, next_after: nullableString(v.next_after) }; +} +export function parseDriveReadTextResponse( + value: unknown, + workspaceId: string, +): DriveReadTextResponse { + const v = record(value); + const entry = parseDriveEntry(v.entry, workspaceId); + const text = string(v.text, DRIVE_TEXT_MAX_BYTES); + if ( + entry.kind !== "file" || + new TextEncoder().encode(text).length > DRIVE_TEXT_MAX_BYTES + ) invalid(); + return { entry, text, truncated: boolean(v.truncated) }; +} +export function parseDriveMutationResponse( + value: unknown, + workspaceId: string, + requestId: string, +): DriveMutationResponse { + const v = record(value); + if (v.request_id !== requestId) invalid(); + return { + request_id: requestId, + entry: v.entry === null ? null : parseDriveEntry(v.entry, workspaceId), + }; +} +export function parseDriveRequestStatusResponse( + value: unknown, + workspaceId: string, + requestId: string, +): DriveRequestStatusResponse { + const v = record(value); + if ( + v.request_id !== requestId || + (v.state !== "committed" && v.state !== "uncommitted") + ) invalid(); + if ((v.state === "uncommitted") !== (v.response === null)) invalid(); + return { + request_id: requestId, + state: v.state, + response: v.response === null + ? null + : parseDriveMutationResponse(v.response, workspaceId, requestId), + }; +} +export function parseDriveApiError(value: unknown): DriveApiError { + const v = record(value); + const codes = [ + "denied", + "not_found", + "conflict", + "invalid", + "limit", + "storage_unavailable", + "outcome_unknown", + ]; + if ( + !codes.includes(v.code as string) || + (v.classification !== "not_committed" && v.classification !== "unknown") || + (v.code === "outcome_unknown") !== (v.classification === "unknown") + ) invalid(); + return { + code: v.code as DriveApiError["code"], + classification: v.classification, + message: string(v.message), + }; +} +export async function readBoundedDriveBytes( + response: Response, + maximum = DRIVE_RESPONSE_MAX_BYTES, +): Promise> { + const length = response.headers.get("content-length"); + if ( + length !== null && + (!/^[0-9]+$/.test(length) || BigInt(length) > BigInt(maximum)) + ) { + await response.body?.cancel(); + invalid(); + } + const reader = response.body?.getReader(); + if (!reader) invalid(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maximum) { + await reader.cancel(); + invalid(); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} +export type DriveUploadTarget = + | { + operation: "create"; + parent: DriveEntryRef; + name: string; + content_type: string; + } + | { + operation: "update"; + id: DriveEntryRef; + expected_revision: string; + content_type: string; + }; +export type DrivePageOptions = { after?: string | null; limit?: number }; +export interface DriveClient { + root(workspaceId: string, signal?: AbortSignal): Promise; + metadata( + workspaceId: string, + ref: DriveEntryRef, + signal?: AbortSignal, + ): Promise; + list( + workspaceId: string, + ref: DriveEntryRef, + options?: DrivePageOptions, + signal?: AbortSignal, + ): Promise; + search( + workspaceId: string, + text: string, + includeText?: boolean, + options?: DrivePageOptions, + signal?: AbortSignal, + ): Promise; + readText( + workspaceId: string, + ref: DriveEntryRef, + signal?: AbortSignal, + ): Promise; + image( + workspaceId: string, + entry: DriveEntry, + signal?: AbortSignal, + ): Promise; + mutate( + workspaceId: string, + requestId: string, + mutation: DriveMutation, + signal?: AbortSignal, + ): Promise; + upload( + workspaceId: string, + requestId: string, + blob: Blob, + target: DriveUploadTarget, + signal?: AbortSignal, + ): Promise; + status( + workspaceId: string, + requestId: string, + signal?: AbortSignal, + ): Promise; +} +export function driveDownloadUrl( + workspaceId: string, + ref: DriveEntryRef, + revision?: string, +): string { + return `${base(workspaceId)}/download${ + query({ + ...refQuery(ref, workspaceId), + expected_revision: revision === undefined + ? null + : parseDriveDecimal(revision), + }) + }`; +} +function requestId(value: string): string { + if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) invalid(); + return value; +} +function name(value: string): string { + if ( + !value || value === "." || value === ".." || hasControl(value) || + /[/\\]/.test(value) || + new TextEncoder().encode(value).length > 255 + ) invalid(); + return value; +} +function validateMutation(value: DriveMutation, workspaceId: string): void { + if ("id" in value) { + parseDriveEntryRef(value.id, workspaceId); + parseDriveDecimal(value.expected_revision); + } + if ("parent" in value) parseDriveEntryRef(value.parent, workspaceId); + if ("name" in value) name(value.name); + if ("text" in value) { + if (new TextEncoder().encode(value.text).length > DRIVE_TEXT_MAX_BYTES) { + throw new DriveRequestError("limit", "not_committed"); + } + mime(value.content_type); + } +} +function page(options: DrivePageOptions, maximum: number): DrivePageOptions { + if ( + options.limit !== undefined && + (!Number.isInteger(options.limit) || options.limit < 1 || + options.limit > maximum) + ) invalid(); + if (options.after !== undefined && options.after !== null) { + string(options.after); + } + return options; +} +export function createDriveClient(fetchFn: typeof fetch = fetch): DriveClient { + async function response( + path: string, + init: RequestInit = {}, + mutation = false, + ): Promise { + // Fetch invocation is the conservative transmission boundary. An abort/network + // error after this point cannot prove that the server did not commit. + try { + return await fetchFn(path, { + ...init, + credentials: "same-origin", + cache: "no-store", + redirect: "error", + }); + } catch { + throw new DriveRequestError( + mutation ? "outcome_unknown" : "storage_unavailable", + mutation ? "unknown" : "not_committed", + ); + } + } + async function checked(res: Response): Promise { + if (res.ok) return; + const payload: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + await readBoundedDriveBytes(res, DRIVE_JSON_MAX_BYTES), + ), + ); + const error = parseDriveApiError(payload); + throw new DriveRequestError(error.code, error.classification); + } + async function json( + path: string, + parse: (value: unknown) => T, + init?: RequestInit, + mutation = false, + ): Promise { + const res = await response(path, init, mutation); + try { + await checked(res); + return parse( + JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + await readBoundedDriveBytes(res, DRIVE_JSON_MAX_BYTES), + ), + ), + ); + } catch (error) { + if (error instanceof DriveRequestError) throw error; + if (mutation) throw new DriveRequestError("outcome_unknown", "unknown"); + throw error; + } + } + function exact(entry: DriveEntry, ref: DriveEntryRef): DriveEntry { + if (entry.entry.node_id !== ref.node_id) invalid(); + return entry; + } + return { + root: (ws, signal) => + json(`${base(ws)}/root`, (v) => { + const entry = parseDriveEntry(v, ws); + if (entry.kind !== "folder" || entry.parent !== null) invalid(); + return entry; + }, { signal }), + metadata: (ws, ref, signal) => + json( + `${base(ws)}/metadata${query(refQuery(ref, ws))}`, + (v) => exact(parseDriveEntry(v, ws), ref), + { signal }, + ), + list: (ws, ref, options = {}, signal) => + json( + `${base(ws)}/list${ + query({ ...refQuery(ref, ws), ...page(options, 200) }) + }`, + (v) => { + const result = parseDriveListResponse(v, ws); + if ( + result.entries.some((e) => e.parent?.node_id !== ref.node_id) + ) invalid(); + return result; + }, + { signal }, + ), + search: (ws, text, includeText = false, options = {}, signal) => + json( + `${base(ws)}/search${ + query({ + query: string(text), + include_text: includeText, + ...page(options, 128), + }) + }`, + (v) => parseDriveListResponse(v, ws, 128), + { signal }, + ), + readText: (ws, ref, signal) => + json( + `${base(ws)}/read-text${ + query({ ...refQuery(ref, ws), max_bytes: DRIVE_TEXT_MAX_BYTES }) + }`, + (v) => { + const result = parseDriveReadTextResponse(v, ws); + exact(result.entry, ref); + return result; + }, + { signal }, + ), + image: async (ws, entry, signal) => { + parseDriveEntry(entry, ws); + // Do not render active SVG or HTML as a preview. + if ( + !/^(image\/(png|jpeg|gif|webp))$/.test(entry.content_type ?? "") || + entry.size === null || entry.size > DRIVE_RESPONSE_MAX_BYTES + ) throw new DriveRequestError("limit", "not_committed"); + const res = await response( + driveDownloadUrl(ws, entry.entry, entry.revision), + { signal }, + ); + await checked(res); + const bytes = await readBoundedDriveBytes(res, DRIVE_RESPONSE_MAX_BYTES); + if (bytes.length !== entry.size) invalid(); + return new Blob([bytes], { type: entry.content_type! }); + }, + mutate: (ws, id, mutation, signal) => { + validateMutation(mutation, ws); + return json( + `${base(ws)}/mutate`, + (v) => { + const result = parseDriveMutationResponse(v, ws, id); + if (mutation.operation === "delete") { + if (result.entry !== null) invalid(); + } else { + if (result.entry === null) invalid(); + if ( + mutation.operation === "create_folder" && + result.entry.kind !== "folder" + ) invalid(); + if ( + (mutation.operation === "create_text" || + mutation.operation === "update_text") && + result.entry.kind !== "file" + ) invalid(); + if ("name" in mutation && result.entry.name !== mutation.name) { + invalid(); + } + if ( + "id" in mutation && + result.entry.entry.node_id !== mutation.id.node_id + ) invalid(); + if ( + "parent" in mutation && + result.entry.parent?.node_id !== mutation.parent.node_id + ) invalid(); + } + return result; + }, + { + method: "POST", + signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request_id: requestId(id), mutation }), + }, + true, + ); + }, + upload: async (ws, id, blob, target, signal) => { + base(ws); + requestId(id); + if (blob.size > DRIVE_FILE_MAX_BYTES) { + throw new DriveRequestError("limit", "not_committed"); + } + const contentType = mime(target.content_type); + const fields = target.operation === "create" + ? { + parent_id: parseDriveEntryRef(target.parent, ws).node_id, + name: name(target.name), + } + : { + id: parseDriveEntryRef(target.id, ws).node_id, + expected_revision: parseDriveDecimal(target.expected_revision), + }; + signal?.throwIfAborted(); + const digest = await crypto.subtle.digest( + "SHA-256", + await blob.arrayBuffer(), + ); + signal?.throwIfAborted(); + const sha256 = Array.from( + new Uint8Array(digest), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + return json( + `${base(ws)}/upload${ + query({ + operation: target.operation, + request_id: id, + entry_workspace_id: ws, + ...fields, + content_type: contentType, + size: blob.size, + sha256, + }) + }`, + (v) => { + const result = parseDriveMutationResponse(v, ws, id); + if (result.entry === null || result.entry.kind !== "file") invalid(); + if ( + target.operation === "create" && result.entry.name !== target.name + ) invalid(); + if ( + target.operation === "update" && + result.entry.entry.node_id !== target.id.node_id + ) invalid(); + if ( + target.operation === "create" && + result.entry.parent?.node_id !== target.parent.node_id + ) invalid(); + return result; + }, + { + method: "PUT", + signal, + headers: { "content-type": "application/octet-stream" }, + body: blob, + }, + true, + ); + }, + status: (ws, id, signal) => + json( + `${base(ws)}/requests/${encodeURIComponent(requestId(id))}`, + (v) => parseDriveRequestStatusResponse(v, ws, id), + { signal }, + ), + }; +} diff --git a/web/workspace/src/lib/workspace/drive/controller.svelte.ts b/web/workspace/src/lib/workspace/drive/controller.svelte.ts new file mode 100644 index 000000000..80201da63 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive/controller.svelte.ts @@ -0,0 +1,513 @@ +import type { DriveMutationResponse } from "#lib/generated/drive-api.ts"; +import { + createDriveClient, + DRIVE_RESPONSE_MAX_BYTES, + DRIVE_TEXT_MAX_BYTES, + type DriveClient, + type DriveEntry, + type DriveEntryRef, + type DriveMutation, + DriveRequestError, + type DriveUploadTarget, + parseDriveEntryRef, +} from "./api.ts"; + +export type DriveDraft = { + text: string; + baseText: string; + expectedRevision: string; + contentType: string; + conflict: boolean; +}; +export type DriveReceipt = { + requestId: string; + state: "pending" | "committed" | "not_committed" | "unknown"; + operation: DriveMutation["operation"] | "upload"; + error: string | null; + response: DriveMutationResponse | null; +}; +export type DriveControllerState = { + workspaceId: string | null; + entry: DriveEntry | null; + entries: DriveEntry[]; + nextAfter: string | null; + search: { query: string; includeText: boolean } | null; + text: string | null; + truncated: boolean; + image: Blob | null; + draft: DriveDraft | null; + receipts: DriveReceipt[]; + loading: boolean; + error: string | null; +}; +type ReceiptRecord = { + receipt: DriveReceipt; + saved?: { text: string; revision: string }; +}; +const empty = (): DriveControllerState => ({ + workspaceId: null, + entry: null, + entries: [], + nextAfter: null, + search: null, + text: null, + truncated: false, + image: null, + draft: null, + receipts: [], + loading: false, + error: null, +}); +function key(ws: string, ref: DriveEntryRef): string { + return JSON.stringify([ws, ref.node_id]); +} +function message(error: unknown): string { + return error instanceof DriveRequestError + ? error.message + : "Drive request could not be completed"; +} +/** A subscribable snapshot store; Svelte consumers may use `$controller` directly. + * All asynchronous publication is fenced by selection generation. Aborting a + * write does not establish failure: its receipt is retained for reconciliation. + */ +export class DriveController { + private value = empty(); + private listeners = new Set<(state: DriveControllerState) => void>(); + private drafts = new Map(); + private receipts = new Map>(); + private generation = 0; + private pageGeneration = 0; + private abort = new AbortController(); + private writeAbort = new AbortController(); + private writeGeneration = 0; + private disposed = false; + constructor( + private readonly client: DriveClient = createDriveClient(), + private readonly newRequestId: () => string = () => crypto.randomUUID(), + ) {} + get state(): DriveControllerState { + return this.value; + } + subscribe(listener: (state: DriveControllerState) => void): () => void { + this.listeners.add(listener); + listener(this.value); + return () => this.listeners.delete(listener); + } + private publish(patch: Partial): void { + this.value = { ...this.value, ...patch }; + for (const listener of this.listeners) listener(this.value); + } + private current(generation: number): boolean { + return !this.disposed && generation === this.generation; + } + private selection(): { + ws: string; + entry: DriveEntry; + key: string; + generation: number; + signal: AbortSignal; + } { + if (this.disposed || !this.value.workspaceId || !this.value.entry) { + throw new Error("Select a Drive entry first"); + } + return { + ws: this.value.workspaceId, + entry: this.value.entry, + key: key(this.value.workspaceId, this.value.entry.entry), + generation: this.generation, + signal: this.abort.signal, + }; + } + private currentReceipts(k: string): DriveReceipt[] { + return [...(this.receipts.get(k)?.values() ?? [])].map((record) => + record.receipt + ); + } + private cancelWrites(reason: string): void { + ++this.writeGeneration; + for (const records of this.receipts.values()) { + for (const record of records.values()) { + if (record.receipt.state === "pending") { + record.receipt = { + ...record.receipt, + state: "unknown", + error: reason, + }; + } + } + } + this.writeAbort.abort(); + this.writeAbort = new AbortController(); + } + /** Cancel active writes only. Cancellation is not proof of noncommit, so the + * receipt and draft are retained and the next action must be status lookup. + * Read/preview/search signals and selection identity are left untouched. + */ + cancelPending(): void { + if (this.disposed) return; + if ( + !this.currentReceiptsForSelection().some((r) => r.state === "pending") + ) return; + this.cancelWrites( + "Transfer cancelled; outcome unknown; check request status", + ); + const s = this.selection(); + this.publish({ receipts: this.currentReceipts(s.key) }); + } + private currentReceiptsForSelection(): DriveReceipt[] { + const { workspaceId, entry } = this.value; + return workspaceId && entry + ? this.currentReceipts(key(workspaceId, entry.entry)) + : []; + } + private fence(): void { + // Late promises must never publish into a reselected identity, even A -> B -> A. + ++this.generation; + ++this.pageGeneration; + this.cancelWrites("Selection changed; check request status"); + this.abort.abort(); + this.abort = new AbortController(); + } + async select(workspaceId: string | null, ref?: DriveEntryRef): Promise { + if (this.disposed) return; + if (ref && workspaceId) parseDriveEntryRef(ref, workspaceId); + this.fence(); + const generation = this.generation; + const pageGeneration = this.pageGeneration; + const signal = this.abort.signal; + this.publish({ ...empty(), workspaceId, loading: workspaceId !== null }); + if (!workspaceId) return; + try { + const entry = ref + ? await this.client.metadata(workspaceId, ref, signal) + : await this.client.root(workspaceId, signal); + if (!this.current(generation)) return; + const k = key(workspaceId, entry.entry); + this.publish({ + entry, + draft: isText(entry) ? this.drafts.get(k) ?? null : null, + receipts: this.currentReceipts(k), + }); + if (entry.kind === "folder") { + const result = await this.client.list( + workspaceId, + entry.entry, + {}, + signal, + ); + if ( + !this.current(generation) || pageGeneration !== this.pageGeneration + ) return; + this.publish({ entries: result.entries, nextAfter: result.next_after }); + } else if (isText(entry)) { + const result = await this.client.readText( + workspaceId, + entry.entry, + signal, + ); + if (!this.current(generation)) return; + let draft = this.drafts.get(k) ?? null; + // Clean acknowledged drafts may refresh, but dirty/conflicted drafts + // and unresolved requests must keep their original CAS revision. + const unresolved = this.currentReceipts(k).some((receipt) => + receipt.state === "pending" || receipt.state === "unknown" + ); + const refreshable = !draft || + (draft.text === draft.baseText && !draft.conflict && !unresolved); + if (refreshable && !unresolved) { + draft = null; + this.drafts.delete(k); + } + if ( + !draft && !unresolved && isText(result.entry) && !result.truncated && + (result.entry.size ?? Infinity) <= DRIVE_TEXT_MAX_BYTES + ) { + draft = { + text: result.text, + baseText: result.text, + expectedRevision: result.entry.revision, + contentType: result.entry.content_type ?? "text/plain", + conflict: false, + }; + this.drafts.set(k, draft); + } + this.publish({ + entry: result.entry, + text: isText(result.entry) ? result.text : null, + truncated: result.truncated, + draft: isText(result.entry) ? draft : null, + }); + } else if ( + isImage(entry) && (entry.size ?? Infinity) <= DRIVE_RESPONSE_MAX_BYTES + ) { + const image = await this.client.image(workspaceId, entry, signal); + if (!this.current(generation)) return; + this.publish({ image }); + } + if (this.current(generation) && pageGeneration === this.pageGeneration) { + this.publish({ loading: false }); + } + } catch (error) { + if (this.current(generation) && pageGeneration === this.pageGeneration) { + this.publish({ loading: false, error: message(error) }); + } + } + } + async refresh(): Promise { + const s = this.selection(); + await this.select(s.ws, s.entry.entry); + } + async search(query: string, includeText = false): Promise { + const s = this.selection(); + const pageGeneration = ++this.pageGeneration; + this.publish({ + search: { query, includeText }, + entries: [], + nextAfter: null, + loading: true, + error: null, + }); + try { + const result = await this.client.search( + s.ws, + query, + includeText, + {}, + s.signal, + ); + if ( + !this.current(s.generation) || pageGeneration !== this.pageGeneration + ) return; + this.publish({ + entries: result.entries, + nextAfter: result.next_after, + loading: false, + }); + } catch (error) { + if ( + this.current(s.generation) && pageGeneration === this.pageGeneration + ) this.publish({ loading: false, error: message(error) }); + } + } + async loadMore(): Promise { + const s = this.selection(); + const after = this.value.nextAfter; + if (!after || this.value.loading) return; + const search = this.value.search; + const pageGeneration = ++this.pageGeneration; + this.publish({ loading: true, error: null }); + try { + const result = search + ? await this.client.search(s.ws, search.query, search.includeText, { + after, + }, s.signal) + : await this.client.list(s.ws, s.entry.entry, { after }, s.signal); + if ( + !this.current(s.generation) || pageGeneration !== this.pageGeneration + ) return; + const entries = new Map( + this.value.entries.map((entry) => [entry.entry.node_id, entry]), + ); + for (const entry of result.entries) { + entries.set(entry.entry.node_id, entry); + } + this.publish({ + entries: [...entries.values()], + nextAfter: result.next_after, + loading: false, + }); + } catch (error) { + if ( + this.current(s.generation) && pageGeneration === this.pageGeneration + ) this.publish({ loading: false, error: message(error) }); + } + } + edit(text: string): void { + const s = this.selection(); + const existing = this.drafts.get(s.key); + if (!existing || !isText(s.entry)) { + throw new Error("This file has no editable bounded text draft"); + } + const draft = { ...existing, text }; + this.drafts.set(s.key, draft); + this.publish({ draft }); + } + /** Explicit user discard/reload, never performed as conflict recovery. */ + async discardDraft(): Promise { + const s = this.selection(); + if ( + this.currentReceipts(s.key).some((r) => + r.state === "unknown" || r.state === "pending" + ) + ) throw new Error("Reconcile the pending request before discarding"); + this.drafts.delete(s.key); + await this.refresh(); + } + async save(): Promise { + const s = this.selection(); + const draft = this.drafts.get(s.key); + if (!draft || !isText(s.entry)) throw new Error("No editable draft"); + const signal = this.writeAbort.signal; + return await this.write( + "update_text", + (id) => + this.client.mutate(s.ws, id, { + operation: "update_text", + id: s.entry.entry, + expected_revision: draft.expectedRevision, + text: draft.text, + content_type: draft.contentType, + }, signal), + { text: draft.text, revision: draft.expectedRevision }, + ); + } + async mutate(mutation: DriveMutation): Promise { + const s = this.selection(); + const signal = this.writeAbort.signal; + return await this.write( + mutation.operation, + (id) => this.client.mutate(s.ws, id, mutation, signal), + ); + } + async upload(blob: Blob, target: DriveUploadTarget): Promise { + const s = this.selection(); + const signal = this.writeAbort.signal; + return await this.write( + "upload", + (id) => this.client.upload(s.ws, id, blob, target, signal), + ); + } + private async write( + operation: DriveReceipt["operation"], + send: (id: string) => Promise, + saved?: ReceiptRecord["saved"], + ): Promise { + const s = this.selection(); + const writeGeneration = this.writeGeneration; + const active = () => + this.current(s.generation) && writeGeneration === this.writeGeneration; + if ( + this.currentReceipts(s.key).some((r) => + r.state === "pending" || r.state === "unknown" + ) + ) throw new Error("Reconcile the existing request; do not submit again"); + const id = this.newRequestId(); + const records = this.receipts.get(s.key) ?? + new Map(); + if (records.has(id)) throw new Error("Request ID must be unique"); + const record: ReceiptRecord = { + receipt: { + requestId: id, + operation, + state: "pending", + error: null, + response: null, + }, + saved, + }; + records.set(id, record); + this.receipts.set(s.key, records); + this.publish({ receipts: this.currentReceipts(s.key), error: null }); + try { + const response = await send(id); + if (!active()) return id; + this.committed(s.key, record, response); + } catch (error) { + if (!active()) return id; + // Non-typed errors include preflight validation (no transmission). The real + // client wraps every failure after fetch as typed unknown. + const unknown = error instanceof DriveRequestError && + error.classification === "unknown"; + record.receipt = { + ...record.receipt, + state: unknown ? "unknown" : "not_committed", + error: message(error), + }; + if (error instanceof DriveRequestError && error.code === "conflict") { + const draft = this.drafts.get(s.key); + if (draft) { + const conflict = { ...draft, conflict: true }; + this.drafts.set(s.key, conflict); + this.publish({ draft: conflict }); + } + } + this.publish({ + receipts: this.currentReceipts(s.key), + error: message(error), + }); + } + return id; + } + private committed( + k: string, + record: ReceiptRecord, + response: DriveMutationResponse, + ): void { + record.receipt = { + ...record.receipt, + state: "committed", + response, + error: null, + }; + const entry = response.entry; + const draft = this.drafts.get(k); + if ( + record.saved && draft && entry && + draft.expectedRevision === record.saved.revision && + entry.entry.node_id === this.value.entry?.entry.node_id + ) { + const updated = { + ...draft, + baseText: record.saved.text, + expectedRevision: entry.revision, + conflict: false, + }; + this.drafts.set(k, updated); + this.publish({ draft: updated, entry, text: record.saved.text }); + } else if ( + entry && entry.entry.node_id === this.value.entry?.entry.node_id + ) this.publish({ entry }); + this.publish({ receipts: this.currentReceipts(k), error: null }); + } + async reconcile(requestId: string): Promise { + const s = this.selection(); + const record = this.receipts.get(s.key)?.get(requestId); + if (!record || record.receipt.state !== "unknown") { + throw new Error("No unknown request to reconcile"); + } + try { + const status = await this.client.status( + s.ws, + requestId, + this.writeAbort.signal, + ); + if (!this.current(s.generation)) return; + if (status.state === "committed" && status.response) { + this.committed(s.key, record, status.response); + } else { + record.receipt = { + ...record.receipt, + error: "No commit observed yet; outcome remains unknown", + }; + this.publish({ receipts: this.currentReceipts(s.key) }); + } + } catch (error) { + if (!this.current(s.generation)) return; + record.receipt = { ...record.receipt, error: message(error) }; + this.publish({ receipts: this.currentReceipts(s.key) }); + } + } + dispose(): void { + this.fence(); + this.disposed = true; + this.listeners.clear(); + } +} +export function isText(entry: DriveEntry): boolean { + return entry.kind === "file" && + ["text/plain", "text/markdown"].includes(entry.content_type ?? ""); +} +export function isImage(entry: DriveEntry): boolean { + return entry.kind === "file" && + /^image\/(png|jpeg|gif|webp)$/.test(entry.content_type ?? ""); +} diff --git a/web/workspace/src/lib/workspace/drive/controller.test.ts b/web/workspace/src/lib/workspace/drive/controller.test.ts new file mode 100644 index 000000000..5e2b104b1 --- /dev/null +++ b/web/workspace/src/lib/workspace/drive/controller.test.ts @@ -0,0 +1,757 @@ +import type { + DriveMutationResponse, + DriveReadTextResponse, + DriveRequestStatusResponse, +} from "#lib/generated/drive-api.ts"; +import { type DriveClient, DriveRequestError } from "./api.ts"; +import { DriveController } from "./controller.svelte.ts"; +import { + assert, + deferred, + entry, + equal, + ref, + rejects, + throws, +} from "./test-fixtures.ts"; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; +function client(patch: Partial = {}): DriveClient { + return { + root: (ws) => Promise.resolve(entry("1", ws, "1", "folder")), + metadata: (ws, target) => + Promise.resolve( + entry( + target.node_id, + ws, + "9007199254740993", + target.node_id === "1" ? "folder" : "file", + ), + ), + list: () => Promise.resolve({ entries: [], next_after: null }), + search: () => Promise.resolve({ entries: [], next_after: null }), + readText: (ws, target) => + Promise.resolve({ + entry: entry(target.node_id, ws), + text: "abc", + truncated: false, + }), + image: () => Promise.resolve(new Blob(["abc"])), + mutate: (ws, id) => + Promise.resolve({ + request_id: id, + entry: entry("2", ws, "9007199254740994"), + }), + upload: (ws, id) => + Promise.resolve({ + request_id: id, + entry: entry("2", ws, "9007199254740994"), + }), + status: (_, id) => + Promise.resolve({ request_id: id, state: "uncommitted", response: null }), + ...patch, + }; +} +function controller(patch: Partial = {}) { + let n = 0; + return new DriveController(client(patch), () => `req-${++n}`); +} +Deno.test("Drive delayed read cannot replace workspace with the same numeric node id", async () => { + const old = deferred(); + const started = deferred(); + const c = controller({ + readText: (ws, target) => { + if (ws === "alpha") { + started.resolve(); + return old.promise; + } + return Promise.resolve({ + entry: entry(target.node_id, ws), + text: "beta", + truncated: false, + }); + }, + }); + const first = c.select("alpha", ref()); + await started.promise; + await c.select("beta", ref("2", "beta")); + old.resolve({ entry: entry(), text: "old", truncated: false }); + await first; + equal(c.state.workspaceId, "beta"); + equal(c.state.text, "beta"); + equal(c.state.draft?.text, "beta"); +}); +Deno.test("Drive A to B to A selection rejects the first A response and late read errors", async () => { + const old = deferred(); + const started = deferred(); + let reads = 0; + const c = controller({ + readText: (ws, target) => { + if (++reads === 1) { + started.resolve(); + return old.promise; + } + return Promise.resolve({ + entry: entry(target.node_id, ws), + text: "fresh", + truncated: false, + }); + }, + }); + const first = c.select("alpha", ref()); + await started.promise; + await c.select("alpha", ref("3")); + await c.select("alpha", ref()); + old.reject(new Error("late")); + await first; + equal(c.state.entry?.entry.node_id, "2"); + equal(c.state.text, "fresh"); + equal(c.state.error, null); +}); +Deno.test("Drive drafts survive workspace and node switches without revision rebasing", async () => { + let revision = "9007199254740993"; + const c = controller({ + readText: (ws, target) => + Promise.resolve({ + entry: entry(target.node_id, ws, revision), + text: "server", + truncated: false, + }), + }); + await c.select("alpha", ref()); + c.edit("local alpha"); + await c.select("beta", ref("2", "beta")); + c.edit("local beta"); + revision = "9007199254740999"; + await c.select("alpha", ref("3")); + await c.select("alpha", ref()); + equal(c.state.draft?.text, "local alpha"); + equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.entry?.revision, "9007199254740999"); + await c.select("beta", ref("2", "beta")); + equal(c.state.draft?.text, "local beta"); +}); +Deno.test("Drive conflict keeps local text and old CAS revision across switching without retry", async () => { + let mutations = 0; + const c = controller({ + mutate: (_, __, mutation) => { + ++mutations; + assert("expected_revision" in mutation); + equal(mutation.expected_revision, "9007199254740993"); + return Promise.reject(new DriveRequestError("conflict", "not_committed")); + }, + }); + await c.select("alpha", ref()); + c.edit("local"); + await c.save(); + equal(c.state.draft?.conflict, true); + equal(c.state.draft?.text, "local"); + await c.select("alpha", ref("3")); + await c.select("alpha", ref()); + equal(c.state.draft?.conflict, true); + equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(mutations, 1); +}); +Deno.test("Drive acknowledged save preserves edits made while the request was in flight", async () => { + const pending = deferred(); + const c = controller({ mutate: () => pending.promise }); + await c.select("alpha", ref()); + c.edit("sent"); + const saving = c.save(); + c.edit("new edit"); + pending.resolve({ + request_id: "req-1", + entry: entry("2", "alpha", "9007199254740994"), + }); + await saving; + equal(c.state.draft?.text, "new edit"); + equal(c.state.draft?.baseText, "sent"); + equal(c.state.draft?.expectedRevision, "9007199254740994"); +}); +Deno.test("Drive late mutation and upload success cannot affect a newly selected identity", async () => { + for (const operation of ["mutation", "upload"] as const) { + const pending = deferred(); + const c = controller({ + mutate: () => pending.promise, + upload: () => pending.promise, + }); + await c.select("alpha", ref()); + c.edit("sent"); + const sending = operation === "mutation" + ? c.save() + : c.upload(new Blob(["abc"]), { + operation: "update", + id: ref(), + expected_revision: "9007199254740993", + content_type: "text/plain", + }); + await c.select("beta", ref("2", "beta")); + pending.resolve({ + request_id: "req-1", + entry: entry("2", "alpha", "9007199254740994"), + }); + await sending; + equal(c.state.workspaceId, "beta"); + equal(c.state.receipts, []); + equal(c.state.draft?.expectedRevision, "9007199254740993"); + await c.select("alpha", ref()); + equal(c.state.receipts[0].state, "unknown"); + equal(c.state.draft?.text, "sent"); + equal(c.state.draft?.expectedRevision, "9007199254740993"); + } +}); +Deno.test("Drive unknown upload reconciles by request id and never blindly retransmits", async () => { + let uploads = 0; + let statuses = 0; + let committed = false; + const c = controller({ + upload: () => { + ++uploads; + return Promise.reject( + new DriveRequestError("outcome_unknown", "unknown"), + ); + }, + status: (_, id) => { + ++statuses; + equal(id, "req-1"); + return Promise.resolve( + committed + ? { + request_id: id, + state: "committed", + response: { + request_id: id, + entry: entry("2", "alpha", "9007199254740994"), + }, + } + : { request_id: id, state: "uncommitted", response: null }, + ); + }, + }); + await c.select("alpha", ref()); + const target = { + operation: "update" as const, + id: ref(), + expected_revision: "9007199254740993", + content_type: "application/octet-stream", + }; + const id = await c.upload(new Blob(["abc"]), target); + equal(id, "req-1"); + equal(c.state.receipts[0].state, "unknown"); + await c.reconcile(id); + equal(c.state.receipts[0].state, "unknown"); + await rejects(() => c.upload(new Blob(["abc"]), target)); + equal(uploads, 1); + committed = true; + await c.reconcile(id); + equal(c.state.receipts[0].state, "committed"); + equal(uploads, 1); + equal(statuses, 2); +}); +Deno.test("Drive late status response is fenced even when the original node is reselected", async () => { + const status = deferred(); + const c = controller({ + mutate: () => + Promise.reject(new DriveRequestError("outcome_unknown", "unknown")), + status: () => status.promise, + }); + await c.select("alpha", ref()); + c.edit("local"); + const id = await c.save(); + const checking = c.reconcile(id); + await c.select("alpha", ref("3")); + await c.select("alpha", ref()); + status.resolve({ + request_id: id, + state: "committed", + response: { + request_id: id, + entry: entry("2", "alpha", "9007199254740994"), + }, + }); + await checking; + equal(c.state.receipts[0].state, "unknown"); + equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.text, "local"); +}); +Deno.test("Drive truncated text has no editable draft and disposal rejects late publication", async () => { + const c = controller({ + readText: () => + Promise.resolve({ entry: entry(), text: "abc", truncated: true }), + }); + await c.select("alpha", ref()); + equal(c.state.draft, null); + await rejects(() => c.save()); + const late = deferred(); + const started = deferred(); + const disposed = controller({ + readText: () => { + started.resolve(); + return late.promise; + }, + }); + let notifications = 0; + disposed.subscribe(() => ++notifications); + const selection = disposed.select("alpha", ref()); + await started.promise; + disposed.dispose(); + const before = notifications; + late.resolve({ entry: entry(), text: "late", truncated: false }); + await selection; + equal(notifications, before); + equal(disposed.state.text, null); +}); +Deno.test("Drive search and pagination ignore results from the previous identity", async () => { + const search = deferred< + { entries: ReturnType[]; next_after: string | null } + >(); + const c = controller({ search: () => search.promise }); + await c.select("alpha", ref("1")); + const searching = c.search("old"); + await c.select("beta", ref("1", "beta")); + search.resolve({ entries: [entry()], next_after: "alpha:2" }); + await searching; + equal(c.state.entries, []); + equal(c.state.search, null); + equal(c.state.nextAfter, null); +}); +Deno.test("Drive late image preview does not publish into another workspace", async () => { + const image = deferred(); + const started = deferred(); + const c = controller({ + metadata: (ws, target) => + Promise.resolve({ + ...entry(target.node_id, ws), + content_type: "image/png", + }), + image: (ws) => { + if (ws === "alpha") { + started.resolve(); + return image.promise; + } + return Promise.resolve(new Blob(["new"])); + }, + }); + const selecting = c.select("alpha", ref()); + await started.promise; + await c.select("beta", ref("2", "beta")); + const current = c.state.image; + image.resolve(new Blob(["old"])); + await selecting; + assert(c.state.image === current); +}); +Deno.test("Drive delayed initial list cannot overwrite a newer search in the same folder", async () => { + const list = deferred< + { entries: ReturnType[]; next_after: string | null } + >(); + const started = deferred(); + const c = controller({ + list: () => { + started.resolve(); + return list.promise; + }, + search: () => + Promise.resolve({ entries: [entry("4")], next_after: "alpha:4" }), + }); + const selecting = c.select("alpha", ref("1")); + await started.promise; + await c.search("new"); + list.resolve({ entries: [entry("2")], next_after: "alpha:2" }); + await selecting; + equal(c.state.entries.map((e) => e.entry.node_id), ["4"]); + equal(c.state.nextAfter, "alpha:4"); + equal(c.state.search?.query, "new"); +}); +Deno.test("Drive delayed root and metadata are fenced before publishing selection", async () => { + for (const kind of ["root", "metadata"] as const) { + const old = deferred>(); + const c = controller({ + [kind]: (ws: string) => + ws === "alpha" + ? old.promise + : Promise.resolve(entry("1", ws, "1", "folder")), + }); + const selecting = c.select( + "alpha", + kind === "metadata" ? ref("1") : undefined, + ); + await c.select("beta", ref("1", "beta")); + old.resolve(entry("1", "alpha", "1", "folder")); + await selecting; + equal(c.state.entry?.entry.workspace_id, "beta"); + equal(c.state.error, null); + } +}); +Deno.test("Drive pagination completion cannot publish after workspace switch", async () => { + const late = deferred< + { entries: ReturnType[]; next_after: string | null } + >(); + const c = controller({ + list: (ws, _, options) => + options?.after ? late.promise : Promise.resolve({ + entries: [], + next_after: ws === "alpha" ? "alpha:2" : null, + }), + }); + await c.select("alpha", ref("1")); + const loading = c.loadMore(); + await c.select("beta", ref("1", "beta")); + late.resolve({ entries: [entry()], next_after: "alpha:3" }); + await loading; + equal(c.state.entries, []); + equal(c.state.nextAfter, null); + equal(c.state.loading, false); +}); +Deno.test("Drive late mutation conflict cannot mark the new node's draft", async () => { + const late = deferred(); + const c = controller({ mutate: () => late.promise }); + await c.select("alpha", ref()); + const saving = c.save(); + await c.select("alpha", ref("3")); + late.reject(new DriveRequestError("conflict", "not_committed")); + await saving; + equal(c.state.draft?.conflict, false); + equal(c.state.error, null); +}); +Deno.test("Drive cancelled writes retain draft and receipt and ignore late completion after committed status", async () => { + for (const operation of ["save", "upload"] as const) { + const pending = deferred(); + let writeSignal: AbortSignal | undefined; + let statusSignal: AbortSignal | undefined; + let statuses = 0; + let sends = 0; + const send = ( + _ws: string, + _id: string, + _body: unknown, + signal?: AbortSignal, + ) => { + ++sends; + writeSignal = signal; + return pending.promise; + }; + const c = controller({ + mutate: send, + upload: (_ws, _id, _blob, _target, signal) => + send(_ws, _id, _blob, signal), + status: (_ws, id, signal) => { + statusSignal = signal; + ++statuses; + return Promise.resolve( + statuses === 1 + ? { request_id: id, state: "uncommitted", response: null } + : { + request_id: id, + state: "committed", + response: { + request_id: id, + entry: entry("2", "alpha", "9007199254740994"), + }, + }, + ); + }, + }); + await c.select("alpha", ref()); + c.edit("sent draft"); + const sending = operation === "save" + ? c.save() + : c.upload(new Blob(["abc"]), { + operation: "update", + id: ref(), + expected_revision: "9007199254740993", + content_type: "text/plain", + }); + equal(c.state.receipts[0].state, "pending"); + const id = c.state.receipts[0].requestId; + c.cancelPending(); + assert(writeSignal?.aborted); + equal(c.state.receipts[0].requestId, id); + equal(c.state.receipts[0].state, "unknown"); + equal(c.state.draft?.text, "sent draft"); + equal(c.state.draft?.expectedRevision, "9007199254740993"); + await rejects(() => c.save()); + await c.reconcile(id); + assert( + statusSignal && !statusSignal.aborted && statusSignal !== writeSignal, + ); + equal(c.state.receipts[0].state, "unknown"); + await rejects(() => + c.upload(new Blob(["abc"]), { + operation: "create", + parent: ref("1"), + name: "note.txt", + content_type: "text/plain", + }) + ); + await c.reconcile(id); + equal(c.state.receipts[0].state, "committed"); + const snapshot = c.state; + pending.resolve({ + request_id: id, + entry: entry("2", "alpha", "9007199254740999"), + }); + equal(await sending, id); + assert(c.state === snapshot); + equal(sends, 1); + equal(c.state.entry?.revision, "9007199254740994"); + } +}); +Deno.test("Drive cancellation does not abort or fence active search reads", async () => { + const search = deferred< + { entries: ReturnType[]; next_after: string | null } + >(); + const upload = deferred(); + let readSignal: AbortSignal | undefined; + let writeSignal: AbortSignal | undefined; + const c = controller({ + search: (_ws, _text, _include, _options, signal) => { + readSignal = signal; + return search.promise; + }, + upload: (_ws, _id, _blob, _target, signal) => { + writeSignal = signal; + return upload.promise; + }, + }); + await c.select("alpha", ref("1")); + const searching = c.search("read still active"); + const uploading = c.upload(new Blob(["abc"]), { + operation: "create", + parent: ref("1"), + name: "note.txt", + content_type: "text/plain", + }); + c.cancelPending(); + assert(writeSignal?.aborted); + assert(readSignal && !readSignal.aborted); + search.resolve({ entries: [entry("3")], next_after: null }); + await searching; + equal(c.state.entries[0].entry.node_id, "3"); + equal(c.state.loading, false); + upload.reject(new DriveRequestError("conflict", "not_committed")); + await uploading; + equal(c.state.receipts[0].state, "unknown"); + equal(c.state.error, null); +}); +Deno.test("Drive concurrent writes are blocked before transmission and receipt creation", async () => { + const pending = deferred(); + let sends = 0; + const c = controller({ + mutate: () => { + ++sends; + return pending.promise; + }, + upload: () => { + ++sends; + return pending.promise; + }, + }); + await c.select("alpha", ref()); + const saving = c.save(); + await rejects(() => c.save()); + await rejects(() => + c.mutate({ + operation: "delete", + id: ref(), + expected_revision: "9007199254740993", + }) + ); + await rejects(() => + c.upload(new Blob(["abc"]), { + operation: "update", + id: ref(), + expected_revision: "9007199254740993", + content_type: "text/plain", + }) + ); + equal(sends, 1); + equal(c.state.receipts.length, 1); + pending.resolve({ + request_id: "req-1", + entry: entry("2", "alpha", "9007199254740994"), + }); + await saving; +}); +Deno.test("Drive clean draft refresh adopts latest revision unless a request remains unresolved", async () => { + let revision = "9007199254740993"; + let text = "abc"; + const c = controller({ + readText: () => + Promise.resolve({ + entry: entry("2", "alpha", revision), + text, + truncated: false, + }), + mutate: () => + Promise.reject(new DriveRequestError("outcome_unknown", "unknown")), + }); + await c.select("alpha", ref()); + revision = "9007199254740994"; + text = "new"; + await c.refresh(); + equal(c.state.draft?.text, "new"); + equal(c.state.draft?.baseText, "new"); + equal(c.state.draft?.expectedRevision, revision); + await c.save(); + revision = "9007199254740995"; + text = "unknown change"; + await c.refresh(); + equal(c.state.text, "unknown change"); + equal(c.state.draft?.text, "new"); + equal(c.state.draft?.expectedRevision, "9007199254740994"); + equal(c.state.receipts[0].state, "unknown"); + await rejects(() => c.save()); +}); +Deno.test("Drive previews allow plain text markdown and four raster types only", async () => { + for ( + const contentType of [ + "text/plain", + "text/markdown", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "text/html", + "text/javascript", + "text/xml", + "text/css", + "application/json", + "application/xml", + "application/javascript", + "image/svg+xml", + "image/avif", + ] + ) { + let textReads = 0; + let imageReads = 0; + const c = controller({ + metadata: () => + Promise.resolve({ ...entry(), content_type: contentType }), + readText: () => { + ++textReads; + return Promise.resolve({ + entry: { ...entry(), content_type: contentType }, + text: "abc", + truncated: false, + }); + }, + image: () => { + ++imageReads; + return Promise.resolve(new Blob(["abc"])); + }, + }); + await c.select("alpha", ref()); + equal( + textReads, + ["text/plain", "text/markdown"].includes(contentType) ? 1 : 0, + ); + equal( + imageReads, + ["image/png", "image/jpeg", "image/gif", "image/webp"].includes( + contentType, + ) + ? 1 + : 0, + ); + if (textReads === 0) { + equal(c.state.text, null); + equal(c.state.draft, null); + } + } +}); +Deno.test("Drive cancelled late success stays unknown until explicit committed status lookup", async () => { + const late = deferred(); + let statusCalls = 0; + const c = controller({ + mutate: () => late.promise, + status: (_ws, id) => { + ++statusCalls; + return Promise.resolve({ + request_id: id, + state: "committed", + response: { + request_id: id, + entry: entry("2", "alpha", "9007199254740994"), + }, + }); + }, + }); + await c.select("alpha", ref()); + c.edit("local"); + const saving = c.save(); + c.cancelPending(); + late.resolve({ + request_id: "req-1", + entry: entry("2", "alpha", "9007199254740994"), + }); + const id = await saving; + equal(c.state.receipts[0].state, "unknown"); + equal(c.state.draft?.expectedRevision, "9007199254740993"); + equal(c.state.draft?.text, "local"); + equal(statusCalls, 0); + await rejects(() => c.save()); + await c.reconcile(id); + equal(c.state.receipts[0].state, "committed"); + equal(c.state.draft?.expectedRevision, "9007199254740994"); + equal(c.state.draft?.baseText, "local"); +}); +Deno.test("Drive archived dirty draft cannot enable editing after MIME changes to active bytes", async () => { + let contentType = "text/plain"; + let textReads = 0; + const c = controller({ + metadata: () => Promise.resolve({ ...entry(), content_type: contentType }), + readText: () => { + ++textReads; + return Promise.resolve({ + entry: { ...entry(), content_type: contentType }, + text: "abc", + truncated: false, + }); + }, + }); + await c.select("alpha", ref()); + c.edit("retain me"); + contentType = "text/html"; + await c.refresh(); + equal(textReads, 1); + equal(c.state.draft, null); + equal(c.state.text, null); + throws(() => c.edit("unsafe edit")); + await rejects(() => c.save()); + contentType = "text/plain"; + await c.refresh(); + equal(c.state.draft?.text, "retain me"); + equal(c.state.draft?.expectedRevision, "9007199254740993"); +}); +Deno.test("Drive conflicted clean draft stays fixed while clean truncated reread removes editor", async () => { + let revision = "9007199254740993"; + let truncated = false; + const readText = () => + Promise.resolve({ + entry: entry("2", "alpha", revision), + text: "abc", + truncated, + }); + const conflict = controller({ + readText, + mutate: () => + Promise.reject(new DriveRequestError("conflict", "not_committed")), + }); + await conflict.select("alpha", ref()); + await conflict.save(); + revision = "9007199254740994"; + await conflict.refresh(); + equal(conflict.state.draft?.text, "abc"); + equal(conflict.state.draft?.baseText, "abc"); + equal(conflict.state.draft?.conflict, true); + equal(conflict.state.draft?.expectedRevision, "9007199254740993"); + const clean = controller({ readText }); + await clean.select("alpha", ref()); + assert(clean.state.draft); + truncated = true; + await clean.refresh(); + equal(clean.state.draft, null); + await rejects(() => clean.save()); +}); diff --git a/web/workspace/src/lib/workspace/drive/test-fixtures.ts b/web/workspace/src/lib/workspace/drive/test-fixtures.ts new file mode 100644 index 000000000..bfca13cba --- /dev/null +++ b/web/workspace/src/lib/workspace/drive/test-fixtures.ts @@ -0,0 +1,66 @@ +import type { DriveEntry, DriveEntryRef } from "./api.ts"; +export function assert( + condition: unknown, + message = "Assertion failed", +): asserts condition { + if (!condition) throw new Error(message); +} +export function equal(actual: unknown, expected: unknown): void { + assert( + JSON.stringify(actual) === JSON.stringify(expected), + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); +} +export function throws(fn: () => unknown): void { + try { + fn(); + } catch { + return; + } + throw new Error("Expected rejection"); +} +export async function rejects( + fn: () => Promise, + check?: (error: unknown) => void, +): Promise { + try { + await fn(); + } catch (error) { + check?.(error); + return; + } + throw new Error("Expected rejection"); +} +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +export function ref(id = "2", ws = "alpha"): DriveEntryRef { + return { workspace_id: ws, node_id: id }; +} +export function entry( + id = "2", + ws = "alpha", + revision = "9007199254740993", + kind: "file" | "folder" = "file", +): DriveEntry { + return { + entry: ref(id, ws), + parent: id === "1" ? null : ref("1", ws), + name: id === "1" ? "" : "note.txt", + kind, + revision, + size: kind === "file" ? 3 : null, + content_type: kind === "file" ? "text/plain" : null, + updated_by: "user:test", + updated_at: "2026-10-09T00:00:00Z", + latest_url: `/api/w/${ws}/drive/${ + kind === "file" ? "download" : "metadata" + }?entry_workspace_id=${ws}&id=${id}`, + }; +} diff --git a/web/workspace/src/lib/workspace/markdown/DocumentMarkdown.svelte b/web/workspace/src/lib/workspace/markdown/DocumentMarkdown.svelte index 77add1dce..3b6b82bbf 100644 --- a/web/workspace/src/lib/workspace/markdown/DocumentMarkdown.svelte +++ b/web/workspace/src/lib/workspace/markdown/DocumentMarkdown.svelte @@ -4,12 +4,13 @@ import MarkdownTable from "./MarkdownTable.svelte"; import SafeMarkdown from "./SafeMarkdown.svelte"; - let { text }: { text: string } = $props(); + let { text, imageRenderer }: { text: string; imageRenderer?: Renderers['image'] } = $props(); - const renderers = { + const renderers = $derived({ code: DocumentCodeBlock, table: MarkdownTable, - } satisfies Partial; + ...(imageRenderer ? { image: imageRenderer } : {}), + } satisfies Partial); diff --git a/web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte b/web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte index 7f3f13ada..e54e0e28b 100644 --- a/web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte +++ b/web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte @@ -1,30 +1,12 @@ - -{#if safeHref} - +{#if target} + {@render children?.()} {:else} diff --git a/web/workspace/src/lib/workspace/markdown/link-policy.ts b/web/workspace/src/lib/workspace/markdown/link-policy.ts new file mode 100644 index 000000000..3f494e262 --- /dev/null +++ b/web/workspace/src/lib/workspace/markdown/link-policy.ts @@ -0,0 +1,43 @@ +// Workspace resource links are navigation, never bearer credentials or fetch targets. +export const MARKDOWN_WORKSPACE_CONTEXT = "yoi-markdown-workspace"; +export type MarkdownWorkspace = () => string; +export type MarkdownLinkTarget = { href: string; external: boolean }; + +export function markdownLinkTarget( + value: string, + workspaceId?: string, +): MarkdownLinkTarget | null { + // Only explicit Workspace routes are accepted as relative links. Reject protocol-relative, + // encoded separators, traversal, query strings and arbitrary app/API endpoints. + if (value.startsWith("/w/") && !/[?#\\\s]/u.test(value)) { + const match = + /^\/w\/([^/]+)\/(drive|tickets|objectives|workers|memory)(?:\/([^/]+))?$/u + .exec(value); + if (!match) return null; + try { + const workspace = decodeURIComponent(match[1]); + const reference = match[3] ? decodeURIComponent(match[3]) : ""; + if ( + !workspace || /[/\\\s]/u.test(workspace) || /[/\\]/u.test(reference) || + reference === "." || reference === ".." + ) return null; + if (workspaceId !== undefined && workspace !== workspaceId) return null; + if ( + match[2] === "drive" && reference && !/^[1-9][0-9]*$/u.test(reference) + ) return null; + return { href: value, external: false }; + } catch { + return null; + } + } + try { + const url = new URL(value); + if ( + !["http:", "https:"].includes(url.protocol) || url.username || + url.password + ) return null; + return { href: value, external: true }; + } catch { + return null; + } +} diff --git a/web/workspace/src/lib/workspace/resource-links.ts b/web/workspace/src/lib/workspace/resource-links.ts index 509a663da..b1672a08a 100644 --- a/web/workspace/src/lib/workspace/resource-links.ts +++ b/web/workspace/src/lib/workspace/resource-links.ts @@ -1,3 +1,16 @@ +import type { DriveEntryRef } from "#lib/generated/drive-api.ts"; + +/** Human-facing latest-only link: name, path, revision and bearer tokens are absent. */ +export function driveHref(entry: DriveEntryRef): string { + if ( + !/^[1-9][0-9]{0,18}$/.test(entry.node_id) || + BigInt(entry.node_id) > 9223372036854775807n + ) { + throw new Error("Invalid Drive node ID"); + } + return `/w/${encodeURIComponent(entry.workspace_id)}/drive/${entry.node_id}`; +} + const RESOURCE_KEY_PATTERN = /^(T|O|W)-(\d+)/; export function resourceKey(reference: string): string { @@ -28,22 +41,35 @@ export function ticketHref( workspaceId: string, ticket: { resource_key: string; title: string }, ): string { - return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.resource_key, ticket.title))}`; + return `/w/${encodeURIComponent(workspaceId)}/tickets/${ + encodeURIComponent( + canonicalResourceReference(ticket.resource_key, ticket.title), + ) + }`; } export function objectiveHref( workspaceId: string, objective: { resource_key: string; title: string }, ): string { - return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.resource_key, objective.title))}`; + return `/w/${encodeURIComponent(workspaceId)}/objectives/${ + encodeURIComponent( + canonicalResourceReference(objective.resource_key, objective.title), + ) + }`; } export function workerHref( workspaceId: string, worker: { resource_key: string; display_name: string }, ): string { - const reference = canonicalResourceReference(worker.resource_key, worker.display_name); - return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`; + const reference = canonicalResourceReference( + worker.resource_key, + worker.display_name, + ); + return `/w/${encodeURIComponent(workspaceId)}/workers/${ + encodeURIComponent(reference) + }`; } export function workerConsoleHref( diff --git a/web/workspace/src/lib/workspace/settings/workdir-removal.ts b/web/workspace/src/lib/workspace/settings/workdir-removal.ts new file mode 100644 index 000000000..4c7194e86 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/workdir-removal.ts @@ -0,0 +1,167 @@ +import type { WorkingDirectoryRemovalResponse } from "#lib/generated/workdir-api.ts"; +import type { + CleanupWorkdirCandidate, + WorkingDirectorySummary, +} from "#lib/workspace/sidebar/types.ts"; +import { workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { parseWorkingDirectoryRemovalResponse } from "#lib/workspace/api/workdirs.ts"; + +// Only reviewed category codes become public copy. Never render a provider message, +// category verbatim, OS error, or host path (including for unknown future codes). +const causes: Readonly> = { + mount_present: + "A mounted filesystem remains inside this Workdir. Ask its resource owner to verify ownership and safely release it before retrying. Unknown or shared mounts must not be forcibly unmounted.", + mount_check_unavailable: + "The Runtime could not check for mounted filesystems. Restore mount inspection on the Runtime before retrying.", + permission_denied: + "Removal permission was denied. Ask the Workspace or Runtime administrator to verify the required access; do not bypass protection with a forced deletion.", + resource_busy: + "A resource is still in use. Ask its owner to stop using it and release it safely before retrying.", + storage_unavailable: + "Runtime storage is unavailable. Restore storage access and capacity before retrying.", + ownership_unknown: + "Resource ownership could not be verified. Ask the Runtime administrator to verify ownership before any resource is released or removed.", + changes_present: + "This Workdir has changes. Preserve or clean those changes before requesting removal again.", + changes_unknown: + "Current changes could not be checked. Restore access so the Runtime can verify change protection before removal.", + provider_cleanup_failed: + "The provider could not finish removal. Ask the Runtime administrator to inspect the correlated removal diagnostics and resolve the cause before retrying.", + provider_observation_unknown: + "The provider could not confirm the remaining Workdir. Restore provider access so the next removal can recheck its current contents.", + provider_cleanup_outcome_unknown: + "The provider has not confirmed deletion. Restore provider access and retry the same removal request to recheck the remaining target. The Workdir stays listed until deletion is confirmed.", + authority_invalid: + "The removal authority could not be verified. Ask the Workspace administrator to verify the Workdir target and owning Runtime before requesting removal again.", + provider_identity_changed: + "The provider target identity changed. Ask the Runtime administrator to verify the target; do not delete a replacement resource.", + runtime_unavailable: + "The Runtime is unavailable. Restore its connection before retrying removal.", + provider_unavailable: + "The Workdir provider is unavailable. Restore its connection before retrying removal.", + unsupported_target: + "This target does not support ordinary removal. Ask its resource owner for the supported cleanup operation.", + blocked_by_live_authority: + "This Workdir has an active attachment, reservation, or hold. Release it through its owning operation before requesting removal again.", + dirty_or_unknown: + "Current change protection prevents removal. Preserve changes or restore change inspection before requesting removal again.", + authority_changed: + "Removal authority or current usage changed. Refresh the inventory and resolve current attachments, reservations, or holds before requesting removal again.", +}; +const unknownCause = + "Removal could not be confirmed. Check the Runtime's correlated removal diagnostics and resolve the cause before requesting removal again. The Workdir remains listed until deletion is confirmed."; + +export function removalCause(category: string | null | undefined): string { + return category != null && Object.hasOwn(causes, category) + ? causes[category] + : unknownCause; +} + +export function canRemoveWorkdir( + workdir: WorkingDirectorySummary, + candidate: CleanupWorkdirCandidate | undefined, +): boolean { + return Boolean( + candidate && !candidate.blocking_reason && !candidate.running_linked && + !candidate.pinned_linked && + (candidate.action === "workdir_clean_cleanup" || + candidate.action === "workdir_record_delete") && + workdir.cleanliness !== "dirty" && !workdir.occupied_by, + ); +} + +export function removalGuard( + workdir: WorkingDirectorySummary, + candidate: CleanupWorkdirCandidate | undefined, +): string { + if ( + workdir.occupied_by || candidate?.running_linked || + candidate?.pinned_linked || candidate?.blocking_reason + ) { + return "An attachment, reservation, or hold protects this Workdir. Release it through its owning operation, then refresh."; + } + if ( + workdir.cleanliness === "dirty" || + candidate?.action === "workdir_dirty_discard" + ) { + return "Changes are protected. Preserve or clean them, then refresh before deletion."; + } + return "Removal eligibility is unavailable. Refresh before deletion."; +} + +export class WorkdirRemovalError extends Error { + constructor(readonly category?: string) { + super(removalCause(category)); + } +} + +// Read only a small public DTO. Discard HTTP error bodies without interpreting or +// displaying them, and drain small bodies rather than aborting completed requests. +async function removalBody(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > 4096) { + await reader.cancel(); + throw new WorkdirRemovalError(); + } + chunks.push(value); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); + } finally { + reader.releaseLock(); + } +} + +export async function removeWorkdir( + fetchFn: typeof fetch, + workspaceId: string, + runtimeId: string, + workdirId: string, +): Promise { + const response = await fetchFn( + workspaceApiPath( + workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/working-directories/${ + encodeURIComponent(workdirId) + }`, + ), + { + method: "DELETE", + headers: { "content-type": "application/json" }, + // An ordinary repeated request uses the existing durable Backend removal authority. + body: JSON.stringify({ reason: "User requested Workdir deletion" }), + }, + ); + if (!response.ok) { + await removalBody(response).catch(() => undefined); + throw new WorkdirRemovalError( + response.status === 403 + ? "permission_denied" + : response.status === 409 + ? "authority_changed" + : response.status === 503 + ? "provider_unavailable" + : undefined, + ); + } + const result = parseWorkingDirectoryRemovalResponse( + JSON.parse(await removalBody(response)), + ); + if (result.working_directory_id !== workdirId) { + throw new WorkdirRemovalError(); + } + return result; +} diff --git a/web/workspace/src/lib/workspace/sidebar/DriveNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/DriveNavSection.svelte new file mode 100644 index 000000000..a01ce9ea2 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/DriveNavSection.svelte @@ -0,0 +1,9 @@ + + diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index 749d5f4e7..13b9da3ad 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -8,6 +8,7 @@ import MergeRequestsNavSection from './MergeRequestsNavSection.svelte'; import TicketsNavSection from './TicketsNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte'; + import DriveNavSection from './DriveNavSection.svelte'; import type { WorkspaceResponse } from './types'; type Props = { @@ -80,6 +81,7 @@ + diff --git a/web/workspace/src/routes/w/[workspaceId]/drive/[[nodeId]]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/drive/[[nodeId]]/+page.svelte new file mode 100644 index 000000000..691ddcaa4 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/drive/[[nodeId]]/+page.svelte @@ -0,0 +1,6 @@ + +Drive · Yoi Workspace + diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte index cb44d6047..6275f9d94 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte @@ -1,102 +1,89 @@