diff --git a/crates/workdir/src/delegation.rs b/crates/workdir/src/delegation.rs index cdbe7fdd..774c27f3 100644 --- a/crates/workdir/src/delegation.rs +++ b/crates/workdir/src/delegation.rs @@ -69,6 +69,42 @@ impl Drop for WorkdirDelegation { } } +pub struct AppliedWorkdirDelegation { + pub scoped_session: WorkdirSessionHandle, + _leases: Vec, +} + +impl std::fmt::Debug for AppliedWorkdirDelegation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppliedWorkdirDelegation") + .field("workdir", self.scoped_session.workdir()) + .field("lease_count", &self._leases.len()) + .finish() + } +} + +pub async fn apply_delegation_chain( + source: WorkdirSessionHandle, + requests: impl IntoIterator, +) -> Result { + let mut current = source; + let mut leases = Vec::new(); + for request in requests { + let authority = if current.is_delegation_capable() { + current.clone() + } else { + delegation_capable_session(current.clone()) + }; + let lease = authority.delegate(request).await?; + current = lease.scoped_session.clone(); + leases.push(lease); + } + Ok(AppliedWorkdirDelegation { + scoped_session: current, + _leases: leases, + }) +} + #[derive(Debug)] struct SessionValidity { active: AtomicBool, @@ -835,6 +871,38 @@ mod tests { )); } + #[tokio::test] + async fn applied_chain_cannot_replace_outer_provider_attenuation() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("outer")).unwrap(); + fs::create_dir_all(root.path().join("outside")).unwrap(); + let result = apply_delegation_chain( + Arc::new(LocalWorkdirSession::materialized_bound( + Workdir::new("delegation-chain-test"), + root.path().to_path_buf(), + root.path().to_path_buf(), + SharedScope::new( + Scope::from_config(&ScopeConfig { + allow: vec![ScopeRule { + target: root.path().to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: Vec::new(), + }) + .unwrap(), + ), + WorkdirSessionCapabilities::ALL, + )), + [ + request("outer", WorkdirDelegationPermission::Read), + request("outside", WorkdirDelegationPermission::Read), + ], + ) + .await; + assert!(matches!(result, Err(WorkdirError::Denied(_)))); + } + #[tokio::test] async fn closing_parent_invalidates_delegated_sessions() { let root = TempDir::new().unwrap(); diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 3483d30c..df3f3327 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -72,8 +72,8 @@ pub enum WorkdirSessionOperation { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorkdirSessionOperationRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delegations: Vec, pub operation: WorkdirSessionOperation, } @@ -216,7 +216,7 @@ mod client { workdir: Workdir, session_id: WorkdirSessionId, capabilities: WorkdirSessionCapabilities, - delegation: Option, + delegations: Vec, closed: AtomicBool, } @@ -269,7 +269,7 @@ mod client { workdir: Workdir::new(opened.workdir_id.as_str()), session_id: opened.session_id, capabilities: opened.capabilities, - delegation: None, + delegations: Vec::new(), closed: AtomicBool::new(false), }) } @@ -297,7 +297,7 @@ mod client { ], )?; let operation = WorkdirSessionOperationRequest { - delegation: self.delegation.clone(), + delegations: self.delegations.clone(), operation, }; let response = self @@ -335,6 +335,8 @@ mod client { if self.closed.load(Ordering::Acquire) { return Err(WorkdirError::SessionClosed); } + let mut delegations = self.delegations.clone(); + delegations.push(request.clone()); Ok(Arc::new(Self { client: self.client.clone(), base_url: self.base_url.clone(), @@ -342,7 +344,7 @@ mod client { workdir: self.workdir.clone(), session_id: self.session_id.clone(), capabilities: self.capabilities, - delegation: Some(request.clone()), + delegations, closed: AtomicBool::new(false), })) } diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index c68f8b52..590a8982 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -18,8 +18,9 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; pub use delegation::{ - ReadOnlyWorkdirSession, WorkdirDelegation, WorkdirDelegationPermission, - WorkdirDelegationRequest, WorkdirDelegationRule, delegation_capable_session, + AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation, + WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, + apply_delegation_chain, delegation_capable_session, }; pub use fs_operation::{ ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index 1f070bdc..d8e4aa28 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -320,8 +320,8 @@ mod tests { pub struct WorkspaceWorkdirSessionOperationRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_session_fence: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delegations: Vec, pub operation: crate::http::WorkdirSessionOperation, } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 48d31c86..41e67859 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -611,18 +611,8 @@ async fn run_workdir_session_operation( .ok_or_else(RuntimeHttpWorkdirError::not_found)?; record.session.clone() }; - let delegation = if let Some(delegation) = request.delegation { - Some( - workdir::delegation_capable_session(source.clone()) - .delegate(delegation) - .await?, - ) - } else { - None - }; - let session = delegation.as_ref().map_or(source.as_ref(), |delegation| { - delegation.scoped_session.as_ref() - }); + let applied = workdir::apply_delegation_chain(source, request.delegations).await?; + let session = applied.scoped_session.as_ref(); let operation = request.operation; let result = match operation { @@ -2279,7 +2269,7 @@ mod tests { expires_at: u64::MAX, }; let operation = WorkdirSessionOperationRequest { - delegation: None, + delegations: Vec::new(), operation: WorkdirSessionOperation::Stat(StatRequest { path: WorkdirPath::new("hello.txt").expect("logical path"), }), @@ -2298,14 +2288,14 @@ mod tests { #[cfg(unix)] { let delegated_read = WorkdirSessionOperationRequest { - delegation: Some(workdir::WorkdirDelegationRequest { + delegations: vec![workdir::WorkdirDelegationRequest { rules: vec![workdir::WorkdirDelegationRule { target: WorkdirPath::new("granted").unwrap(), permission: workdir::WorkdirDelegationPermission::Read, recursive: true, }], cwd: WorkdirPath::new("granted").unwrap(), - }), + }], operation: WorkdirSessionOperation::Read(ReadRequest { path: WorkdirPath::new("link").unwrap(), offset: 0, diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 2318b77c..c35365ff 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -155,7 +155,7 @@ pub struct WorkspaceAttachedWorkdirSession { client: Arc, workdir: Workdir, expected_session_fence: Option, - delegation: Option, + delegations: Vec, } impl WorkspaceAttachedWorkdirSession { @@ -164,7 +164,7 @@ impl WorkspaceAttachedWorkdirSession { client, workdir: Workdir::new("workspace-attachment"), expected_session_fence: None, - delegation: None, + delegations: Vec::new(), }) } @@ -183,7 +183,7 @@ impl WorkspaceAttachedWorkdirSession { ), serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { expected_session_fence: self.expected_session_fence.clone(), - delegation: self.delegation.clone(), + delegations: self.delegations.clone(), operation, }) .map_err(|error| { @@ -267,11 +267,13 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { })?; fence.value }; + let mut delegations = self.delegations.clone(); + delegations.push(request.clone()); Ok(Arc::new(Self { client: self.client.clone(), workdir: self.workdir.clone(), expected_session_fence: Some(expected_session_fence), - delegation: Some(request.clone()), + delegations, })) } @@ -1100,7 +1102,60 @@ mod tests { serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); assert_eq!(body["expected_session_fence"], "attachment-fence"); assert_eq!(body["operation"]["operation"], "stat"); - assert_eq!(body["delegation"]["rules"][0]["target"], ""); + assert_eq!(body["delegations"][0]["rules"][0]["target"], ""); + } + + #[tokio::test] + async fn nested_attached_session_preserves_full_delegation_chain() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![ + response(json!({"value": "attachment-fence"})), + response(json!({ + "operation": "stat", + "result": {"path": "nested/file", "kind": "file", "size": 1} + })), + ])); + let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle( + client.clone(), + )); + let outer = parent + .delegate(workdir::WorkdirDelegationRequest { + rules: vec![workdir::WorkdirDelegationRule { + target: workdir::WorkdirPath::new("").unwrap(), + permission: workdir::WorkdirDelegationPermission::Read, + recursive: true, + }], + cwd: workdir::WorkdirPath::new("").unwrap(), + }) + .await + .unwrap(); + let nested = outer + .scoped_session + .delegate(workdir::WorkdirDelegationRequest { + rules: vec![workdir::WorkdirDelegationRule { + target: workdir::WorkdirPath::new("nested").unwrap(), + permission: workdir::WorkdirDelegationPermission::Read, + recursive: true, + }], + cwd: workdir::WorkdirPath::new("nested").unwrap(), + }) + .await + .unwrap(); + nested + .scoped_session + .stat(StatRequest { + path: workdir::WorkdirPath::new("file").unwrap(), + }) + .await + .unwrap(); + + let requests = client.requests(); + assert_eq!(requests.len(), 2); + let body: serde_json::Value = + serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["delegations"].as_array().unwrap().len(), 2); + assert_eq!(body["delegations"][0]["rules"][0]["target"], ""); + assert_eq!(body["delegations"][1]["rules"][0]["target"], "nested"); + assert_eq!(body["operation"]["request"]["path"], "nested/file"); } #[test] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0db12f5f..e6b445b7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5139,24 +5139,14 @@ async fn scoped_execute_current_worker_workdir_operation( request.expected_session_fence.as_deref(), )?; let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?; - let delegation = if let Some(delegation) = request.delegation { - Some( - workdir::delegation_capable_session(source.clone()) - .delegate(delegation) - .await - .map_err(|error| Error::RuntimeOperationFailed { - runtime_id: worker.runtime_id.clone(), - code: "workdir_session_delegation_failed".to_string(), - message: error.to_string(), - })?, - ) - } else { - None - }; - let session = delegation - .as_ref() - .map_or(&source, |delegation| &delegation.scoped_session); - let result = execute_workdir_session_operation(session, request.operation) + let applied = workdir::apply_delegation_chain(source, request.delegations) + .await + .map_err(|error| Error::RuntimeOperationFailed { + runtime_id: worker.runtime_id.clone(), + code: "workdir_session_delegation_failed".to_string(), + message: error.to_string(), + })?; + let result = execute_workdir_session_operation(&applied.scoped_session, request.operation) .await .map_err(|error| Error::RuntimeOperationFailed { runtime_id: worker.runtime_id.clone(),