diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 70b6a6af..99d72538 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -12,8 +12,10 @@ use workspace_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, - ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, + ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest, + RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse, + TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, + WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, }; use crate::{BackendApiClient, BackendWorkspaceClientError}; @@ -241,6 +243,53 @@ impl BackendWorkspaceProductClient { ) } + pub fn list_runtimes( + &self, + ) -> Result, BackendWorkspaceClientError> { + self.get_json("/runtimes") + } + + pub fn runtime_detail( + &self, + runtime_id: &str, + ) -> Result { + self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id))) + } + + pub fn reveal_runtime_trust_key( + &self, + runtime_id: &str, + ) -> Result { + self.get_json(&format!( + "/runtimes/{}/trust-key", + encode_path_segment(runtime_id) + )) + } + + pub fn put_runtime_trust_key( + &self, + runtime_id: &str, + request: &PutRuntimeTrustKeyRequest, + ) -> Result { + self.send_json( + Method::PUT, + &format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)), + Some(request), + ) + } + + pub fn revoke_runtime_trust_key( + &self, + runtime_id: &str, + request: &RevokeRuntimeTrustKeyRequest, + ) -> Result { + self.send_json( + Method::DELETE, + &format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)), + Some(request), + ) + } + pub fn memory_document(&self) -> Result { self.get_json("/memory") } diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index fb68fcbc..433dec0c 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -118,6 +118,7 @@ impl Tool for BashTool { command: params.command, timeout_secs, output_limit: INLINE_BYTE_BUDGET, + cwd: None, spill_dir: Some(self.output_dir.clone()), tool_call_id: Some(call_id.clone()), }) diff --git a/crates/workdir/src/delegation.rs b/crates/workdir/src/delegation.rs deleted file mode 100644 index 17598dab..00000000 --- a/crates/workdir/src/delegation.rs +++ /dev/null @@ -1,1189 +0,0 @@ -use std::collections::HashMap; -use std::path::Path; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, Weak}; - -use async_trait::async_trait; -use fs_operation::{ - EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, - ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, -}; -use tokio::sync::broadcast; - -use crate::{ - CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, - CommandSnapshot, CommandStatus, Workdir, WorkdirError, WorkdirSession, - WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, -}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkdirDelegationPermission { - Read, - Write, -} - -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkdirDelegationRule { - pub target: FsPath, - pub permission: WorkdirDelegationPermission, - pub recursive: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkdirDelegationRequest { - pub rules: Vec, - pub cwd: FsPath, -} - -pub struct WorkdirDelegation { - pub scoped_session: WorkdirSessionHandle, - pub capabilities: WorkdirSessionCapabilities, - validity: Arc, -} - -impl std::fmt::Debug for WorkdirDelegation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WorkdirDelegation") - .field("workdir", &self.scoped_session.workdir()) - .field("capabilities", &self.capabilities) - .field("active", &self.is_active()) - .finish() - } -} - -impl WorkdirDelegation { - pub fn is_active(&self) -> bool { - self.validity.is_active() - } - - pub fn release(&self) { - self.validity.active.store(false, Ordering::Release); - } -} - -impl Drop for WorkdirDelegation { - fn drop(&mut self) { - self.release(); - } -} - -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, - parent: Option>, -} - -impl SessionValidity { - fn root() -> Arc { - Arc::new(Self { - active: AtomicBool::new(true), - parent: None, - }) - } - - fn child(parent: Arc) -> Arc { - Arc::new(Self { - active: AtomicBool::new(true), - parent: Some(parent), - }) - } - - fn is_active(&self) -> bool { - self.active.load(Ordering::Acquire) - && self.parent.as_ref().is_none_or(|parent| parent.is_active()) - } -} - -#[derive(Clone, Debug)] -struct ActiveWriteLease { - validity: Weak, - rules: Vec, -} - -struct DelegatingWorkdirSession { - source: WorkdirSessionHandle, - cwd: FsPath, - scope: Option>, - capabilities: WorkdirSessionCapabilities, - validity: Arc, - child_write_leases: Mutex>, - next_lease_id: AtomicU64, - closes_source: bool, -} - -impl std::fmt::Debug for DelegatingWorkdirSession { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DelegatingWorkdirSession") - .field("workdir", &self.source.workdir()) - .field("scope", &self.scope) - .field("capabilities", &self.capabilities) - .field("active", &self.validity.is_active()) - .finish_non_exhaustive() - } -} - -/// Wrap a provider session with logical-path delegation and parent write gates. -pub fn delegation_capable_session(source: WorkdirSessionHandle) -> WorkdirSessionHandle { - let capabilities = source.capabilities(); - Arc::new(DelegatingWorkdirSession { - source, - cwd: FsPath::new("").expect("empty Workdir path is valid"), - scope: None, - capabilities, - validity: SessionValidity::root(), - child_write_leases: Mutex::new(HashMap::new()), - next_lease_id: AtomicU64::new(1), - closes_source: true, - }) -} - -impl DelegatingWorkdirSession { - fn ensure_active(&self) -> Result<(), WorkdirError> { - if self.validity.is_active() { - Ok(()) - } else { - Err(WorkdirError::SessionClosed) - } - } - - fn ensure_capability( - &self, - required: WorkdirSessionCapability, - operation: &'static str, - ) -> Result<(), WorkdirError> { - self.ensure_active()?; - if self.capabilities.supports(required) { - Ok(()) - } else { - Err(WorkdirError::Denied(format!( - "delegated workdir session does not permit {operation}" - ))) - } - } - - fn ensure_path( - &self, - path: &FsPath, - permission: WorkdirDelegationPermission, - ) -> Result<(), WorkdirError> { - self.ensure_active()?; - if let Some(scope) = &self.scope { - if !scope - .iter() - .any(|rule| rule_allows_path(rule, path, permission)) - { - return Err(WorkdirError::Denied(format!( - "logical workdir path `{path}` is outside the delegated {permission:?} scope" - ))); - } - } - if permission == WorkdirDelegationPermission::Write { - self.ensure_parent_write_available(path)?; - } - Ok(()) - } - - fn resolve_path(&self, path: &FsPath) -> Result { - if self.cwd.as_str().is_empty() { - return Ok(path.clone()); - } - let joined = Path::new(self.cwd.as_str()).join(path.as_str()); - let joined = joined.to_str().ok_or_else(|| { - WorkdirError::Denied("logical Workdir path is not valid UTF-8".into()) - })?; - FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string())) - } - - fn ensure_read( - &self, - path: &FsPath, - capability: WorkdirSessionCapability, - ) -> Result<(), WorkdirError> { - self.ensure_capability(capability, "read operations")?; - self.ensure_path(path, WorkdirDelegationPermission::Read) - } - - fn ensure_write( - &self, - path: &FsPath, - capability: WorkdirSessionCapability, - ) -> Result<(), WorkdirError> { - self.ensure_capability(capability, "write operations")?; - self.ensure_path(path, WorkdirDelegationPermission::Write) - } - - fn ensure_command(&self) -> Result<(), WorkdirError> { - self.ensure_capability(WorkdirSessionCapability::Command, "command execution") - } - - fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> { - let mut leases = self - .child_write_leases - .lock() - .expect("workdir delegation lease mutex poisoned"); - leases.retain(|_, lease| lease.validity.upgrade().is_some_and(|v| v.is_active())); - if leases.values().any(|lease| { - lease.rules.iter().any(|rule| { - rule.permission == WorkdirDelegationPermission::Write - && rule_allows_path(rule, path, WorkdirDelegationPermission::Write) - }) - }) { - Err(WorkdirError::Denied(format!( - "logical workdir path `{path}` is leased to a child session" - ))) - } else { - Ok(()) - } - } - - fn validate_delegation_rules( - &self, - rules: &[WorkdirDelegationRule], - ) -> Result { - self.ensure_active()?; - if rules.is_empty() { - return Err(WorkdirError::Denied( - "workdir delegation requires at least one logical scope rule".into(), - )); - } - let writable = rules - .iter() - .any(|rule| rule.permission == WorkdirDelegationPermission::Write); - if !self.capabilities.supports(WorkdirSessionCapability::Read) - || (writable - && (!self.capabilities.supports(WorkdirSessionCapability::Write) - || !self.capabilities.supports(WorkdirSessionCapability::Edit) - || !self - .capabilities - .supports(WorkdirSessionCapability::Command))) - { - return Err(WorkdirError::Denied( - "parent workdir session cannot delegate the requested capabilities".into(), - )); - } - for requested in rules { - if let Some(scope) = &self.scope { - if !scope - .iter() - .any(|parent| rule_contains_rule(parent, requested)) - { - return Err(WorkdirError::Denied(format!( - "logical workdir scope `{}` exceeds the parent delegation", - requested.target - ))); - } - } - } - let mut delegated = vec![WorkdirSessionCapability::Read]; - for capability in [ - WorkdirSessionCapability::Glob, - WorkdirSessionCapability::Grep, - ] { - if self.capabilities.supports(capability) { - delegated.push(capability); - } - } - if writable { - delegated.push(WorkdirSessionCapability::Write); - delegated.push(WorkdirSessionCapability::Edit); - delegated.push(WorkdirSessionCapability::Command); - } - Ok(WorkdirSessionCapabilities::from_capabilities(delegated)) - } -} - -#[async_trait] -impl WorkdirSession for DelegatingWorkdirSession { - fn workdir(&self) -> &Workdir { - self.source.workdir() - } - - fn capabilities(&self) -> WorkdirSessionCapabilities { - self.capabilities - } - - fn is_delegation_capable(&self) -> bool { - true - } - - fn transports_delegation_context(&self) -> bool { - self.source.transports_delegation_context() - } - - async fn capture_delegation_source( - &self, - request: &WorkdirDelegationRequest, - ) -> Result { - self.ensure_active()?; - if self.scope.is_some() { - return Err(WorkdirError::Denied( - "scoped Workdir sessions cannot expose their provider source".into(), - )); - } - self.source.capture_delegation_source(request).await - } - - async fn delegate( - &self, - request: WorkdirDelegationRequest, - ) -> Result { - let capabilities = self.validate_delegation_rules(&request.rules)?; - if !request - .rules - .iter() - .any(|rule| rule_allows_path(rule, &request.cwd, WorkdirDelegationPermission::Read)) - { - return Err(WorkdirError::Denied(format!( - "delegated cwd `{}` is outside the delegated readable scope", - request.cwd - ))); - } - let source = self.source.capture_delegation_source(&request).await?; - let validity = SessionValidity::child(self.validity.clone()); - let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); - if request - .rules - .iter() - .any(|rule| rule.permission == WorkdirDelegationPermission::Write) - { - self.child_write_leases - .lock() - .expect("workdir delegation lease mutex poisoned") - .insert( - id, - ActiveWriteLease { - validity: Arc::downgrade(&validity), - rules: request.rules.clone(), - }, - ); - } - let child: WorkdirSessionHandle = Arc::new(DelegatingWorkdirSession { - source, - cwd: request.cwd, - scope: Some(request.rules), - capabilities, - validity: validity.clone(), - child_write_leases: Mutex::new(HashMap::new()), - next_lease_id: AtomicU64::new(1), - closes_source: false, - }); - let scoped_session: WorkdirSessionHandle = - if capabilities == WorkdirSessionCapabilities::READ_ONLY { - Arc::new(ReadOnlyWorkdirSession::new(child)) - } else { - child - }; - Ok(WorkdirDelegation { - scoped_session, - capabilities, - validity, - }) - } - - async fn stat(&self, mut request: StatRequest) -> Result { - let path = self.resolve_path(&request.path)?; - self.ensure_read(&path, WorkdirSessionCapability::Read)?; - if !self.source.transports_delegation_context() { - request.path = path; - } - self.source.stat(request).await - } - - async fn read(&self, mut request: ReadRequest) -> Result { - let path = self.resolve_path(&request.path)?; - self.ensure_read(&path, WorkdirSessionCapability::Read)?; - if !self.source.transports_delegation_context() { - request.path = path; - } - self.source.read(request).await - } - - async fn write(&self, mut request: WriteRequest) -> Result { - let path = self.resolve_path(&request.path)?; - self.ensure_write(&path, WorkdirSessionCapability::Write)?; - if !self.source.transports_delegation_context() { - request.path = path; - } - self.source.write(request).await - } - - async fn edit(&self, mut request: EditRequest) -> Result { - let path = self.resolve_path(&request.path)?; - self.ensure_write(&path, WorkdirSessionCapability::Edit)?; - if !self.source.transports_delegation_context() { - request.path = path; - } - self.source.edit(request).await - } - - async fn list(&self, mut request: ListRequest) -> Result { - let path = self.resolve_path(&request.path)?; - self.ensure_read(&path, WorkdirSessionCapability::Read)?; - if !self.source.transports_delegation_context() { - request.path = path; - } - self.source.list(request).await - } - - async fn glob(&self, mut request: GlobRequest) -> Result { - let path = self.resolve_path(&request.path)?; - self.ensure_read(&path, WorkdirSessionCapability::Glob)?; - if !self.source.transports_delegation_context() { - request.path = path; - } - self.source.glob(request).await - } - - async fn grep(&self, mut request: GrepRequest) -> Result { - let path = self.resolve_path(&request.path)?; - self.ensure_read(&path, WorkdirSessionCapability::Grep)?; - if !self.source.transports_delegation_context() { - request.path = path; - } - self.source.grep(request).await - } - - async fn start_command(&self, request: CommandRequest) -> Result { - self.ensure_command()?; - self.source.start_command(request).await - } - - async fn command_status(&self, handle: CommandHandle) -> Result { - self.ensure_command()?; - self.source.command_status(handle).await - } - - async fn command_output( - &self, - request: CommandOutputRequest, - ) -> Result { - self.ensure_command()?; - self.source.command_output(request).await - } - - async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { - self.ensure_command()?; - self.source.cancel_command(handle).await - } - - fn subscribe_command_events(&self) -> Option> { - self.ensure_capability(WorkdirSessionCapability::Command, "command observation") - .ok()?; - self.source.subscribe_command_events() - } - - fn command_snapshot(&self) -> Vec { - if self - .ensure_capability(WorkdirSessionCapability::Command, "command observation") - .is_err() - { - return Vec::new(); - } - self.source.command_snapshot() - } - - async fn close(&self) -> Result<(), WorkdirError> { - self.validity.active.store(false, Ordering::Release); - if self.closes_source { - self.source.close().await - } else { - Ok(()) - } - } -} - -/// A fail-closed read-only view over an already scoped delegated session. -#[derive(Debug)] -pub struct ReadOnlyWorkdirSession { - inner: WorkdirSessionHandle, -} - -impl ReadOnlyWorkdirSession { - pub fn new(inner: WorkdirSessionHandle) -> Self { - Self { inner } - } -} - -#[async_trait] -impl WorkdirSession for ReadOnlyWorkdirSession { - fn workdir(&self) -> &Workdir { - self.inner.workdir() - } - - fn capabilities(&self) -> WorkdirSessionCapabilities { - WorkdirSessionCapabilities::READ_ONLY - } - - fn is_delegation_capable(&self) -> bool { - true - } - - fn transports_delegation_context(&self) -> bool { - self.inner.transports_delegation_context() - } - - async fn delegate( - &self, - request: WorkdirDelegationRequest, - ) -> Result { - if request - .rules - .iter() - .any(|rule| rule.permission == WorkdirDelegationPermission::Write) - { - return Err(WorkdirError::Denied( - "read-only workdir session cannot delegate write access".into(), - )); - } - self.inner.delegate(request).await - } - - async fn stat(&self, request: StatRequest) -> Result { - self.inner.stat(request).await - } - - async fn read(&self, request: ReadRequest) -> Result { - self.inner.read(request).await - } - - async fn write(&self, _request: WriteRequest) -> Result { - Err(WorkdirError::Denied("read-only workdir session".into())) - } - - async fn edit(&self, _request: EditRequest) -> Result { - Err(WorkdirError::Denied("read-only workdir session".into())) - } - - async fn list(&self, request: ListRequest) -> Result { - self.inner.list(request).await - } - - async fn glob(&self, request: GlobRequest) -> Result { - self.inner.glob(request).await - } - - async fn grep(&self, request: GrepRequest) -> Result { - self.inner.grep(request).await - } - - async fn start_command(&self, _request: CommandRequest) -> Result { - Err(WorkdirError::Denied("read-only workdir session".into())) - } - - async fn command_status(&self, _handle: CommandHandle) -> Result { - Err(WorkdirError::Denied("read-only workdir session".into())) - } - - async fn command_output( - &self, - _request: CommandOutputRequest, - ) -> Result { - Err(WorkdirError::Denied("read-only workdir session".into())) - } - - async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> { - Err(WorkdirError::Denied("read-only workdir session".into())) - } - - async fn close(&self) -> Result<(), WorkdirError> { - self.inner.close().await - } -} - -fn rule_allows_path( - rule: &WorkdirDelegationRule, - path: &FsPath, - required: WorkdirDelegationPermission, -) -> bool { - if required == WorkdirDelegationPermission::Write - && rule.permission != WorkdirDelegationPermission::Write - { - return false; - } - path_in_rule(rule, path) -} - -fn path_in_rule(rule: &WorkdirDelegationRule, path: &FsPath) -> bool { - let target = Path::new(rule.target.as_str()); - let path = Path::new(path.as_str()); - if path == target { - return true; - } - let Ok(suffix) = path.strip_prefix(target) else { - return false; - }; - let depth = suffix.components().count(); - rule.recursive || depth <= 1 -} - -fn rule_contains_rule(parent: &WorkdirDelegationRule, child: &WorkdirDelegationRule) -> bool { - if child.permission == WorkdirDelegationPermission::Write - && parent.permission != WorkdirDelegationPermission::Write - { - return false; - } - if !path_in_rule(parent, &child.target) { - return false; - } - if parent.recursive { - return true; - } - !child.recursive && parent.target == child.target -} - -#[cfg(test)] -mod tests { - use std::fs; - - use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope}; - use tempfile::TempDir; - - use super::*; - use crate::LocalWorkdirSession; - - fn fs_path(path: &str) -> FsPath { - FsPath::new(path).unwrap() - } - - fn session(root: &Path) -> WorkdirSessionHandle { - let scope = SharedScope::new( - Scope::from_config(&ScopeConfig { - allow: vec![ScopeRule { - target: root.to_path_buf(), - permission: Permission::Write, - recursive: true, - }], - deny: Vec::new(), - }) - .unwrap(), - ); - delegation_capable_session(Arc::new(LocalWorkdirSession::materialized_bound( - Workdir::new("delegation-test"), - root.to_path_buf(), - root.to_path_buf(), - scope, - WorkdirSessionCapabilities::ALL, - ))) - } - - fn request(path: &str, permission: WorkdirDelegationPermission) -> WorkdirDelegationRequest { - WorkdirDelegationRequest { - rules: vec![WorkdirDelegationRule { - target: fs_path(path), - permission, - recursive: true, - }], - cwd: fs_path(path), - } - } - - fn read(path: &str) -> ReadRequest { - ReadRequest { - path: fs_path(path), - offset: 0, - limit: 20, - max_bytes: 1024, - } - } - - fn write(path: &str, content: &str) -> WriteRequest { - WriteRequest { - path: fs_path(path), - content: content.as_bytes().to_vec(), - expected_hash: None, - } - } - - async fn run_command( - session: &WorkdirSessionHandle, - command: impl Into, - tool_call_id: impl Into, - ) -> CommandOutput { - let handle = session - .start_command(CommandRequest { - command: command.into(), - timeout_secs: 5, - output_limit: 1024, - spill_dir: None, - tool_call_id: Some(tool_call_id.into()), - }) - .await - .unwrap(); - session - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: 1024, - wait: true, - }) - .await - .unwrap() - } - - #[tokio::test] - async fn delegation_capable_session_forwards_command_telemetry() { - let root = TempDir::new().unwrap(); - let parent = session(root.path()); - let mut events = parent - .subscribe_command_events() - .expect("delegation wrapper must preserve command observation"); - let handle = parent - .start_command(CommandRequest { - command: "printf ready; sleep 0.2; printf done".into(), - timeout_secs: 5, - output_limit: 1024, - spill_dir: None, - tool_call_id: Some("tool-delegated".into()), - }) - .await - .unwrap(); - - let first_output = loop { - let event = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()) - .await - .expect("delegated command telemetry should not stall") - .unwrap(); - if let CommandEvent::Output { content, .. } = event { - break content; - } - }; - assert_eq!(first_output, "ready"); - let snapshots = parent.command_snapshot(); - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].command_id, handle.0); - assert_eq!(snapshots[0].status, CommandStatus::Running); - assert_eq!(snapshots[0].stdout.content, "ready"); - - let output = parent - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: 1024, - wait: true, - }) - .await - .unwrap(); - assert_eq!(output.status, CommandStatus::Completed); - assert_eq!(output.content, "readydone"); - assert!(parent.command_snapshot().is_empty()); - } - - #[test] - fn non_recursive_rule_covers_target_and_direct_children_only() { - let rule = WorkdirDelegationRule { - target: fs_path("docs"), - permission: WorkdirDelegationPermission::Read, - recursive: false, - }; - assert!(path_in_rule(&rule, &fs_path("docs"))); - assert!(path_in_rule(&rule, &fs_path("docs/readme.md"))); - assert!(!path_in_rule(&rule, &fs_path("docs/guides/start.md"))); - } - - #[tokio::test] - async fn read_only_delegation_allows_prefix_and_denies_mutation() { - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("docs")).unwrap(); - fs::create_dir_all(root.path().join("secret")).unwrap(); - fs::write(root.path().join("docs/readme.md"), "visible").unwrap(); - fs::write(root.path().join("secret/key"), "hidden").unwrap(); - let parent = session(root.path()); - - let child = parent - .delegate(request("docs", WorkdirDelegationPermission::Read)) - .await - .unwrap(); - assert_eq!(child.capabilities, WorkdirSessionCapabilities::READ_ONLY); - assert_eq!( - child - .scoped_session - .read(read("readme.md")) - .await - .unwrap() - .bytes, - b"visible" - ); - assert!(matches!( - child.scoped_session.write(write("new.md", "no")).await, - Err(WorkdirError::Denied(_)) - )); - assert!( - !child - .capabilities - .supports(WorkdirSessionCapability::Command) - ); - assert!(child.scoped_session.subscribe_command_events().is_none()); - assert!(child.scoped_session.command_snapshot().is_empty()); - assert!(matches!( - child - .scoped_session - .start_command(CommandRequest { - command: "printf denied".into(), - timeout_secs: 5, - output_limit: 1024, - spill_dir: None, - tool_call_id: Some("read-only-command".into()), - }) - .await, - Err(WorkdirError::Denied(_)) - )); - } - - #[cfg(unix)] - #[tokio::test] - async fn provider_scope_denies_read_through_symlink_outside_grant() { - use std::os::unix::fs::symlink; - - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("granted")).unwrap(); - fs::create_dir_all(root.path().join("secret")).unwrap(); - fs::write(root.path().join("secret/key"), "hidden").unwrap(); - symlink("../secret/key", root.path().join("granted/link")).unwrap(); - let parent = session(root.path()); - let child = parent - .delegate(request("granted", WorkdirDelegationPermission::Read)) - .await - .unwrap(); - - let result = child.scoped_session.read(read("link")).await; - assert!( - result.is_err(), - "symlink read escaped provider scope: {result:?}" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn provider_scope_denies_write_through_symlink_outside_grant() { - use std::os::unix::fs::symlink; - - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("granted")).unwrap(); - fs::create_dir_all(root.path().join("secret")).unwrap(); - symlink("../secret", root.path().join("granted/outside")).unwrap(); - let parent = session(root.path()); - let child = parent - .delegate(request("granted", WorkdirDelegationPermission::Write)) - .await - .unwrap(); - - let result = child - .scoped_session - .write(write("outside/new", "forbidden")) - .await; - assert!( - result.is_err(), - "symlink write escaped provider scope: {result:?}" - ); - assert!(!root.path().join("secret/new").exists()); - } - - #[cfg(unix)] - #[tokio::test] - async fn write_delegation_rejects_symlink_target_before_lease() { - use std::os::unix::fs::symlink; - - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("granted")).unwrap(); - fs::create_dir_all(root.path().join("secret")).unwrap(); - symlink("../secret", root.path().join("granted/outside")).unwrap(); - let parent = session(root.path()); - - assert!(matches!( - parent - .delegate(request( - "granted/outside", - WorkdirDelegationPermission::Write - )) - .await, - Err(WorkdirError::Denied(_)) - )); - parent - .write(write("secret/parent", "still-authoritative")) - .await - .unwrap(); - } - - #[tokio::test] - async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() { - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("leased")).unwrap(); - fs::create_dir_all(root.path().join("other")).unwrap(); - let parent = session(root.path()); - let child = parent - .delegate(request("leased", WorkdirDelegationPermission::Write)) - .await - .unwrap(); - assert!( - child - .capabilities - .supports(WorkdirSessionCapability::Command) - ); - let child_output = run_command( - &child.scoped_session, - "printf child-command", - "delegated-child-command", - ) - .await; - assert_eq!(child_output.content, "child-command"); - let parent_output = run_command( - &parent, - "printf parent-write > leased/from-command; printf parent-command", - "parent-command-during-child-write", - ) - .await; - assert_eq!(parent_output.status, CommandStatus::Completed); - assert_eq!(parent_output.content, "parent-command"); - assert_eq!( - fs::read_to_string(root.path().join("leased/from-command")).unwrap(), - "parent-write" - ); - - assert!(matches!( - parent.write(write("leased/file", "parent")).await, - Err(WorkdirError::Denied(_)) - )); - parent.write(write("other/file", "parent")).await.unwrap(); - child - .scoped_session - .write(write("file", "child")) - .await - .unwrap(); - child.release(); - assert!(matches!( - child - .scoped_session - .start_command(CommandRequest { - command: "printf revoked".into(), - timeout_secs: 5, - output_limit: 1024, - spill_dir: None, - tool_call_id: Some("revoked-child-command".into()), - }) - .await, - Err(WorkdirError::SessionClosed) - )); - parent - .write(write("leased/parent", "parent")) - .await - .unwrap(); - assert!(matches!( - child.scoped_session.read(read("file")).await, - Err(WorkdirError::SessionClosed) - )); - } - - #[tokio::test] - async fn nested_delegation_is_attenuated_and_parent_revocation_cascades() { - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("docs/sub")).unwrap(); - fs::create_dir_all(root.path().join("docs/peer")).unwrap(); - fs::write(root.path().join("docs/sub/a"), "a").unwrap(); - fs::write(root.path().join("docs/peer/b"), "b").unwrap(); - let root_session = session(root.path()); - let child = root_session - .delegate(request("docs", WorkdirDelegationPermission::Read)) - .await - .unwrap(); - let nested = child - .scoped_session - .delegate(request("docs/sub", WorkdirDelegationPermission::Read)) - .await - .unwrap(); - - nested.scoped_session.read(read("a")).await.unwrap(); - assert!( - child - .scoped_session - .delegate(request("other", WorkdirDelegationPermission::Read)) - .await - .is_err() - ); - assert!( - child - .scoped_session - .delegate(request("docs/sub", WorkdirDelegationPermission::Write)) - .await - .is_err() - ); - - child.release(); - assert!(matches!( - nested.scoped_session.read(read("a")).await, - Err(WorkdirError::SessionClosed) - )); - } - - #[tokio::test] - async fn nested_write_leases_do_not_block_command_capable_ancestors() { - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("docs/sub")).unwrap(); - let root_session = session(root.path()); - let child = root_session - .delegate(request("docs", WorkdirDelegationPermission::Write)) - .await - .unwrap(); - let nested = child - .scoped_session - .delegate(request("docs/sub", WorkdirDelegationPermission::Write)) - .await - .unwrap(); - - for (session, label) in [ - (&root_session, "root"), - (&child.scoped_session, "child"), - (&nested.scoped_session, "nested"), - ] { - let output = run_command( - session, - format!("printf {label}"), - format!("{label}-command-during-nested-write"), - ) - .await; - assert_eq!(output.status, CommandStatus::Completed); - assert_eq!(output.content, label); - } - - assert!(matches!( - root_session.write(write("docs/root", "blocked")).await, - Err(WorkdirError::Denied(_)) - )); - assert!(matches!( - child - .scoped_session - .write(write("sub/child", "blocked")) - .await, - Err(WorkdirError::Denied(_)) - )); - nested - .scoped_session - .write(write("nested", "allowed")) - .await - .unwrap(); - - nested.release(); - child.release(); - } - - #[tokio::test] - async fn reapplied_write_delegation_chain_forwards_command_lifecycle() { - let root = TempDir::new().unwrap(); - fs::create_dir_all(root.path().join("delegated")).unwrap(); - let applied = apply_delegation_chain( - session(root.path()), - [request("delegated", WorkdirDelegationPermission::Write)], - ) - .await - .unwrap(); - - let output = run_command( - &applied.scoped_session, - "printf reapplied", - "reapplied-command", - ) - .await; - assert_eq!(output.status, CommandStatus::Completed); - assert_eq!(output.content, "reapplied"); - } - - #[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(); - fs::create_dir_all(root.path().join("docs")).unwrap(); - fs::write(root.path().join("docs/a"), "a").unwrap(); - let parent = session(root.path()); - let child = parent - .delegate(request("docs", WorkdirDelegationPermission::Read)) - .await - .unwrap(); - - parent.close().await.unwrap(); - assert!(matches!( - parent - .start_command(CommandRequest { - command: "printf closed".into(), - timeout_secs: 5, - output_limit: 1024, - spill_dir: None, - tool_call_id: Some("closed-parent-command".into()), - }) - .await, - Err(WorkdirError::SessionClosed) - )); - assert!(matches!( - child.scoped_session.read(read("a")).await, - Err(WorkdirError::SessionClosed) - )); - } -} diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 72733e8b..f89b608b 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -68,12 +68,10 @@ pub enum WorkdirSessionOperation { CommandCancel(CommandHandle), } -/// Wire envelope for an operation and its optional provider-enforced child scope. +/// Wire envelope for one provider operation. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorkdirSessionOperationRequest { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delegations: Vec, pub operation: WorkdirSessionOperation, } @@ -289,7 +287,7 @@ mod client { use reqwest::{Client, StatusCode, Url}; use super::*; - use crate::{Workdir, WorkdirSession, WorkdirSessionHandle}; + use crate::{Workdir, WorkdirSession}; /// Provides a fresh bearer token for each Runtime request. Backend /// implementations can mint short-lived capability tokens without making a @@ -324,7 +322,6 @@ mod client { workdir: Workdir, session_id: WorkdirSessionId, capabilities: WorkdirSessionCapabilities, - delegations: Vec, closed: AtomicBool, } @@ -377,7 +374,6 @@ mod client { workdir: Workdir::new(opened.workdir_id.as_str()), session_id: opened.session_id, capabilities: opened.capabilities, - delegations: Vec::new(), closed: AtomicBool::new(false), }) } @@ -404,10 +400,7 @@ mod client { "operations", ], )?; - let operation = WorkdirSessionOperationRequest { - delegations: self.delegations.clone(), - operation, - }; + let operation = WorkdirSessionOperationRequest { operation }; let response = self .client .post(url) @@ -436,37 +429,6 @@ mod client { self.capabilities } - fn transports_delegation_context(&self) -> bool { - true - } - - async fn capture_delegation_source( - &self, - request: &crate::WorkdirDelegationRequest, - ) -> Result { - if self.closed.load(Ordering::Acquire) { - return Err(WorkdirError::SessionClosed); - } - let mut delegations = self.delegations.clone(); - delegations.push(request.clone()); - let candidate = Arc::new(Self { - client: self.client.clone(), - base_url: self.base_url.clone(), - authorization: self.authorization.clone(), - workdir: self.workdir.clone(), - session_id: self.session_id.clone(), - capabilities: self.capabilities, - delegations, - closed: AtomicBool::new(false), - }); - candidate - .stat(StatRequest { - path: fs_operation::FsPath::new("").expect("empty Workdir path is valid"), - }) - .await?; - Ok(candidate) - } - async fn stat(&self, request: StatRequest) -> Result { match self.operate(WorkdirSessionOperation::Stat(request)).await? { WorkdirSessionOperationResult::Stat(result) => Ok(result), diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index 5d58da26..4cfa91c1 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -5,10 +5,10 @@ //! bound to one Worker. Tools consume sessions; they do not own Workdir //! materialization or cleanup. -mod delegation; pub mod http; mod local; mod operation; +mod scope; pub mod workspace; use std::path::{Path, PathBuf}; @@ -18,11 +18,6 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; -pub use delegation::{ - AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation, - WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, - apply_delegation_chain, delegation_capable_session, -}; pub use fs_operation::{ ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, @@ -32,6 +27,10 @@ pub use local::{ LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink, }; pub use operation::*; +pub use scope::{ + ReadOnlyWorkdirSession, WorkdirScopeLease, WorkdirToolBroker, WorkdirToolScope, + WorkdirToolScopePermission, WorkdirToolScopeRule, +}; /// Persistent, opaque identity of one materialized Workdir. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -148,39 +147,6 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync { fn workdir(&self) -> &Workdir; fn capabilities(&self) -> WorkdirSessionCapabilities; - fn is_delegation_capable(&self) -> bool { - false - } - - /// Whether this session transports the delegation chain to another - /// provider boundary that will apply logical cwd/path resolution there. - fn transports_delegation_context(&self) -> bool { - false - } - - /// Capture a provider-specific source for a delegated child session. - /// Remote providers use this boundary to pin attachment identity without - /// exposing transport handles or host paths. - async fn capture_delegation_source( - &self, - _request: &WorkdirDelegationRequest, - ) -> Result { - Err(WorkdirError::Denied( - "workdir provider does not support delegated sessions".into(), - )) - } - - /// Attenuate this session into a revocable child lease. Only sessions - /// created with [`delegation_capable_session`] implement this operation. - async fn delegate( - &self, - _request: WorkdirDelegationRequest, - ) -> Result { - Err(WorkdirError::Denied( - "workdir session is not delegation-capable".into(), - )) - } - async fn stat(&self, request: StatRequest) -> Result; async fn read(&self, request: ReadRequest) -> Result; async fn write(&self, request: WriteRequest) -> Result; diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 8f9556dd..94efeceb 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; -use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope}; +use manifest::{Scope, SharedScope}; use sha2::{Digest, Sha256}; use tokio::process::Command; use tokio::sync::{Mutex, broadcast, watch}; @@ -28,10 +28,8 @@ use crate::{ CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, - ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission, - WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession, - WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest, - WriteResult, + ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirSession, + WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, WriteResult, }; #[cfg(test)] use crate::{EntryKind, WriteOutcome}; @@ -558,69 +556,6 @@ impl WorkdirSession for LocalWorkdirSession { self.inner.capabilities } - async fn capture_delegation_source( - &self, - request: &WorkdirDelegationRequest, - ) -> Result { - let host_rules = request - .rules - .iter() - .map(|rule| ScopeRule { - target: self.inner.root.join(rule.target.as_str()), - permission: match rule.permission { - WorkdirDelegationPermission::Read => Permission::Read, - WorkdirDelegationPermission::Write => Permission::Write, - }, - recursive: rule.recursive, - }) - .collect::>(); - for (logical, host) in request.rules.iter().zip(&host_rules) { - if logical.permission == WorkdirDelegationPermission::Write { - let resolved = Scope::resolved_target(host) - .map_err(|error| WorkdirError::Denied(error.to_string()))?; - if resolved != host.target { - return Err(WorkdirError::Denied(format!( - "write delegation target `{}` traverses a symlink", - logical.target - ))); - } - } - } - let parent_scope = self.inner.scope.snapshot(); - for rule in &host_rules { - if !parent_scope - .allows_rule(rule) - .map_err(|error| WorkdirError::Denied(error.to_string()))? - { - return Err(WorkdirError::Denied(format!( - "delegated provider scope `{}` exceeds the parent session", - rule.target.display() - ))); - } - } - let child_scope = Scope::from_config(&ScopeConfig { - allow: host_rules, - deny: Vec::new(), - }) - .map_err(|error| WorkdirError::Denied(error.to_string()))?; - let child_cwd = self.inner.root.join(request.cwd.as_str()); - if !child_scope.is_readable(&child_cwd) - || !std::fs::metadata(&child_cwd).is_ok_and(|metadata| metadata.is_dir()) - { - return Err(WorkdirError::Denied(format!( - "delegated cwd `{}` is not a readable Workdir directory", - request.cwd - ))); - } - Ok(Arc::new(LocalWorkdirSession::materialized_bound( - self.inner.workdir.clone(), - self.inner.root.clone(), - self.inner.root.clone(), - SharedScope::new(child_scope), - self.inner.capabilities, - ))) - } - async fn stat(&self, request: StatRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; let logical = request.path.clone(); @@ -694,9 +629,20 @@ impl WorkdirSession for LocalWorkdirSession { { return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf())); } + let cwd = if let Some(logical_cwd) = request.cwd.as_ref() { + let cwd = self.resolve(logical_cwd); + let scope = self.inner.scope.snapshot(); + if !scope.is_readable(&cwd) + || !std::fs::metadata(&cwd).is_ok_and(|metadata| metadata.is_dir()) + { + return Err(WorkdirError::OutOfScope(cwd)); + } + cwd + } else { + self.inner.cwd.clone() + }; let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed); let handle = CommandHandle(format!("command-{id}")); - let cwd = self.inner.cwd.clone(); let (completion_tx, completion) = watch::channel(false); let command_id = handle.0.clone(); let telemetry = self.inner.command_telemetry.clone(); @@ -1516,6 +1462,7 @@ mod tests { command: "sleep 30".to_owned(), timeout_secs: 60, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: None, }, @@ -2043,6 +1990,7 @@ mod tests { command: "pwd && printf provider-command".into(), timeout_secs: 5, output_limit: 4096, + cwd: None, spill_dir: None, tool_call_id: None, }, @@ -2141,6 +2089,7 @@ mod tests { command: "printf hidden".into(), timeout_secs: 5, output_limit: 1, + cwd: None, spill_dir: Some(spill.path().to_path_buf()), tool_call_id: None, }, @@ -2178,6 +2127,7 @@ mod tests { command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(), timeout_secs: 5, output_limit: 64, + cwd: None, spill_dir: Some(spill.path().to_path_buf()), tool_call_id: None, }, @@ -2224,6 +2174,7 @@ mod tests { command: "printf 'aéz'".into(), timeout_secs: 5, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: None, }, @@ -2449,6 +2400,7 @@ mod tests { command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), timeout_secs: 5, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: Some("tool-7".into()), }, @@ -2553,6 +2505,7 @@ mod tests { command: "sleep 30".into(), timeout_secs: 1, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: None, }, @@ -2623,6 +2576,7 @@ mod tests { command: "sleep 30".into(), timeout_secs: 60, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: None, }, diff --git a/crates/workdir/src/operation.rs b/crates/workdir/src/operation.rs index 67858685..5af8b54a 100644 --- a/crates/workdir/src/operation.rs +++ b/crates/workdir/src/operation.rs @@ -11,6 +11,10 @@ pub struct CommandRequest { pub command: String, pub timeout_secs: u64, pub output_limit: usize, + /// Workdir-relative command directory. Providers validate it against the + /// active session before process start. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, /// Provider-local directory where complete output is retained when the /// inline result exceeds `output_limit`. pub spill_dir: Option, diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs new file mode 100644 index 00000000..131ae6cf --- /dev/null +++ b/crates/workdir/src/scope.rs @@ -0,0 +1,1944 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; + +use async_trait::async_trait; +use fs_operation::{ + EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, + ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, +}; +use tokio::sync::broadcast; + +const MAX_SCOPED_COMMANDS: usize = 16; + +use crate::{ + CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, + CommandSnapshot, CommandStatus, CommandStream, Workdir, WorkdirError, WorkdirSession, + WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkdirToolScopePermission { + Read, + Write, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkdirToolScopeRule { + pub target: FsPath, + pub permission: WorkdirToolScopePermission, + pub recursive: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkdirToolScope { + pub rules: Vec, + pub cwd: FsPath, + pub command: bool, +} + +#[derive(Clone)] +pub struct WorkdirToolBroker { + authority: Arc, + session: WorkdirSessionHandle, + event_forwarder: Option>>>>, +} + +impl std::fmt::Debug for WorkdirToolBroker { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WorkdirToolBroker") + .field("workdir", self.session.workdir()) + .field("capabilities", &self.session.capabilities()) + .finish_non_exhaustive() + } +} + +impl WorkdirToolBroker { + /// Own the parent Worker's active session and mediate every scoped child operation. + pub fn new(source: WorkdirSessionHandle) -> Self { + let capabilities = source.capabilities(); + let (command_events, _) = broadcast::channel(64); + let authority = Arc::new(ScopedWorkdirSession { + source, + cwd: FsPath::new("").expect("empty Workdir path is valid"), + scope: None, + capabilities, + validity: SessionValidity::root(), + child_write_leases: Mutex::new(HashMap::new()), + next_lease_id: AtomicU64::new(1), + close_lock: Arc::new(tokio::sync::Mutex::new(())), + owned_commands: Arc::new(Mutex::new(HashSet::new())), + pending_command_events: Arc::new(Mutex::new(HashMap::new())), + starting_tool_calls: Arc::new(Mutex::new(HashSet::new())), + forwarded_starts: Arc::new(Mutex::new(HashSet::new())), + forwarded_terminals: Arc::new(Mutex::new(HashSet::new())), + command_events, + closes_source: true, + #[cfg(test)] + command_start_gate: Mutex::new(None), + }); + Self { + session: authority.clone(), + authority, + event_forwarder: None, + } + } + + /// Session used only by tools registered by the owning Worker. + pub fn tool_session(&self) -> WorkdirSessionHandle { + self.session.clone() + } + + /// Create a revocable, attenuated tool route without delegating a provider session. + pub async fn scope( + &self, + request: WorkdirToolScope, + ) -> Result { + self.authority.scope(request).await + } +} + +impl std::ops::Deref for WorkdirToolBroker { + type Target = WorkdirSessionHandle; + + fn deref(&self) -> &Self::Target { + &self.session + } +} + +pub struct WorkdirScopeLease { + broker: WorkdirToolBroker, + pub capabilities: WorkdirSessionCapabilities, + validity: Arc, + cleanup_pending: Arc, + close_lock: Arc>, +} + +impl std::fmt::Debug for WorkdirScopeLease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkdirScopeLease") + .field("workdir", self.broker.session.workdir()) + .field("capabilities", &self.capabilities) + .field("active", &self.is_active()) + .finish() + } +} + +impl WorkdirScopeLease { + pub fn broker(&self) -> WorkdirToolBroker { + self.broker.clone() + } + + pub fn tool_session(&self) -> WorkdirSessionHandle { + self.broker.tool_session() + } + + pub async fn scope( + &self, + request: WorkdirToolScope, + ) -> Result { + self.broker.scope(request).await + } + + pub async fn close(&self) -> Result<(), WorkdirError> { + let _close_guard = self.close_lock.lock().await; + if !self.cleanup_pending.load(Ordering::Acquire) { + return Ok(()); + } + self.validity.active.store(false, Ordering::Release); + let command_ids = self + .broker + .authority + .owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .iter() + .cloned() + .collect::>(); + let mut first_error = None; + for command_id in command_ids { + let handle = CommandHandle(command_id.clone()); + let cancel = self + .broker + .authority + .source + .cancel_command(handle.clone()) + .await; + let terminal = self + .broker + .authority + .source + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1, + wait: true, + }) + .await; + match (cancel, terminal) { + (_, Ok(output)) => { + self.broker.authority.publish_terminal_if_missing( + &command_id, + output.status, + output.exit_code, + output.next_cursor.unwrap_or(output.content.len()) as u64, + &output.content, + ); + self.broker + .authority + .owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .remove(&command_id); + } + (Ok(()), Err(WorkdirError::UnknownCommand(_))) + | (Err(WorkdirError::UnknownCommand(_)), Err(WorkdirError::UnknownCommand(_))) => { + self.broker.authority.publish_terminal_if_missing( + &command_id, + CommandStatus::Cancelled, + None, + 0, + "", + ); + self.broker + .authority + .owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .remove(&command_id); + } + (Err(error), _) | (_, Err(error)) => { + first_error.get_or_insert(error); + } + } + } + if let Some(error) = first_error { + return Err(error); + } + tokio::task::yield_now().await; + self.finish_release(); + Ok(()) + } + + pub fn is_active(&self) -> bool { + self.validity.is_active() + } + + /// Revoke a scope whose owner has already terminalized every tool call. + /// Use [`Self::close`] when commands may still be live. + pub fn revoke(&self) { + self.finish_release(); + } + + fn finish_release(&self) { + self.validity.active.store(false, Ordering::Release); + self.cleanup_pending.store(false, Ordering::Release); + if let Some(forwarder) = &self.broker.event_forwarder + && let Some(handle) = forwarder + .lock() + .expect("scoped command forwarder mutex poisoned") + .take() + { + handle.abort(); + } + } +} + +impl std::ops::Deref for WorkdirScopeLease { + type Target = WorkdirSessionHandle; + + fn deref(&self) -> &Self::Target { + &self.broker.session + } +} + +impl Drop for WorkdirScopeLease { + fn drop(&mut self) { + self.finish_release(); + } +} + +#[derive(Debug)] +struct SessionValidity { + active: AtomicBool, + parent: Option>, +} + +impl SessionValidity { + fn root() -> Arc { + Arc::new(Self { + active: AtomicBool::new(true), + parent: None, + }) + } + + fn child(parent: Arc) -> Arc { + Arc::new(Self { + active: AtomicBool::new(true), + parent: Some(parent), + }) + } + + fn is_active(&self) -> bool { + self.active.load(Ordering::Acquire) + && self.parent.as_ref().is_none_or(|parent| parent.is_active()) + } +} + +#[derive(Clone, Debug)] +struct ActiveWriteLease { + validity: Weak, + cleanup_pending: Weak, + rules: Vec, +} + +#[cfg(test)] +struct TestCommandStartGate { + entered: tokio::sync::Notify, + release: tokio::sync::Notify, +} + +struct ScopedWorkdirSession { + source: WorkdirSessionHandle, + cwd: FsPath, + scope: Option>, + capabilities: WorkdirSessionCapabilities, + validity: Arc, + child_write_leases: Mutex>, + next_lease_id: AtomicU64, + close_lock: Arc>, + owned_commands: Arc>>, + pending_command_events: Arc>>>, + starting_tool_calls: Arc>>, + forwarded_starts: Arc>>, + forwarded_terminals: Arc>>, + command_events: broadcast::Sender, + closes_source: bool, + #[cfg(test)] + command_start_gate: Mutex>>, +} + +impl std::fmt::Debug for ScopedWorkdirSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScopedWorkdirSession") + .field("workdir", &self.source.workdir()) + .field("scope", &self.scope) + .field("capabilities", &self.capabilities) + .field("active", &self.validity.is_active()) + .finish_non_exhaustive() + } +} + +impl ScopedWorkdirSession { + fn ensure_active(&self) -> Result<(), WorkdirError> { + if self.validity.is_active() { + Ok(()) + } else { + Err(WorkdirError::SessionClosed) + } + } + + fn ensure_capability( + &self, + required: WorkdirSessionCapability, + operation: &'static str, + ) -> Result<(), WorkdirError> { + self.ensure_active()?; + if self.capabilities.supports(required) { + Ok(()) + } else { + Err(WorkdirError::Denied(format!( + "scoped Workdir tools do not permit {operation}" + ))) + } + } + + fn ensure_path( + &self, + path: &FsPath, + permission: WorkdirToolScopePermission, + ) -> Result<(), WorkdirError> { + self.ensure_active()?; + if let Some(scope) = &self.scope { + if !scope + .iter() + .any(|rule| rule_allows_path(rule, path, permission)) + { + return Err(WorkdirError::Denied(format!( + "logical workdir path `{path}` is outside the scoped {permission:?} scope" + ))); + } + } + if permission == WorkdirToolScopePermission::Write { + self.ensure_parent_write_available(path)?; + } + Ok(()) + } + + fn resolve_path(&self, path: &FsPath) -> Result { + if self.cwd.as_str().is_empty() { + return Ok(path.clone()); + } + let joined = Path::new(self.cwd.as_str()).join(path.as_str()); + let joined = joined.to_str().ok_or_else(|| { + WorkdirError::Denied("logical Workdir path is not valid UTF-8".into()) + })?; + FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string())) + } + + fn ensure_read( + &self, + path: &FsPath, + capability: WorkdirSessionCapability, + ) -> Result<(), WorkdirError> { + self.ensure_capability(capability, "read operations")?; + self.ensure_path(path, WorkdirToolScopePermission::Read) + } + + fn ensure_write( + &self, + path: &FsPath, + capability: WorkdirSessionCapability, + ) -> Result<(), WorkdirError> { + self.ensure_capability(capability, "write operations")?; + self.ensure_path(path, WorkdirToolScopePermission::Write) + } + + fn ensure_command(&self) -> Result<(), WorkdirError> { + self.ensure_capability(WorkdirSessionCapability::Command, "command execution") + } + + fn ensure_owned_command(&self, handle: &CommandHandle) -> Result<(), WorkdirError> { + self.ensure_command()?; + if self.scope.is_none() + || self + .owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .contains(&handle.0) + { + Ok(()) + } else { + Err(WorkdirError::UnknownCommand(handle.0.clone())) + } + } + + fn publish_terminal_if_missing( + &self, + command_id: &str, + status: CommandStatus, + exit_code: Option, + offset: u64, + fallback_output: &str, + ) { + let mut terminals = self + .forwarded_terminals + .lock() + .expect("forwarded terminal command mutex poisoned"); + if !terminals.insert(command_id.to_string()) { + return; + } + if !fallback_output.is_empty() { + let _ = self.command_events.send(CommandEvent::Output { + command_id: command_id.to_string(), + stream: CommandStream::Stdout, + start_offset: 0, + end_offset: fallback_output.len() as u64, + content: fallback_output.to_string(), + observed_at_ms: unix_timestamp_ms(), + }); + } + let _ = self.command_events.send(CommandEvent::Terminal { + command_id: command_id.to_string(), + status, + exit_code, + stdout_end_offset: offset, + stderr_end_offset: 0, + observed_at_ms: unix_timestamp_ms(), + }); + } + + fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> { + let mut leases = self + .child_write_leases + .lock() + .expect("Workdir tool scope lease mutex poisoned"); + leases.retain(|_, lease| { + lease + .validity + .upgrade() + .is_some_and(|validity| validity.is_active()) + || lease + .cleanup_pending + .upgrade() + .is_some_and(|pending| pending.load(Ordering::Acquire)) + }); + if leases.values().any(|lease| { + lease.rules.iter().any(|rule| { + rule.permission == WorkdirToolScopePermission::Write + && rule_allows_path(rule, path, WorkdirToolScopePermission::Write) + }) + }) { + Err(WorkdirError::Denied(format!( + "logical workdir path `{path}` is leased to child Workdir tools" + ))) + } else { + Ok(()) + } + } + + async fn ensure_source_path_has_no_symlink(&self, path: &FsPath) -> Result<(), WorkdirError> { + let mut current = String::new(); + for component in Path::new(path.as_str()).components() { + let component = component.as_os_str().to_string_lossy(); + if component.is_empty() || component == "." { + continue; + } + if !current.is_empty() { + current.push('/'); + } + current.push_str(&component); + let current = FsPath::new(¤t).map_err(|error| { + WorkdirError::Denied(format!("invalid scoped Workdir path: {error}")) + })?; + match self.source.stat(StatRequest { path: current }).await { + Ok(result) if result.kind == fs_operation::EntryKind::Symlink => { + return Err(WorkdirError::Denied(format!( + "scoped Workdir path `{path}` traverses a symlink" + ))); + } + Ok(_) => {} + Err(WorkdirError::NotFound(_)) => break, + Err(error) => return Err(error), + } + } + Ok(()) + } + + async fn ensure_scope_targets_do_not_traverse_symlinks( + &self, + rules: &[WorkdirToolScopeRule], + ) -> Result<(), WorkdirError> { + for rule in rules { + self.ensure_source_path_has_no_symlink(&rule.target).await?; + } + Ok(()) + } + + async fn resolve_operation_path(&self, path: &FsPath) -> Result { + self.ensure_active()?; + let resolved = self.resolve_path(path)?; + if self.scope.is_some() { + self.ensure_source_path_has_no_symlink(&resolved).await?; + } + Ok(resolved) + } + + fn validate_scope( + &self, + rules: &[WorkdirToolScopeRule], + command: bool, + ) -> Result { + self.ensure_active()?; + if rules.is_empty() { + return Err(WorkdirError::Denied( + "workdir tool scope requires at least one logical scope rule".into(), + )); + } + let writable = rules + .iter() + .any(|rule| rule.permission == WorkdirToolScopePermission::Write); + if !self.capabilities.supports(WorkdirSessionCapability::Read) + || (writable + && (!self.capabilities.supports(WorkdirSessionCapability::Write) + || !self.capabilities.supports(WorkdirSessionCapability::Edit))) + { + return Err(WorkdirError::Denied( + "parent Workdir session cannot scope the requested capabilities".into(), + )); + } + if command { + if !writable { + return Err(WorkdirError::Denied( + "command execution requires a writable scoped path".into(), + )); + } + if !self + .capabilities + .supports(WorkdirSessionCapability::Command) + { + return Err(WorkdirError::Denied( + "parent Workdir session does not support Command".into(), + )); + } + } + for requested in rules { + if let Some(scope) = &self.scope { + if !scope + .iter() + .any(|parent| rule_contains_rule(parent, requested)) + { + return Err(WorkdirError::Denied(format!( + "logical workdir scope `{}` exceeds the parent tool scope", + requested.target + ))); + } + } + } + let mut delegated = vec![WorkdirSessionCapability::Read]; + for capability in [ + WorkdirSessionCapability::Glob, + WorkdirSessionCapability::Grep, + ] { + if self.capabilities.supports(capability) { + delegated.push(capability); + } + } + if writable { + delegated.push(WorkdirSessionCapability::Write); + delegated.push(WorkdirSessionCapability::Edit); + } + if command { + delegated.push(WorkdirSessionCapability::Command); + } + Ok(WorkdirSessionCapabilities::from_capabilities(delegated)) + } + + async fn scope( + self: &Arc, + request: WorkdirToolScope, + ) -> Result { + let capabilities = self.validate_scope(&request.rules, request.command)?; + if !request + .rules + .iter() + .any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read)) + { + return Err(WorkdirError::Denied(format!( + "scoped tool cwd `{}` is outside the readable scope", + request.cwd + ))); + } + self.ensure_scope_targets_do_not_traverse_symlinks(&request.rules) + .await?; + let validity = SessionValidity::child(self.validity.clone()); + let cleanup_pending = Arc::new(AtomicBool::new(true)); + let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); + if request + .rules + .iter() + .any(|rule| rule.permission == WorkdirToolScopePermission::Write) + { + let mut leases = self + .child_write_leases + .lock() + .expect("Workdir tool scope lease mutex poisoned"); + leases.retain(|_, lease| { + lease + .validity + .upgrade() + .is_some_and(|validity| validity.is_active()) + || lease + .cleanup_pending + .upgrade() + .is_some_and(|pending| pending.load(Ordering::Acquire)) + }); + let requested_write_rules = request + .rules + .iter() + .filter(|rule| rule.permission == WorkdirToolScopePermission::Write); + for requested in requested_write_rules { + if leases.values().any(|lease| { + lease + .rules + .iter() + .any(|active| rules_overlap(active, requested)) + }) { + return Err(WorkdirError::Denied(format!( + "scoped write path `{}` overlaps an active child scope", + requested.target + ))); + } + } + leases.insert( + id, + ActiveWriteLease { + validity: Arc::downgrade(&validity), + cleanup_pending: Arc::downgrade(&cleanup_pending), + rules: request.rules.clone(), + }, + ); + } + let owned_commands = Arc::new(Mutex::new(HashSet::new())); + let pending_command_events = Arc::new(Mutex::new(HashMap::new())); + let starting_tool_calls = Arc::new(Mutex::new(HashSet::new())); + let forwarded_starts = Arc::new(Mutex::new(HashSet::new())); + let forwarded_terminals = Arc::new(Mutex::new(HashSet::new())); + let (command_events, _) = broadcast::channel(64); + let event_forwarder = forward_owned_command_events( + self.source.subscribe_command_events(), + owned_commands.clone(), + pending_command_events.clone(), + starting_tool_calls.clone(), + forwarded_starts.clone(), + forwarded_terminals.clone(), + command_events.clone(), + ) + .map(|handle| Arc::new(Mutex::new(Some(handle)))); + let close_lock = Arc::new(tokio::sync::Mutex::new(())); + let child = Arc::new(ScopedWorkdirSession { + source: self.source.clone(), + cwd: request.cwd, + scope: Some(request.rules), + capabilities, + validity: validity.clone(), + child_write_leases: Mutex::new(HashMap::new()), + next_lease_id: AtomicU64::new(1), + close_lock: close_lock.clone(), + owned_commands, + pending_command_events, + starting_tool_calls, + forwarded_starts, + forwarded_terminals, + command_events, + closes_source: false, + #[cfg(test)] + command_start_gate: Mutex::new(None), + }); + let broker = WorkdirToolBroker { + session: child.clone(), + authority: child, + event_forwarder, + }; + Ok(WorkdirScopeLease { + broker, + capabilities, + validity, + cleanup_pending, + close_lock, + }) + } +} + +#[async_trait] +impl WorkdirSession for ScopedWorkdirSession { + fn workdir(&self) -> &Workdir { + self.source.workdir() + } + + fn capabilities(&self) -> WorkdirSessionCapabilities { + self.capabilities + } + + async fn stat(&self, mut request: StatRequest) -> Result { + let path = self.resolve_operation_path(&request.path).await?; + self.ensure_read(&path, WorkdirSessionCapability::Read)?; + request.path = path; + self.source.stat(request).await + } + + async fn read(&self, mut request: ReadRequest) -> Result { + let path = self.resolve_operation_path(&request.path).await?; + self.ensure_read(&path, WorkdirSessionCapability::Read)?; + request.path = path; + self.source.read(request).await + } + + async fn write(&self, mut request: WriteRequest) -> Result { + let path = self.resolve_operation_path(&request.path).await?; + self.ensure_write(&path, WorkdirSessionCapability::Write)?; + request.path = path; + self.source.write(request).await + } + + async fn edit(&self, mut request: EditRequest) -> Result { + let path = self.resolve_operation_path(&request.path).await?; + self.ensure_write(&path, WorkdirSessionCapability::Edit)?; + request.path = path; + self.source.edit(request).await + } + + async fn list(&self, mut request: ListRequest) -> Result { + let path = self.resolve_operation_path(&request.path).await?; + self.ensure_read(&path, WorkdirSessionCapability::Read)?; + request.path = path; + self.source.list(request).await + } + + async fn glob(&self, mut request: GlobRequest) -> Result { + let path = self.resolve_operation_path(&request.path).await?; + self.ensure_read(&path, WorkdirSessionCapability::Glob)?; + request.path = path; + self.source.glob(request).await + } + + async fn grep(&self, mut request: GrepRequest) -> Result { + let path = self.resolve_operation_path(&request.path).await?; + self.ensure_read(&path, WorkdirSessionCapability::Grep)?; + request.path = path; + self.source.grep(request).await + } + + async fn start_command( + &self, + mut request: CommandRequest, + ) -> Result { + let _admission_guard = self.close_lock.lock().await; + // Command is an explicit capability, not a typed path mutation. We + // intentionally keep an ancestor's Command capability available while + // a child holds a write scope; only typed Write/Edit operations use the + // best-effort overlapping-path guard below. + self.ensure_command()?; + #[cfg(test)] + { + let gate = self + .command_start_gate + .lock() + .expect("command start gate mutex poisoned") + .clone(); + if let Some(gate) = gate { + gate.entered.notify_one(); + gate.release.notified().await; + } + } + if self.scope.is_some() + && self + .owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .len() + >= MAX_SCOPED_COMMANDS + { + return Err(WorkdirError::Unavailable(format!( + "scoped command limit of {MAX_SCOPED_COMMANDS} is reached" + ))); + } + let tool_call_id = request.tool_call_id.clone(); + if self.scope.is_some() { + request.cwd = Some(match request.cwd.as_ref() { + Some(cwd) => self.resolve_path(cwd)?, + None => self.cwd.clone(), + }); + } + if let Some(tool_call_id) = &tool_call_id { + self.starting_tool_calls + .lock() + .expect("starting tool call mutex poisoned") + .insert(tool_call_id.clone()); + } + let handle = match self.source.start_command(request).await { + Ok(handle) => handle, + Err(error) => { + if let Some(tool_call_id) = &tool_call_id { + self.starting_tool_calls + .lock() + .expect("starting tool call mutex poisoned") + .remove(tool_call_id); + } + return Err(error); + } + }; + let mut owned = self + .owned_commands + .lock() + .expect("scoped command set mutex poisoned"); + owned.insert(handle.0.clone()); + if let Some(tool_call_id) = &tool_call_id { + self.starting_tool_calls + .lock() + .expect("starting tool call mutex poisoned") + .remove(tool_call_id); + } + let pending = self + .pending_command_events + .lock() + .expect("pending scoped command event mutex poisoned") + .remove(&handle.0) + .unwrap_or_default(); + drop(owned); + if !pending + .iter() + .any(|event| matches!(event, CommandEvent::Started { .. })) + { + publish_owned_command_event( + &self.command_events, + &self.forwarded_starts, + &self.forwarded_terminals, + CommandEvent::Started { + command_id: handle.0.clone(), + tool_call_id, + observed_at_ms: unix_timestamp_ms(), + }, + ); + } + for event in pending { + publish_owned_command_event( + &self.command_events, + &self.forwarded_starts, + &self.forwarded_terminals, + event, + ); + } + Ok(handle) + } + + async fn command_status(&self, handle: CommandHandle) -> Result { + self.ensure_owned_command(&handle)?; + self.source.command_status(handle).await + } + + async fn command_output( + &self, + request: CommandOutputRequest, + ) -> Result { + self.ensure_owned_command(&request.handle)?; + let command_id = request.handle.0.clone(); + let output = self.source.command_output(request).await?; + if !matches!(output.status, CommandStatus::Running) { + self.publish_terminal_if_missing( + &command_id, + output.status, + output.exit_code, + output.next_cursor.unwrap_or(output.content.len()) as u64, + &output.content, + ); + self.owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .remove(&command_id); + } + Ok(output) + } + + async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { + self.ensure_owned_command(&handle)?; + self.source.cancel_command(handle).await + } + + fn subscribe_command_events(&self) -> Option> { + if !self + .capabilities + .supports(WorkdirSessionCapability::Command) + { + return None; + } + if self.scope.is_none() { + self.source.subscribe_command_events() + } else { + Some(self.command_events.subscribe()) + } + } + + fn command_snapshot(&self) -> Vec { + if !self + .capabilities + .supports(WorkdirSessionCapability::Command) + { + return Vec::new(); + } + if self.scope.is_none() { + return self.source.command_snapshot(); + } + let owned = self + .owned_commands + .lock() + .expect("scoped command set mutex poisoned"); + self.source + .command_snapshot() + .into_iter() + .filter(|snapshot| owned.contains(&snapshot.command_id)) + .collect() + } + + async fn close(&self) -> Result<(), WorkdirError> { + self.validity.active.store(false, Ordering::Release); + if self.closes_source { + self.source.close().await + } else { + Ok(()) + } + } +} + +/// A fail-closed read-only view over an already scoped scoped tool route. +#[derive(Debug)] +pub struct ReadOnlyWorkdirSession { + inner: WorkdirSessionHandle, +} + +impl ReadOnlyWorkdirSession { + pub fn new(inner: WorkdirSessionHandle) -> Self { + Self { inner } + } +} + +#[async_trait] +impl WorkdirSession for ReadOnlyWorkdirSession { + fn workdir(&self) -> &Workdir { + self.inner.workdir() + } + + fn capabilities(&self) -> WorkdirSessionCapabilities { + WorkdirSessionCapabilities::READ_ONLY + } + + async fn stat(&self, request: StatRequest) -> Result { + self.inner.stat(request).await + } + + async fn read(&self, request: ReadRequest) -> Result { + self.inner.read(request).await + } + + async fn write(&self, _request: WriteRequest) -> Result { + Err(WorkdirError::Denied("read-only workdir session".into())) + } + + async fn edit(&self, _request: EditRequest) -> Result { + Err(WorkdirError::Denied("read-only workdir session".into())) + } + + async fn list(&self, request: ListRequest) -> Result { + self.inner.list(request).await + } + + async fn glob(&self, request: GlobRequest) -> Result { + self.inner.glob(request).await + } + + async fn grep(&self, request: GrepRequest) -> Result { + self.inner.grep(request).await + } + + async fn start_command(&self, _request: CommandRequest) -> Result { + Err(WorkdirError::Denied("read-only workdir session".into())) + } + + async fn command_status(&self, _handle: CommandHandle) -> Result { + Err(WorkdirError::Denied("read-only workdir session".into())) + } + + async fn command_output( + &self, + _request: CommandOutputRequest, + ) -> Result { + Err(WorkdirError::Denied("read-only workdir session".into())) + } + + async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> { + Err(WorkdirError::Denied("read-only workdir session".into())) + } + + async fn close(&self) -> Result<(), WorkdirError> { + self.inner.close().await + } +} + +fn forward_owned_command_events( + receiver: Option>, + owned_commands: Arc>>, + pending_command_events: Arc>>>, + starting_tool_calls: Arc>>, + forwarded_starts: Arc>>, + forwarded_terminals: Arc>>, + sender: broadcast::Sender, +) -> Option> { + let mut receiver = receiver?; + Some(tokio::spawn(async move { + loop { + let event = match receiver.recv().await { + Ok(event) => event, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }; + let command_id = command_event_id(&event).to_string(); + let mut owned = owned_commands + .lock() + .expect("scoped command set mutex poisoned"); + if !owned.contains(&command_id) { + let claimed = matches!( + &event, + CommandEvent::Started { + tool_call_id: Some(tool_call_id), + .. + } if starting_tool_calls + .lock() + .expect("starting tool call mutex poisoned") + .contains(tool_call_id) + ); + if !claimed { + continue; + } + owned.insert(command_id.clone()); + pending_command_events + .lock() + .expect("pending scoped command event mutex poisoned") + .entry(command_id) + .or_default() + .push(event); + continue; + } + let mut pending = pending_command_events + .lock() + .expect("pending scoped command event mutex poisoned"); + if let Some(events) = pending.get_mut(&command_id) { + if events.len() < 64 { + events.push(event); + } + continue; + } + drop(pending); + drop(owned); + publish_owned_command_event(&sender, &forwarded_starts, &forwarded_terminals, event); + } + })) +} + +fn command_event_id(event: &CommandEvent) -> &str { + match event { + CommandEvent::Started { command_id, .. } + | CommandEvent::Output { command_id, .. } + | CommandEvent::Terminal { command_id, .. } => command_id, + } +} + +fn publish_owned_command_event( + sender: &broadcast::Sender, + forwarded_starts: &Mutex>, + forwarded_terminals: &Mutex>, + event: CommandEvent, +) { + let command_id = command_event_id(&event); + let mut terminals = forwarded_terminals + .lock() + .expect("forwarded terminal command mutex poisoned"); + match &event { + CommandEvent::Terminal { .. } if !terminals.insert(command_id.to_string()) => return, + CommandEvent::Started { .. } if terminals.contains(command_id) => return, + CommandEvent::Started { .. } + if !forwarded_starts + .lock() + .expect("forwarded command start mutex poisoned") + .insert(command_id.to_string()) => + { + return; + } + CommandEvent::Output { .. } if terminals.contains(command_id) => return, + _ => {} + } + drop(terminals); + let _ = sender.send(event); +} + +fn unix_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64 +} + +fn rules_overlap(left: &WorkdirToolScopeRule, right: &WorkdirToolScopeRule) -> bool { + left.permission == WorkdirToolScopePermission::Write + && right.permission == WorkdirToolScopePermission::Write + && (rule_allows_path(left, &right.target, WorkdirToolScopePermission::Write) + || rule_allows_path(right, &left.target, WorkdirToolScopePermission::Write)) +} + +fn rule_allows_path( + rule: &WorkdirToolScopeRule, + path: &FsPath, + required: WorkdirToolScopePermission, +) -> bool { + if required == WorkdirToolScopePermission::Write + && rule.permission != WorkdirToolScopePermission::Write + { + return false; + } + path_in_rule(rule, path) +} + +fn path_in_rule(rule: &WorkdirToolScopeRule, path: &FsPath) -> bool { + let target = Path::new(rule.target.as_str()); + let path = Path::new(path.as_str()); + if path == target { + return true; + } + let Ok(suffix) = path.strip_prefix(target) else { + return false; + }; + let depth = suffix.components().count(); + rule.recursive || depth <= 1 +} + +fn rule_contains_rule(parent: &WorkdirToolScopeRule, child: &WorkdirToolScopeRule) -> bool { + if child.permission == WorkdirToolScopePermission::Write + && parent.permission != WorkdirToolScopePermission::Write + { + return false; + } + if !path_in_rule(parent, &child.target) { + return false; + } + if parent.recursive { + return true; + } + !child.recursive && parent.target == child.target +} + +#[cfg(test)] +mod tests { + use std::fs; + + use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope}; + use tempfile::TempDir; + + use super::*; + use crate::LocalWorkdirSession; + + fn fs_path(path: &str) -> FsPath { + FsPath::new(path).unwrap() + } + + fn session(root: &Path) -> WorkdirToolBroker { + let scope = SharedScope::new( + Scope::from_config(&ScopeConfig { + allow: vec![ScopeRule { + target: root.to_path_buf(), + permission: Permission::Write, + recursive: true, + }], + deny: Vec::new(), + }) + .unwrap(), + ); + WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::materialized_bound( + Workdir::new("delegation-test"), + root.to_path_buf(), + root.to_path_buf(), + scope, + WorkdirSessionCapabilities::ALL, + ))) + } + + fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope { + WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: fs_path(path), + permission, + recursive: true, + }], + cwd: fs_path(path), + command: permission == WorkdirToolScopePermission::Write, + } + } + + fn read(path: &str) -> ReadRequest { + ReadRequest { + path: fs_path(path), + offset: 0, + limit: 20, + max_bytes: 1024, + } + } + + fn write(path: &str, content: &str) -> WriteRequest { + WriteRequest { + path: fs_path(path), + content: content.as_bytes().to_vec(), + expected_hash: None, + } + } + + async fn run_command( + session: &WorkdirSessionHandle, + command: impl Into, + tool_call_id: impl Into, + ) -> CommandOutput { + let handle = session + .start_command(CommandRequest { + command: command.into(), + timeout_secs: 5, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some(tool_call_id.into()), + }) + .await + .unwrap(); + session + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1024, + wait: true, + }) + .await + .unwrap() + } + + #[tokio::test] + async fn workdir_tool_broker_session_forwards_command_telemetry() { + let root = TempDir::new().unwrap(); + let parent = session(root.path()); + let mut events = parent + .subscribe_command_events() + .expect("delegation wrapper must preserve command observation"); + let handle = parent + .start_command(CommandRequest { + command: "printf ready; sleep 0.2; printf done".into(), + timeout_secs: 5, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some("tool-delegated".into()), + }) + .await + .unwrap(); + + let first_output = loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()) + .await + .expect("delegated command telemetry should not stall") + .unwrap(); + if let CommandEvent::Output { content, .. } = event { + break content; + } + }; + assert_eq!(first_output, "ready"); + let snapshots = parent.command_snapshot(); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].command_id, handle.0); + assert_eq!(snapshots[0].status, CommandStatus::Running); + assert_eq!(snapshots[0].stdout.content, "ready"); + + let output = parent + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1024, + wait: true, + }) + .await + .unwrap(); + assert_eq!(output.status, CommandStatus::Completed); + assert_eq!(output.content, "readydone"); + assert!(parent.command_snapshot().is_empty()); + } + + #[tokio::test] + async fn write_scope_without_command_grant_has_no_command_capability() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("work")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: fs_path("work"), + permission: WorkdirToolScopePermission::Write, + recursive: true, + }], + cwd: fs_path("work"), + command: false, + }) + .await + .unwrap(); + + assert!(child.capabilities.supports(WorkdirSessionCapability::Write)); + assert!( + !child + .capabilities + .supports(WorkdirSessionCapability::Command) + ); + let error = child + .start_command(CommandRequest { + command: "pwd".into(), + timeout_secs: 5, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: None, + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Denied(_))); + } + + #[tokio::test] + async fn scoped_commands_use_child_cwd_and_do_not_leak_between_siblings() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("one")).unwrap(); + fs::create_dir_all(root.path().join("two")).unwrap(); + let parent = session(root.path()); + let first = parent + .scope(request("one", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let second = parent + .scope(request("two", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let mut first_events = first.subscribe_command_events().unwrap(); + let mut second_events = second.subscribe_command_events().unwrap(); + + let handle = first + .start_command(CommandRequest { + command: "pwd; sleep 0.2".into(), + timeout_secs: 5, + output_limit: 4096, + cwd: None, + spill_dir: None, + tool_call_id: Some("first-command".into()), + }) + .await + .unwrap(); + assert!(matches!( + first_events.recv().await.unwrap(), + CommandEvent::Started { .. } + )); + assert!(matches!( + tokio::time::timeout(std::time::Duration::from_millis(50), second_events.recv()).await, + Err(_) + )); + assert!(matches!( + second.command_status(handle.clone()).await, + Err(WorkdirError::UnknownCommand(_)) + )); + + let output = first + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 4096, + wait: true, + }) + .await + .unwrap(); + let expected = root.path().join("one").to_string_lossy().into_owned(); + assert!( + output + .content + .lines() + .next() + .is_some_and(|line| line == expected) + ); + } + + #[test] + fn non_recursive_rule_covers_target_and_direct_children_only() { + let rule = WorkdirToolScopeRule { + target: fs_path("docs"), + permission: WorkdirToolScopePermission::Read, + recursive: false, + }; + assert!(path_in_rule(&rule, &fs_path("docs"))); + assert!(path_in_rule(&rule, &fs_path("docs/readme.md"))); + assert!(!path_in_rule(&rule, &fs_path("docs/guides/start.md"))); + } + + #[tokio::test] + async fn read_only_delegation_allows_prefix_and_denies_mutation() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("docs")).unwrap(); + fs::create_dir_all(root.path().join("secret")).unwrap(); + fs::write(root.path().join("docs/readme.md"), "visible").unwrap(); + fs::write(root.path().join("secret/key"), "hidden").unwrap(); + let parent = session(root.path()); + + let child = parent + .scope(request("docs", WorkdirToolScopePermission::Read)) + .await + .unwrap(); + assert_eq!(child.capabilities, WorkdirSessionCapabilities::READ_ONLY); + assert_eq!( + child.read(read("readme.md")).await.unwrap().bytes, + b"visible" + ); + assert!(matches!( + child.write(write("new.md", "no")).await, + Err(WorkdirError::Denied(_)) + )); + assert!( + !child + .capabilities + .supports(WorkdirSessionCapability::Command) + ); + assert!(child.subscribe_command_events().is_none()); + assert!(child.command_snapshot().is_empty()); + assert!(matches!( + child + .start_command(CommandRequest { + command: "printf denied".into(), + timeout_secs: 5, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some("read-only-command".into()), + }) + .await, + Err(WorkdirError::Denied(_)) + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn provider_scope_denies_read_through_symlink_outside_grant() { + use std::os::unix::fs::symlink; + + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("granted")).unwrap(); + fs::create_dir_all(root.path().join("secret")).unwrap(); + fs::write(root.path().join("secret/key"), "hidden").unwrap(); + symlink("../secret/key", root.path().join("granted/link")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("granted", WorkdirToolScopePermission::Read)) + .await + .unwrap(); + + let result = child.read(read("link")).await; + assert!( + result.is_err(), + "symlink read escaped provider scope: {result:?}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn provider_scope_denies_write_through_symlink_outside_grant() { + use std::os::unix::fs::symlink; + + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("granted")).unwrap(); + fs::create_dir_all(root.path().join("secret")).unwrap(); + symlink("../secret", root.path().join("granted/outside")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("granted", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + + let result = child.write(write("outside/new", "forbidden")).await; + assert!( + result.is_err(), + "symlink write escaped provider scope: {result:?}" + ); + assert!(!root.path().join("secret/new").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn write_delegation_rejects_symlink_target_before_lease() { + use std::os::unix::fs::symlink; + + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("granted")).unwrap(); + fs::create_dir_all(root.path().join("secret")).unwrap(); + symlink("../secret", root.path().join("granted/outside")).unwrap(); + let parent = session(root.path()); + + assert!(matches!( + parent + .scope(request( + "granted/outside", + WorkdirToolScopePermission::Write + )) + .await, + Err(WorkdirError::Denied(_)) + )); + parent + .write(write("secret/parent", "still-authoritative")) + .await + .unwrap(); + } + + #[tokio::test] + async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("leased")).unwrap(); + fs::create_dir_all(root.path().join("other")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("leased", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + assert!( + child + .capabilities + .supports(WorkdirSessionCapability::Command) + ); + let child_output = + run_command(&child, "printf child-command", "delegated-child-command").await; + assert_eq!(child_output.content, "child-command"); + let parent_output = run_command( + &parent, + "printf parent-write > leased/from-command; printf parent-command", + "parent-command-during-child-write", + ) + .await; + assert_eq!(parent_output.status, CommandStatus::Completed); + assert_eq!(parent_output.content, "parent-command"); + assert_eq!( + fs::read_to_string(root.path().join("leased/from-command")).unwrap(), + "parent-write" + ); + + assert!(matches!( + parent.write(write("leased/file", "parent")).await, + Err(WorkdirError::Denied(_)) + )); + parent.write(write("other/file", "parent")).await.unwrap(); + child.write(write("file", "child")).await.unwrap(); + child.close().await.unwrap(); + assert!(matches!( + child + .start_command(CommandRequest { + command: "printf revoked".into(), + timeout_secs: 5, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some("revoked-child-command".into()), + }) + .await, + Err(WorkdirError::SessionClosed) + )); + parent + .write(write("leased/parent", "parent")) + .await + .unwrap(); + assert!(matches!( + child.read(read("file")).await, + Err(WorkdirError::SessionClosed) + )); + } + + #[tokio::test] + async fn sibling_write_scopes_must_not_overlap() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("shared/one")).unwrap(); + fs::create_dir_all(root.path().join("other")).unwrap(); + let parent = session(root.path()); + let first = parent + .scope(request("shared", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + + assert!(matches!( + parent + .scope(request("shared/one", WorkdirToolScopePermission::Write)) + .await, + Err(WorkdirError::Denied(_)) + )); + let other = parent + .scope(request("other", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + other.close().await.unwrap(); + first.close().await.unwrap(); + } + + #[tokio::test] + async fn fast_command_keeps_started_output_terminal_event_order() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("work")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("work", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let mut events = child.subscribe_command_events().unwrap(); + + let output = run_command(&child.tool_session(), "printf fast-output", "fast-command").await; + assert_eq!(output.content, "fast-output"); + + let mut kinds = Vec::new(); + let mut streamed = String::new(); + while kinds.last().is_none_or(|kind| *kind != "terminal") { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), events.recv()) + .await + .expect("fast command event timeout") + .expect("fast command event channel"); + match event { + CommandEvent::Started { .. } => kinds.push("started"), + CommandEvent::Output { content, .. } => { + kinds.push("output"); + streamed.push_str(&content); + } + CommandEvent::Terminal { .. } => kinds.push("terminal"), + } + } + assert_eq!(kinds.first(), Some(&"started")); + assert_eq!(kinds.last(), Some(&"terminal")); + assert_eq!(kinds.iter().filter(|kind| **kind == "started").count(), 1); + assert!(kinds.contains(&"output")); + assert!(streamed.contains("fast-output")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), events.recv()) + .await + .is_err(), + "no provider event may follow the terminal event" + ); + assert!(child.command_snapshot().is_empty()); + child.close().await.unwrap(); + } + + #[tokio::test] + async fn scoped_command_ceiling_rejects_the_seventeenth_live_command() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("work")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("work", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + for index in 0..MAX_SCOPED_COMMANDS { + child + .start_command(CommandRequest { + command: "sleep 30".into(), + timeout_secs: 60, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some(format!("command-{index}")), + }) + .await + .unwrap(); + } + + let error = child + .start_command(CommandRequest { + command: "sleep 30".into(), + timeout_secs: 60, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some("command-over-limit".into()), + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Unavailable(message) if message.contains("limit"))); + child.close().await.unwrap(); + } + + #[tokio::test] + async fn close_serializes_with_inflight_command_admission() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("work")).unwrap(); + let parent = session(root.path()); + let child = Arc::new( + parent + .scope(request("work", WorkdirToolScopePermission::Write)) + .await + .unwrap(), + ); + let gate = Arc::new(TestCommandStartGate { + entered: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + *child.broker.authority.command_start_gate.lock().unwrap() = Some(gate.clone()); + let entered = gate.entered.notified(); + let command_child = child.clone(); + let command = tokio::spawn(async move { + command_child + .start_command(CommandRequest { + command: "sleep 30".into(), + timeout_secs: 60, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some("racing-command".into()), + }) + .await + }); + entered.await; + let close_child = child.clone(); + let mut close = tokio::spawn(async move { close_child.close().await }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), &mut close) + .await + .is_err(), + "close must wait for command admission to commit or fail" + ); + + gate.release.notify_one(); + command.await.unwrap().unwrap(); + close.await.unwrap().unwrap(); + assert!(!child.is_active()); + assert!( + child + .broker + .authority + .owned_commands + .lock() + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn closing_scope_cancels_and_terminalizes_owned_commands() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("work")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("work", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let mut events = child.subscribe_command_events().unwrap(); + let handle = child + .start_command(CommandRequest { + command: "sleep 30; printf leaked > marker".into(), + timeout_secs: 60, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some("owned-command".into()), + }) + .await + .unwrap(); + assert!(matches!( + events.recv().await.unwrap(), + CommandEvent::Started { .. } + )); + + child.close().await.unwrap(); + + assert!(matches!( + parent.command_status(handle).await, + Ok(CommandStatus::Cancelled | CommandStatus::Completed | CommandStatus::Failed) + | Err(WorkdirError::UnknownCommand(_)) + )); + assert!(!root.path().join("work/marker").exists()); + let terminal = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if let CommandEvent::Terminal { .. } = events.recv().await.unwrap() { + break; + } + } + }) + .await; + assert!( + terminal.is_ok(), + "scope close must publish terminal command telemetry" + ); + } + + #[tokio::test] + async fn nested_delegation_is_attenuated_and_parent_revocation_cascades() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("docs/sub")).unwrap(); + fs::create_dir_all(root.path().join("docs/peer")).unwrap(); + fs::write(root.path().join("docs/sub/a"), "a").unwrap(); + fs::write(root.path().join("docs/peer/b"), "b").unwrap(); + let root_session = session(root.path()); + let child = root_session + .scope(request("docs", WorkdirToolScopePermission::Read)) + .await + .unwrap(); + let nested = child + .scope(request("docs/sub", WorkdirToolScopePermission::Read)) + .await + .unwrap(); + + nested.read(read("a")).await.unwrap(); + assert!( + child + .scope(request("other", WorkdirToolScopePermission::Read)) + .await + .is_err() + ); + assert!( + child + .scope(request("docs/sub", WorkdirToolScopePermission::Write)) + .await + .is_err() + ); + + child.close().await.unwrap(); + assert!(matches!( + nested.read(read("a")).await, + Err(WorkdirError::SessionClosed) + )); + } + + #[tokio::test] + async fn nested_write_leases_do_not_block_command_capable_ancestors() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("docs/sub")).unwrap(); + let root_session = session(root.path()); + let child = root_session + .scope(request("docs", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let nested = child + .scope(request("docs/sub", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + + for (session, label) in [ + (root_session.tool_session(), "root"), + (child.tool_session(), "child"), + (nested.tool_session(), "nested"), + ] { + let output = run_command( + &session, + format!("printf {label}"), + format!("{label}-command-during-nested-write"), + ) + .await; + assert_eq!(output.status, CommandStatus::Completed); + assert_eq!(output.content, label); + } + + assert!(matches!( + root_session.write(write("docs/root", "blocked")).await, + Err(WorkdirError::Denied(_)) + )); + assert!(matches!( + child.write(write("sub/child", "blocked")).await, + Err(WorkdirError::Denied(_)) + )); + nested.write(write("nested", "allowed")).await.unwrap(); + + nested.close().await.unwrap(); + child.close().await.unwrap(); + } + + #[tokio::test] + async fn closing_parent_invalidates_scoped_tools() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("docs")).unwrap(); + fs::write(root.path().join("docs/a"), "a").unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("docs", WorkdirToolScopePermission::Read)) + .await + .unwrap(); + + parent.close().await.unwrap(); + assert!(matches!( + parent + .start_command(CommandRequest { + command: "printf closed".into(), + timeout_secs: 5, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: Some("closed-parent-command".into()), + }) + .await, + Err(WorkdirError::SessionClosed) + )); + let child_result = child.read(read("a")).await; + assert!( + matches!(child_result, Err(WorkdirError::SessionClosed)), + "child result after parent close: {child_result:?}" + ); + } +} diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index 3872bc9d..fc696f99 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -104,15 +104,5 @@ mod tests { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WorkspaceWorkdirSessionOperationRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected_session_fence: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delegations: Vec, pub operation: crate::http::WorkdirSessionOperation, } - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WorkspaceWorkdirSessionFence { - pub value: String, -} diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 7762ac86..28cf26aa 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -774,8 +774,7 @@ async fn run_workdir_session_operation( .ok_or_else(RuntimeHttpWorkdirError::not_found)?; record.session.clone() }; - let applied = workdir::apply_delegation_chain(source, request.delegations).await?; - let session = applied.scoped_session.as_ref(); + let session = source.as_ref(); let operation = request.operation; let result = match operation { @@ -2215,8 +2214,8 @@ mod tests { use manifest::{Scope, SharedScope}; use tower::ServiceExt; use workdir::{ - GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, StatRequest, Workdir, - WorkdirPath, WorkdirSessionCapabilities, + GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath, + WorkdirSessionCapabilities, }; #[tokio::test] @@ -2770,16 +2769,6 @@ mod tests { async fn workdir_session_operations_enforce_owner_and_close_terminally() { let temp = tempfile::tempdir().expect("tempdir"); std::fs::write(temp.path().join("hello.txt"), "hello").expect("write fixture"); - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - std::fs::create_dir(temp.path().join("granted")).expect("granted directory"); - std::fs::write(temp.path().join("granted/visible"), "visible") - .expect("visible fixture"); - std::fs::create_dir(temp.path().join("secret")).expect("secret directory"); - std::fs::write(temp.path().join("secret/key"), "hidden").expect("secret fixture"); - symlink("../secret/key", temp.path().join("granted/link")).expect("symlink fixture"); - } let scope = SharedScope::new(Scope::writable(temp.path()).expect("scope")); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound( Workdir::new("wd-1"), @@ -2813,7 +2802,6 @@ mod tests { expires_at: u64::MAX, }; let operation = WorkdirSessionOperationRequest { - delegations: Vec::new(), operation: WorkdirSessionOperation::Stat(StatRequest { path: WorkdirPath::new("hello.txt").expect("logical path"), }), @@ -2830,7 +2818,6 @@ mod tests { assert!(matches!(result, WorkdirSessionOperationResult::Stat(_))); let grep = WorkdirSessionOperationRequest { - delegations: Vec::new(), operation: WorkdirSessionOperation::Grep(GrepRequest { pattern: "hello".into(), path: WorkdirPath::new("hello.txt").unwrap(), @@ -2853,78 +2840,7 @@ mod tests { ) .await .expect("grep direct file through provider operation"); - match result { - WorkdirSessionOperationResult::Grep(result) => { - assert_eq!(result.match_count, 1); - assert_eq!(result.matched_files, 1); - assert!(result.output.starts_with("hello.txt\n")); - assert!(result.output.contains("> 1 │ hello")); - } - other => panic!("unexpected workdir grep result: {other:?}"), - } - - #[cfg(unix)] - { - let delegated_visible = WorkdirSessionOperationRequest { - 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("visible").unwrap(), - offset: 0, - limit: 20, - max_bytes: 1024, - }), - }; - let visible = run_workdir_session_operation( - State(state.clone()), - Path("session-1".to_string()), - Some(Extension(auth.clone())), - Ok(Json(delegated_visible)), - ) - .await - .expect("non-root delegated cwd should resolve once") - .0; - assert!(matches!( - visible, - WorkdirSessionOperationResult::Read(result) if result.bytes == b"visible" - )); - - let delegated_read = WorkdirSessionOperationRequest { - 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, - limit: 20, - max_bytes: 1024, - }), - }; - let error = run_workdir_session_operation( - State(state.clone()), - Path("session-1".to_string()), - Some(Extension(auth.clone())), - Ok(Json(delegated_read)), - ) - .await - .expect_err("provider must reject delegated symlink escape"); - assert_ne!(error.status, StatusCode::OK); - assert_eq!( - std::fs::read_to_string(temp.path().join("secret/key")).unwrap(), - "hidden" - ); - } + assert!(matches!(result, WorkdirSessionOperationResult::Grep(_))); let wrong_owner = RuntimeAuthContext { workspace_id: "workspace-b".to_string(), diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 98bf1be1..f9cc2388 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -705,6 +705,7 @@ impl WorkerController { runtime_base.to_path_buf(), spawned_registry.clone(), Some(method_tx.downgrade()), + None, ) .await?; if let Some(session) = fs_for_view.as_ref() { @@ -1116,6 +1117,7 @@ pub(crate) async fn register_worker_tools( runtime_base: PathBuf, spawned_registry: Arc, parent_method_tx: Option>, + inherited_workdir_tool_broker: Option, ) -> std::io::Result> where C: LlmClient + Clone + 'static, @@ -1124,21 +1126,26 @@ where // Worker-immutable snapshots taken before the mutable worker borrow // below so the worker borrow doesn't conflict with reads on `worker`. let feature_config = worker.manifest().feature.clone(); + let mut workdir_tool_broker = inherited_workdir_tool_broker; if feature_config.manage_workdir.enabled && worker.workdir_session().is_none() { let workspace_client = worker.workspace_client_handle(); - worker.bind_workdir_session(Some(workdir::delegation_capable_session( + let broker = workdir::WorkdirToolBroker::new( crate::feature::builtin::manage_workdir::WorkspaceAttachedWorkdirSession::handle( workspace_client, ), - ))); - } - if feature_config.sub_worker.enabled + ); + worker.bind_workdir_session(Some(broker.tool_session())); + workdir_tool_broker = Some(broker); + } else if workdir_tool_broker.is_none() && let Some(existing) = worker.workdir_session().cloned() - && !existing.is_delegation_capable() { - worker.bind_workdir_session(Some(workdir::delegation_capable_session(existing))); + let broker = workdir::WorkdirToolBroker::new(existing); + worker.bind_workdir_session(Some(broker.tool_session())); + workdir_tool_broker = Some(broker); } - let worker_workdir = worker.workdir_session().cloned(); + let worker_workdir = workdir_tool_broker + .as_ref() + .map(workdir::WorkdirToolBroker::tool_session); let local_filesystem = worker.local_working_directory().cloned(); let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone()); let task_feature = worker.task_feature(); @@ -1305,8 +1312,17 @@ where "manage Workdir tools require Backend Workspace API authority", )); } + let shutdown_registry = spawned_registry.clone(); + let reopen_registry = spawned_registry.clone(); feature_registry.add_module( - crate::feature::builtin::manage_workdir::manage_workdir_feature(workspace_client), + crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle( + workspace_client, + Arc::new(move || { + let child_registry = shutdown_registry.clone(); + Box::pin(async move { child_registry.shutdown_internal().await }) + }), + Arc::new(move || reopen_registry.reopen_internal()), + ), ); } if feature_config.workspace_worker_discovery.enabled { @@ -1368,7 +1384,6 @@ where } let host_worker_observation_provider = worker.worker_observation_provider(); - let source_workdir_session = worker.workdir_session().cloned(); { let workspace_client = worker.workspace_client_handle(); let engine = worker.engine_mut(); @@ -1410,7 +1425,7 @@ where runtime_base.clone(), bash_output_dir.clone(), spawner_workspace_root, - source_workdir_session, + workdir_tool_broker, spawned_registry.clone(), spawner_manifest, prompts, @@ -2292,7 +2307,16 @@ async fn controller_loop( // Memory/Workdir teardown so they cannot observe a partially closed Worker. worker.stop_feature_runtime("controller shutdown").await; - if let Some(session) = worker.workdir_session() + let child_cleanup_succeeded = match spawned_registry.shutdown_internal().await { + Ok(()) => true, + Err(error) => { + tracing::warn!(%error, "Internal SubWorker cleanup failed before Workdir shutdown"); + false + } + }; + + if child_cleanup_succeeded + && let Some(session) = worker.workdir_session() && let Err(error) = session.close().await { tracing::warn!(%error, "Workdir session close failed"); @@ -3702,4 +3726,21 @@ mod tests { .is_ok() ); } + + #[test] + fn controller_shutdown_orders_child_cleanup_before_workdir_close() { + let source = include_str!("controller.rs"); + let shutdown_start = source + .rfind("worker.stop_feature_runtime(\"controller shutdown\")") + .expect("controller shutdown block"); + let shutdown = &source[shutdown_start..]; + let children = shutdown + .find("spawned_registry.shutdown_internal().await") + .expect("Internal SubWorker cleanup"); + let workdir = shutdown + .find("session.close().await") + .expect("parent Workdir close"); + assert!(children < workdir); + assert!(shutdown.contains("if child_cleanup_succeeded")); + } } diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index f00093eb..eb950bbc 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -5,6 +5,8 @@ //! endpoints, credentials, materializer handles, and operation sessions stay //! behind [`WorkspaceClient`]. +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -12,7 +14,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::json; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; -use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest}; +use workdir::workspace::WorkspaceWorkdirSessionOperationRequest; use workdir::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, @@ -52,16 +54,48 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation."; const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session."; const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed."; +pub(crate) type BeforeWorkdirRelease = + Arc Pin> + Send>> + Send + Sync>; +pub(crate) type AfterWorkdirAttach = Arc; + const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals."; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct ManageWorkdirFeature { client: Arc, + before_workdir_release: Option, + after_workdir_attach: Option, +} + +impl std::fmt::Debug for ManageWorkdirFeature { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ManageWorkdirFeature") + .field("client_kind", &self.client.kind()) + .field("release_guard", &self.before_workdir_release.is_some()) + .finish() + } } impl ManageWorkdirFeature { pub fn new(client: Arc) -> Self { - Self { client } + Self { + client, + before_workdir_release: None, + after_workdir_attach: None, + } + } + + pub(crate) fn with_child_lifecycle( + client: Arc, + before_workdir_release: BeforeWorkdirRelease, + after_workdir_attach: AfterWorkdirAttach, + ) -> Self { + Self { + client, + before_workdir_release: Some(before_workdir_release), + after_workdir_attach: Some(after_workdir_attach), + } } } @@ -81,7 +115,10 @@ impl FeatureModule for ManageWorkdirFeature { } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { - let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()); + let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()).with_child_lifecycle( + self.before_workdir_release.clone(), + self.after_workdir_attach.clone(), + ); for (name, definition) in [ ( LIST_TOOL, @@ -142,9 +179,21 @@ impl FeatureModule for ManageWorkdirFeature { } } -#[derive(Clone, Debug)] +#[derive(Clone)] struct WorkspaceHttpWorkdirBackend { client: Arc, + before_workdir_release: Option, + after_workdir_attach: Option, +} + +impl std::fmt::Debug for WorkspaceHttpWorkdirBackend { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WorkspaceHttpWorkdirBackend") + .field("client_kind", &self.client.kind()) + .field("release_guard", &self.before_workdir_release.is_some()) + .finish() + } } /// Worker-local Workdir handle whose operation authority remains in the Workspace Backend. @@ -156,8 +205,6 @@ struct WorkspaceHttpWorkdirBackend { pub struct WorkspaceAttachedWorkdirSession { client: Arc, workdir: Workdir, - expected_session_fence: Option, - delegations: Vec, } impl WorkspaceAttachedWorkdirSession { @@ -165,8 +212,6 @@ impl WorkspaceAttachedWorkdirSession { Arc::new(Self { client, workdir: Workdir::new("workspace-attachment"), - expected_session_fence: None, - delegations: Vec::new(), }) } @@ -183,16 +228,13 @@ impl WorkspaceAttachedWorkdirSession { "/api/w/{}/workers/self/workdir-session/operations", encode_path_segment(workspace_id) ), - serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { - expected_session_fence: self.expected_session_fence.clone(), - delegations: self.delegations.clone(), - operation, - }) - .map_err(|error| { - WorkdirError::Transport(format!( - "failed to encode Workspace Workdir operation: {error}" - )) - })?, + serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { operation }).map_err( + |error| { + WorkdirError::Transport(format!( + "failed to encode Workspace Workdir operation: {error}" + )) + }, + )?, ); let response = self .client @@ -241,59 +283,6 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { WorkdirSessionCapabilities::ALL } - fn transports_delegation_context(&self) -> bool { - true - } - - async fn capture_delegation_source( - &self, - request: &workdir::WorkdirDelegationRequest, - ) -> Result { - let expected_session_fence = if let Some(fence) = &self.expected_session_fence { - fence.clone() - } else { - let workspace_id = self.client.workspace_id().ok_or_else(|| { - WorkdirError::Unavailable("Workspace identity is unavailable".to_string()) - })?; - let response = self - .client - .execute(WorkspaceRequest { - method: WorkspaceRequestMethod::Get, - path: format!( - "/api/w/{}/workers/self/workdir-session/fence", - encode_path_segment(workspace_id) - ), - body: None, - }) - .map_err(|error| { - WorkdirError::Unavailable(format!( - "failed to capture Workdir attachment fence: {error}" - )) - })?; - let fence: WorkspaceWorkdirSessionFence = serde_json::from_str(&response.body) - .map_err(|error| { - WorkdirError::Unavailable(format!( - "invalid Workdir attachment fence response: {error}" - )) - })?; - fence.value - }; - let mut delegations = self.delegations.clone(); - delegations.push(request.clone()); - let candidate = Arc::new(Self { - client: self.client.clone(), - workdir: self.workdir.clone(), - expected_session_fence: Some(expected_session_fence), - delegations, - }); - candidate - .stat(StatRequest { - path: workdir::WorkdirPath::new("").expect("empty Workdir path is valid"), - }) - .await?; - Ok(candidate) - } - async fn stat(&self, request: StatRequest) -> Result { match self.operate(WorkdirSessionOperation::Stat(request))? { WorkdirSessionOperationResult::Stat(result) => Ok(result), @@ -387,7 +376,21 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { impl WorkspaceHttpWorkdirBackend { fn new(client: Arc) -> Self { - Self { client } + Self { + client, + before_workdir_release: None, + after_workdir_attach: None, + } + } + + fn with_child_lifecycle( + mut self, + before_workdir_release: Option, + after_workdir_attach: Option, + ) -> Self { + self.before_workdir_release = before_workdir_release; + self.after_workdir_attach = after_workdir_attach; + self } fn workspace_id(&self) -> Result<&str, ToolError> { @@ -565,11 +568,26 @@ impl Tool for WorkspaceHttpWorkdirTool { parse_input::(input_json)?, ctx.call_id.to_string(), ), - WorkdirOperation::Attach => self - .backend - .attach(parse_input::(input_json)?), + WorkdirOperation::Attach => { + let result = self + .backend + .attach(parse_input::(input_json)?); + if result.is_ok() + && let Some(after_attach) = &self.backend.after_workdir_attach + { + after_attach(); + } + result + } WorkdirOperation::Detach => { let _input = parse_input::(input_json)?; + if let Some(before_release) = &self.backend.before_workdir_release { + before_release().await.map_err(|error| { + ToolError::ExecutionFailed(format!( + "stop Internal SubWorkers before Workdir detach: {error}" + )) + })?; + } self.backend.detach() } WorkdirOperation::Delete => self @@ -765,6 +783,7 @@ struct WorkdirDeleteInput { #[cfg(test)] mod tests { use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; use crate::feature::{FeatureModule, FeatureRegistryBuilder}; @@ -1155,6 +1174,7 @@ mod tests { command: "true".to_string(), timeout_secs: 120, output_limit: 1024, + cwd: None, spill_dir: Some("/worker-local/bash-output".into()), tool_call_id: Some("call-1".to_string()), }) @@ -1178,83 +1198,6 @@ mod tests { ); } - #[tokio::test] - async fn delegated_attached_session_carries_captured_fence_on_operations() { - let client = Arc::new(RecordingWorkspaceClient::new(vec![ - response(json!({"value": "attachment-fence"})), - response(json!({ - "operation": "stat", - "result": {"path": "", "kind": "directory", "size": 0} - })), - response(json!({ - "operation": "stat", - "result": {"path": "visible.txt", "kind": "file", "size": 8} - })), - ])); - let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle( - client.clone(), - )); - let delegation = parent - .delegate(workdir::WorkdirDelegationRequest { - rules: vec![workdir::WorkdirDelegationRule { - target: workdir::WorkdirPath::new("").unwrap(), - permission: workdir::WorkdirDelegationPermission::Read, - recursive: false, - }], - cwd: workdir::WorkdirPath::new("").unwrap(), - }) - .await - .unwrap(); - delegation - .scoped_session - .stat(StatRequest { - path: workdir::WorkdirPath::new("visible.txt").unwrap(), - }) - .await - .unwrap(); - - let requests = client.requests(); - assert_eq!(requests.len(), 3); - assert_eq!( - requests[0].path, - "/api/w/workspace%2Ftest/workers/self/workdir-session/fence" - ); - let body: serde_json::Value = - serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap(); - assert_eq!(body["expected_session_fence"], "attachment-fence"); - assert_eq!(body["operation"]["operation"], "stat"); - assert_eq!(body["delegations"][0]["rules"][0]["target"], ""); - } - - #[tokio::test] - async fn attached_provider_rejection_happens_before_delegation_is_returned() { - let client = Arc::new(RecordingWorkspaceClient::new(vec![ - response(json!({"value": "attachment-fence"})), - response(json!({"error": "provider rejected delegated write target"})), - ])); - let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle( - client.clone(), - )); - let result = parent - .delegate(workdir::WorkdirDelegationRequest { - rules: vec![workdir::WorkdirDelegationRule { - target: workdir::WorkdirPath::new("linked-target").unwrap(), - permission: workdir::WorkdirDelegationPermission::Write, - recursive: true, - }], - cwd: workdir::WorkdirPath::new("linked-target").unwrap(), - }) - .await; - - assert!(result.is_err(), "provider rejection must fail before lease"); - let requests = client.requests(); - assert_eq!(requests.len(), 2); - let validation: serde_json::Value = - serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); - assert_eq!(validation["operation"]["operation"], "stat"); - assert_eq!(validation["delegations"].as_array().unwrap().len(), 1); - } - #[tokio::test] async fn attached_session_preserves_typed_provider_validation_error() { let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response( @@ -1298,73 +1241,52 @@ mod tests { } #[tokio::test] - async fn nested_attached_session_preserves_full_delegation_chain() { + async fn scoped_broker_operations_carry_no_child_context() { let client = Arc::new(RecordingWorkspaceClient::new(vec![ - response(json!({"value": "attachment-fence"})), response(json!({ "operation": "stat", - "result": {"path": "", "kind": "directory", "size": 0} + "result": {"path": "visible.txt", "kind": "file", "size": 8} })), response(json!({ "operation": "stat", - "result": {"path": "nested", "kind": "directory", "size": 0} - })), - response(json!({ - "operation": "stat", - "result": {"path": "nested/file", "kind": "file", "size": 1} + "result": {"path": "visible.txt", "kind": "file", "size": 8} })), ])); - let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle( + let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle( client.clone(), )); - let outer = parent - .delegate(workdir::WorkdirDelegationRequest { - rules: vec![workdir::WorkdirDelegationRule { + let scoped = broker + .scope(workdir::WorkdirToolScope { + rules: vec![workdir::WorkdirToolScopeRule { target: workdir::WorkdirPath::new("").unwrap(), - permission: workdir::WorkdirDelegationPermission::Read, + permission: workdir::WorkdirToolScopePermission::Read, recursive: true, }], cwd: workdir::WorkdirPath::new("").unwrap(), + command: false, }) .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 + scoped .stat(StatRequest { - path: workdir::WorkdirPath::new("file").unwrap(), + path: workdir::WorkdirPath::new("visible.txt").unwrap(), }) .await .unwrap(); let requests = client.requests(); - assert_eq!(requests.len(), 4); - let outer_validation: serde_json::Value = - serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); - let nested_validation: serde_json::Value = - serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap(); - assert_eq!(outer_validation["delegations"].as_array().unwrap().len(), 1); - assert_eq!( - nested_validation["delegations"].as_array().unwrap().len(), - 2 - ); - let body: serde_json::Value = - serde_json::from_str(requests[3].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"], "file"); + assert_eq!(requests.len(), 2); + for request in requests { + assert_eq!( + request.path, + "/api/w/workspace%2Ftest/workers/self/workdir-session/operations" + ); + let body: serde_json::Value = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + assert!(body.get("delegations").is_none()); + assert!(body.get("child").is_none()); + assert!(body.get("expected_session_fence").is_none()); + } } #[test] @@ -1416,4 +1338,86 @@ mod tests { assert!(client.requests().is_empty()); assert!(parse_input::(r#"{"path":"/tmp"}"#).is_err()); } + + #[tokio::test] + async fn detach_stops_internal_subworkers_before_backend_release() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ + "workspace_id": "workspace/test", + "workdir_id": "wd-attached", + "attached": false + }))])); + let cleanup_calls = Arc::new(AtomicUsize::new(0)); + let cleanup_calls_for_guard = cleanup_calls.clone(); + let before_release: BeforeWorkdirRelease = Arc::new(move || { + let cleanup_calls = cleanup_calls_for_guard.clone(); + Box::pin(async move { + cleanup_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }); + let tool = WorkspaceHttpWorkdirTool { + backend: WorkspaceHttpWorkdirBackend::new(client.clone()) + .with_child_lifecycle(Some(before_release), None), + operation: WorkdirOperation::Detach, + }; + + tool.execute("{}", ToolExecutionContext::default()) + .await + .unwrap(); + + assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1); + assert_eq!(client.requests().len(), 1); + assert_eq!( + client.requests()[0].path, + "/api/w/workspace%2Ftest/workers/self/workdir-attachment" + ); + } + + #[tokio::test] + async fn detach_does_not_release_backend_when_child_cleanup_fails() { + let client = Arc::new(RecordingWorkspaceClient::new(Vec::new())); + let before_release: BeforeWorkdirRelease = + Arc::new(|| Box::pin(async { Err(std::io::Error::other("child cleanup failed")) })); + let tool = WorkspaceHttpWorkdirTool { + backend: WorkspaceHttpWorkdirBackend::new(client.clone()) + .with_child_lifecycle(Some(before_release), None), + operation: WorkdirOperation::Detach, + }; + + let error = tool + .execute("{}", ToolExecutionContext::default()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("stop Internal SubWorkers")); + assert!(client.requests().is_empty()); + } + + #[tokio::test] + async fn successful_attach_reopens_internal_subworker_admission() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ + "workspace_id": "workspace/test", + "workdir_id": "wd-attached", + "attached": true + }))])); + let reopen_calls = Arc::new(AtomicUsize::new(0)); + let reopen_calls_for_hook = reopen_calls.clone(); + let after_attach: AfterWorkdirAttach = Arc::new(move || { + reopen_calls_for_hook.fetch_add(1, Ordering::SeqCst); + }); + let tool = WorkspaceHttpWorkdirTool { + backend: WorkspaceHttpWorkdirBackend::new(client) + .with_child_lifecycle(None, Some(after_attach)), + operation: WorkdirOperation::Attach, + }; + + tool.execute( + r#"{"workdir_id":"wd-attached"}"#, + ToolExecutionContext::default(), + ) + .await + .unwrap(); + + assert_eq!(reopen_calls.load(Ordering::SeqCst), 1); + } } diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 63c5b85b..0f553102 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -744,7 +744,7 @@ pub(crate) fn prepare_internal_worker_from_spec( } Box::pin(prepare_internal_worker_session( - worker, store, visibility, None, None, + worker, store, visibility, None, None, None, )) .await }) @@ -781,13 +781,16 @@ pub(crate) async fn prepare_internal_worker_session( visibility: InternalWorkerVisibility, child_registry: Option>, on_turn_end: Option>, + command_event_broker: Option, ) -> Result { let (event_tx, _event_rx) = broadcast::channel(256); let sink = worker.sink(); spawn_internal_log_event_bridge(sink.clone(), event_tx.clone()); let alerter = Alerter::new(event_tx.clone()); let in_flight = InFlightEvents::new(event_tx.clone()); - if let Some(session) = worker.workdir_session() { + if let Some(broker) = command_event_broker.as_ref() { + wire_workdir_command_events(&broker.tool_session(), &in_flight); + } else if let Some(session) = worker.workdir_session() { wire_workdir_command_events(session, &in_flight); } let actor_in_flight = in_flight.clone(); @@ -918,6 +921,7 @@ pub(crate) async fn spawn_prepared_internal_worker_session( InternalWorkerVisibility::ServicePrivate, None, on_turn_end, + None, ) .await?; handle.send(input).await?; diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 62fec8d4..fd946e54 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -12,7 +12,7 @@ use std::collections::{BTreeMap, HashSet}; use std::io; use std::sync::{ Arc, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, }; use std::time::Instant; @@ -23,9 +23,9 @@ use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnaps use session_store::{ LoggedItem, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError, }; -use tokio::sync::broadcast; +use tokio::sync::{Notify, broadcast}; use tracing::warn; -use workdir::WorkdirDelegation; +use workdir::WorkdirScopeLease; use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility}; use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord}; @@ -68,10 +68,11 @@ pub(crate) struct SubWorkerStopSummary { pub(crate) struct InternalSpawnedWorkerRecord { pub worker_name: String, pub scope_delegated: Vec, - pub workdir_delegation: Arc, + pub workdir_tool_scope: Arc, #[cfg(test)] pub installed_tools: Arc<[String]>, pub session: InternalWorkerSessionHandle, + pub child_registry: Arc, change_tracker: Option, started_at: Instant, stop_lock: Arc>, @@ -86,18 +87,20 @@ impl InternalSpawnedWorkerRecord { pub(crate) fn new( worker_name: String, scope_delegated: Vec, - workdir_delegation: WorkdirDelegation, + workdir_tool_scope: WorkdirScopeLease, #[cfg(test)] installed_tools: Vec, session: InternalWorkerSessionHandle, + child_registry: Arc, change_tracker: Option, ) -> Self { Self { worker_name, scope_delegated, - workdir_delegation: Arc::new(workdir_delegation), + workdir_tool_scope: Arc::new(workdir_tool_scope), #[cfg(test)] installed_tools: installed_tools.into(), session, + child_registry, change_tracker, started_at: Instant::now(), stop_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -235,18 +238,56 @@ pub(crate) struct InternalSpawnReservation { } impl InternalSpawnReservation { - pub(crate) fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> { - if record.worker_name != self.worker_name { - return Err(io::Error::new( + pub(crate) async fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> { + let rejection = if record.worker_name != self.worker_name { + Some(io::Error::new( io::ErrorKind::InvalidInput, "internal SubWorker reservation name does not match record name", - )); + )) + } else { + match self.registry.internal_records.lock() { + Ok(mut records) => { + if self.registry.internal_shutting_down.load(Ordering::Acquire) { + Some(io::Error::new( + io::ErrorKind::Interrupted, + "internal SubWorker registry is shutting down", + )) + } else { + records.push(record.clone()); + None + } + } + Err(_) => Some(io::Error::other( + "internal spawned-worker registry lock poisoned", + )), + } + }; + if let Some(error) = rejection { + let mut cleanup_failures = Vec::new(); + if let Err(cleanup) = record.session.stop().await { + cleanup_failures.push(format!("stop rejected Internal SubWorker: {cleanup}")); + } + if let Err(cleanup) = Box::pin(record.child_registry.shutdown_internal()).await { + cleanup_failures.push(format!( + "stop rejected Internal SubWorker descendants: {cleanup}" + )); + } + if let Err(cleanup) = record.workdir_tool_scope.close().await { + cleanup_failures.push(format!( + "close rejected Internal SubWorker Workdir tools: {cleanup}" + )); + } + if cleanup_failures.is_empty() { + return Err(error); + } + self.registry + .internal_spawn_cleanup_failed + .store(true, Ordering::Release); + return Err(io::Error::other(format!( + "{error}; {}", + cleanup_failures.join("; ") + ))); } - self.registry - .internal_records - .lock() - .map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))? - .push(record.clone()); self.registry.start_protocol_forwarding(record); self.committed = true; Ok(()) @@ -260,6 +301,10 @@ impl Drop for InternalSpawnReservation { names.remove(&self.worker_name); } } + self.registry + .pending_internal_spawns + .fetch_sub(1, Ordering::AcqRel); + self.registry.pending_internal_notify.notify_waiters(); } } @@ -267,6 +312,10 @@ pub struct SpawnedWorkerRegistry { internal_records: std::sync::Mutex>, service_records: std::sync::Mutex>, internal_names: std::sync::Mutex>, + internal_shutting_down: AtomicBool, + pending_internal_spawns: AtomicUsize, + pending_internal_notify: Notify, + internal_spawn_cleanup_failed: AtomicBool, parent_scope: Option, parent_protocol: Mutex, String)>>, } @@ -283,6 +332,10 @@ impl SpawnedWorkerRegistry { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), internal_names: std::sync::Mutex::new(HashSet::new()), + internal_shutting_down: AtomicBool::new(false), + pending_internal_spawns: AtomicUsize::new(0), + pending_internal_notify: Notify::new(), + internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, parent_protocol: Mutex::new(None), }) @@ -294,6 +347,10 @@ impl SpawnedWorkerRegistry { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), internal_names: std::sync::Mutex::new(HashSet::new()), + internal_shutting_down: AtomicBool::new(false), + pending_internal_spawns: AtomicUsize::new(0), + pending_internal_notify: Notify::new(), + internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, parent_protocol: Mutex::new(None), }) @@ -304,6 +361,10 @@ impl SpawnedWorkerRegistry { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), internal_names: std::sync::Mutex::new(HashSet::new()), + internal_shutting_down: AtomicBool::new(false), + pending_internal_spawns: AtomicUsize::new(0), + pending_internal_notify: Notify::new(), + internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: Some(parent_scope), parent_protocol: Mutex::new(None), }) @@ -383,6 +444,10 @@ impl SpawnedWorkerRegistry { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), internal_names: std::sync::Mutex::new(HashSet::new()), + internal_shutting_down: AtomicBool::new(false), + pending_internal_spawns: AtomicUsize::new(0), + pending_internal_notify: Notify::new(), + internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope, parent_protocol: Mutex::new(None), }), @@ -394,6 +459,16 @@ impl SpawnedWorkerRegistry { self: &Arc, worker_name: String, ) -> io::Result { + let records = self + .internal_records + .lock() + .map_err(|_| io::Error::other("internal Worker registry lock poisoned"))?; + if self.internal_shutting_down.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "internal SubWorker registry is shutting down", + )); + } let mut names = self .internal_names .lock() @@ -404,7 +479,9 @@ impl SpawnedWorkerRegistry { format!("spawned worker `{worker_name}` is already registered"), )); } + self.pending_internal_spawns.fetch_add(1, Ordering::AcqRel); drop(names); + drop(records); Ok(InternalSpawnReservation { registry: Arc::clone(self), worker_name, @@ -679,18 +756,11 @@ impl SpawnedWorkerRegistry { .unwrap_or_default() } - pub(crate) fn reclaim_internal_scope(&self, worker_name: &str) -> io::Result { - let record = self.get_internal(worker_name).ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "internal SubWorker not found") - })?; - self.reclaim_record_scope(&record) - } - fn reclaim_record_scope(&self, record: &InternalSpawnedWorkerRecord) -> io::Result { if !record.claim_scope_reclaim() { return Ok(false); } - record.workdir_delegation.release(); + record.workdir_tool_scope.revoke(); let result = if let Some(parent_scope) = &self.parent_scope { parent_scope .update(|current| current.with_removed_deny_rules(delegated_write_rules(record))) @@ -705,6 +775,58 @@ impl SpawnedWorkerRegistry { result } + pub(crate) async fn close_internal_scope(&self, name: &str) -> io::Result { + let Some(record) = self.get_internal(name) else { + return Ok(false); + }; + Box::pin(record.child_registry.shutdown_internal()).await?; + record + .workdir_tool_scope + .close() + .await + .map_err(|error| io::Error::other(error.to_string()))?; + self.reclaim_record_scope(&record) + } + + pub(crate) async fn shutdown_internal(&self) -> io::Result<()> { + let names = { + let records = self + .internal_records + .lock() + .map_err(|_| io::Error::other("internal Worker registry lock poisoned"))?; + self.internal_shutting_down.store(true, Ordering::Release); + records + .iter() + .map(|record| record.worker_name.clone()) + .collect::>() + }; + loop { + let notified = self.pending_internal_notify.notified(); + if self.pending_internal_spawns.load(Ordering::Acquire) == 0 { + break; + } + notified.await; + } + let mut first_error = None; + for name in names { + if let Err(error) = self.remove_internal(&name).await { + first_error.get_or_insert(error); + } + } + if first_error.is_none() && self.internal_spawn_cleanup_failed.load(Ordering::Acquire) { + first_error = Some(io::Error::other( + "an in-flight Internal SubWorker failed cleanup during shutdown", + )); + } + first_error.map_or(Ok(()), Err) + } + + pub(crate) fn reopen_internal(&self) { + self.internal_shutting_down.store(false, Ordering::Release); + self.internal_spawn_cleanup_failed + .store(false, Ordering::Release); + } + /// Stop one direct Internal SubWorker and discard its registry/scope state. /// /// The child actor must acknowledge its stop before the registry is removed. @@ -731,6 +853,12 @@ impl SpawnedWorkerRegistry { .stop() .await .map_err(|error| io::Error::other(error.to_string()))?; + Box::pin(record.child_registry.shutdown_internal()).await?; + record + .workdir_tool_scope + .close() + .await + .map_err(|error| io::Error::other(error.to_string()))?; let summary = record.stop_summary(); self.reclaim_record_scope(&record)?; let removed = @@ -966,7 +1094,7 @@ mod tests { deny: Vec::new(), }) .unwrap(); - let source = workdir::delegation_capable_session(Arc::new( + let source = workdir::WorkdirToolBroker::new(Arc::new( workdir::LocalWorkdirSession::materialized_bound( workdir::Workdir::new("registry-test"), root.clone(), @@ -976,13 +1104,14 @@ mod tests { ), )); let delegation = source - .delegate(workdir::WorkdirDelegationRequest { - rules: vec![workdir::WorkdirDelegationRule { + .scope(workdir::WorkdirToolScope { + rules: vec![workdir::WorkdirToolScopeRule { target: workdir::WorkdirPath::new("").unwrap(), - permission: workdir::WorkdirDelegationPermission::Read, + permission: workdir::WorkdirToolScopePermission::Read, recursive: true, }], cwd: workdir::WorkdirPath::new("").unwrap(), + command: false, }) .await .unwrap(); @@ -993,6 +1122,7 @@ mod tests { delegation, Vec::new(), session, + registry(), None, ), sender, @@ -1230,6 +1360,143 @@ mod tests { } } + #[tokio::test] + async fn parent_shutdown_stops_all_internal_workers_before_returning() { + let registry = registry(); + for name in ["first", "second"] { + let (record, _events) = record(name, InternalWorkerVisibility::ParentClient).await; + record + .session + .force_status(InternalWorkerSessionStatus::Running); + install_record(®istry, record); + } + + registry.shutdown_internal().await.unwrap(); + + assert!(registry.list_internal().is_empty()); + assert!(registry.get_internal("first").is_none()); + assert!(registry.get_internal("second").is_none()); + } + + #[tokio::test] + async fn shutdown_rejects_new_reservations_until_reopened() { + let registry = registry(); + registry.shutdown_internal().await.unwrap(); + assert!(registry.reserve_internal_name("late-child".into()).is_err()); + + registry.reopen_internal(); + let reservation = registry.reserve_internal_name("late-child".into()).unwrap(); + drop(reservation); + } + + #[tokio::test] + async fn concurrent_commit_and_shutdown_leave_no_live_internal_worker() { + let registry = registry(); + let reservation = registry + .reserve_internal_name("racing-child".into()) + .unwrap(); + let (record, _events) = + record("racing-child", InternalWorkerVisibility::ParentClient).await; + let scope = record.workdir_tool_scope.clone(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let commit_barrier = barrier.clone(); + let commit = tokio::spawn(async move { + commit_barrier.wait().await; + reservation.commit(record).await + }); + let shutdown_registry = registry.clone(); + let shutdown = tokio::spawn(async move { + barrier.wait().await; + shutdown_registry.shutdown_internal().await + }); + + let commit = commit.await.unwrap(); + shutdown.await.unwrap().unwrap(); + if let Err(error) = commit { + assert_eq!(error.kind(), io::ErrorKind::Interrupted); + } + + assert!(registry.list_internal().is_empty()); + assert!(!scope.is_active()); + } + + #[tokio::test] + async fn shutdown_fences_a_reservation_that_has_not_committed() { + let registry = registry(); + let reservation = registry + .reserve_internal_name("racing-child".into()) + .unwrap(); + let (record, _events) = + record("racing-child", InternalWorkerVisibility::ParentClient).await; + + let mut shutdown = { + let registry = registry.clone(); + tokio::spawn(async move { registry.shutdown_internal().await }) + }; + while !registry.internal_shutting_down.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), &mut shutdown) + .await + .is_err(), + "shutdown must wait for the pending spawn to roll back" + ); + let error = reservation.commit(record).await.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Interrupted); + shutdown.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn rejected_spawn_cleanup_failure_keeps_shutdown_failed_closed() { + let registry = registry(); + let reservation = registry + .reserve_internal_name("cleanup-failure".into()) + .unwrap(); + let (record, _events) = + record("cleanup-failure", InternalWorkerVisibility::ParentClient).await; + record.session.force_stop_failure(); + let shutdown = { + let registry = registry.clone(); + tokio::spawn(async move { registry.shutdown_internal().await }) + }; + while !registry.internal_shutting_down.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + + let error = reservation.commit(record).await.unwrap_err(); + assert!( + error + .to_string() + .contains("stop rejected Internal SubWorker") + ); + let shutdown_error = shutdown.await.unwrap().unwrap_err(); + assert!( + shutdown_error + .to_string() + .contains("failed cleanup during shutdown") + ); + assert!(registry.internal_shutting_down.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn shutdown_recursively_stops_grandchildren_before_parent_scope_release() { + let registry = registry(); + let (child, _child_events) = record("child", InternalWorkerVisibility::ParentClient).await; + let child_registry = child.child_registry.clone(); + let (grandchild, _grandchild_events) = + record("grandchild", InternalWorkerVisibility::ParentClient).await; + let grandchild_scope = grandchild.workdir_tool_scope.clone(); + install_record(&child_registry, grandchild); + install_record(®istry, child); + + registry.shutdown_internal().await.unwrap(); + + assert!(registry.list_internal().is_empty()); + assert!(child_registry.list_internal().is_empty()); + assert!(!grandchild_scope.is_active()); + } + #[tokio::test] async fn running_worker_is_stopped_before_removal() { let registry = registry(); diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 6bb1b47b..6ec7c3cc 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -22,8 +22,7 @@ use manifest::{ use serde::Deserialize; use tokio::sync::mpsc; use workdir::{ - WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, WorkdirPath, - WorkdirSessionHandle, + WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission, WorkdirToolScopeRule, }; use crate::PromptCatalogSource; @@ -64,6 +63,9 @@ struct SubWorkerSpawnInput { /// spawner's explicit delegation authority; direct tool scope alone is not /// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true. scope: Vec, + /// Explicitly grant command execution through the parent-owned Workdir tool broker. + #[serde(default)] + command: bool, /// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate. /// Review capability material is generated by the trusted spawn layer. #[serde(default)] @@ -284,8 +286,8 @@ pub struct SubWorkerSpawnTool { workspace_root: PathBuf, /// Directory the spawned SubWorker's tools should use when the LLM did not /// override it. Defaults to the spawner's cwd. - /// Active provider-backed Workdir session from which child leases are captured. - source_workdir_session: Option, + /// Parent-owned broker for scoped Workdir tool execution. + workdir_tool_broker: Option, /// Parent-owned in-memory registry shared by the five SubWorker tools. registry: Arc, /// Spawner's resolved Manifest. `profile = "inherit"` derives the @@ -312,7 +314,7 @@ impl SubWorkerSpawnTool { runtime_base: PathBuf, bash_output_dir: PathBuf, workspace_root: PathBuf, - source_workdir_session: Option, + workdir_tool_broker: Option, registry: Arc, spawner_manifest: WorkerManifest, prompt_loader: PromptCatalogSource, @@ -325,7 +327,7 @@ impl SubWorkerSpawnTool { runtime_base, bash_output_dir, workspace_root, - source_workdir_session, + workdir_tool_broker, registry, spawner_manifest, prompt_loader, @@ -358,6 +360,11 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro "Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(), )); } + if !input.command { + return Err(ToolError::InvalidArgument( + "Merge Request Reviewer SubWorkers require an explicit command grant".to_string(), + )); + } Ok(()) } @@ -387,7 +394,7 @@ impl Tool for SubWorkerSpawnTool { .reserve_internal_name(input.name.clone()) .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - let mut workdir_rules = parse_workdir_scope(&input.scope)?; + let workdir_rules = parse_workdir_scope(&input.scope)?; let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name); tokio::fs::create_dir_all(&child_bash_output_dir) .await @@ -397,28 +404,15 @@ impl Tool for SubWorkerSpawnTool { child_bash_output_dir.display() )) })?; - let source_workdir_session = - require_active_workdir_session(self.source_workdir_session.as_ref())?; - let transports_delegation_context = source_workdir_session.transports_delegation_context(); - // Provider-transported sessions resolve every delegation rule in the - // receiving Workdir namespace. The Bash spill directory instead belongs - // to this Worker host, so forwarding it would widen the request with a - // foreign absolute path and fail the provider's existing scope check. - if !transports_delegation_context { - workdir_rules.push(WorkdirDelegationRule { - target: WorkdirPath::new_scoped(child_bash_output_dir.to_string_lossy()) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, - permission: WorkdirDelegationPermission::Read, - recursive: true, - }); - } - let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?; - let workdir_delegation = source_workdir_session - .delegate(delegation_request) + let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?; + let tool_scope = workdir_tool_scope(input.cwd.as_deref(), workdir_rules, input.command)?; + let workdir_scope = workdir_tool_broker + .scope(tool_scope) .await .map_err(|error| { - ToolError::InvalidArgument(format!("delegate Workdir session: {error}")) + ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}")) })?; + let child_workdir_tool_broker = workdir_scope.broker(); let spawn_selector = parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| { @@ -507,7 +501,6 @@ impl Tool for SubWorkerSpawnTool { ) .await .map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?; - child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone())); child .add_scope_rules([ScopeRule { target: child_bash_output_dir.clone(), @@ -527,6 +520,7 @@ impl Tool for SubWorkerSpawnTool { self.runtime_base.clone(), child_registry.clone(), None, + Some(child_workdir_tool_broker.clone()), ) .await .map_err(|error| { @@ -555,13 +549,16 @@ impl Tool for SubWorkerSpawnTool { InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped ) { if let Some(registry) = registry.upgrade() { - if let Err(error) = registry.reclaim_internal_scope(&child_name) { - tracing::warn!( - child_name, - %error, - "failed to reclaim delegated scope after Internal SubWorker failure" - ); - } + let child_name = child_name.clone(); + tokio::spawn(async move { + if let Err(error) = registry.close_internal_scope(&child_name).await { + tracing::warn!( + child_name, + %error, + "failed to close parent-owned Workdir tools after Internal SubWorker failure" + ); + } + }); } } let message = format!( @@ -569,6 +566,7 @@ impl Tool for SubWorkerSpawnTool { ); parent_notifications.notify(child_name.clone(), message, true); })), + Some(child_workdir_tool_broker.clone()), ) .await; let session = session_result.map_err(|error| { @@ -619,15 +617,19 @@ impl Tool for SubWorkerSpawnTool { ), body.to_string(), ); - let response = self - .workspace_context - .client() - .execute(request) - .map_err(|error| { - ToolError::ExecutionFailed(format!("register review capability: {error}")) - })?; + let response = match self.workspace_context.client().execute(request) { + Ok(response) => response, + Err(error) => { + let _ = session.stop().await; + let _ = workdir_scope.close().await; + return Err(ToolError::ExecutionFailed(format!( + "register review capability: {error}" + ))); + } + }; if !response.is_success() { let _ = session.stop().await; + let _ = workdir_scope.close().await; return Err(ToolError::ExecutionFailed(format!( "register review capability failed with status {}: {}", response.status, response.body @@ -638,14 +640,14 @@ impl Tool for SubWorkerSpawnTool { let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new( input.name.clone(), scope_allow, - workdir_delegation, + workdir_scope, #[cfg(test)] installed_tools, session.clone(), + child_registry, child_change_tracker, ); - if let Err(error) = name_reservation.commit(record) { - let _ = session.stop().await; + if let Err(error) = name_reservation.commit(record).await { return Err(ToolError::ExecutionFailed(format!( "register Internal Worker session: {error}" ))); @@ -691,18 +693,18 @@ fn logical_workdir_path(value: &str, field: &str) -> Result { }) } -fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result, ToolError> { +fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result, ToolError> { if rules.is_empty() { return Err(ToolError::InvalidArgument("scope must not be empty".into())); } rules .iter() .map(|rule| { - Ok(WorkdirDelegationRule { + Ok(WorkdirToolScopeRule { target: logical_workdir_path(&rule.target, "scope.target")?, permission: match rule.permission { - PermissionInput::Read => WorkdirDelegationPermission::Read, - PermissionInput::Write => WorkdirDelegationPermission::Write, + PermissionInput::Read => WorkdirToolScopePermission::Read, + PermissionInput::Write => WorkdirToolScopePermission::Write, }, recursive: rule.recursive, }) @@ -710,22 +712,24 @@ fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result, - rules: Vec, -) -> Result { - Ok(WorkdirDelegationRequest { + rules: Vec, + command: bool, +) -> Result { + Ok(WorkdirToolScope { rules, cwd: logical_workdir_path(cwd.unwrap_or("."), "cwd")?, + command, }) } -fn require_active_workdir_session( - session: Option<&WorkdirSessionHandle>, -) -> Result<&WorkdirSessionHandle, ToolError> { - session.ok_or_else(|| { +fn require_workdir_tool_broker( + broker: Option<&WorkdirToolBroker>, +) -> Result<&WorkdirToolBroker, ToolError> { + broker.ok_or_else(|| { ToolError::InvalidArgument( - "SubWorkerSpawn requires an active Workdir session; attach a Workdir before delegating filesystem access" + "SubWorkerSpawn requires parent-owned Workdir tools; attach a Workdir before granting filesystem access" .to_string(), ) }) @@ -963,7 +967,7 @@ pub(crate) fn sub_worker_spawn_tool( runtime_base: PathBuf, bash_output_dir: PathBuf, workspace_root: PathBuf, - source_workdir_session: Option, + workdir_tool_broker: Option, registry: Arc, spawner_manifest: WorkerManifest, prompts: Arc>, @@ -975,7 +979,7 @@ pub(crate) fn sub_worker_spawn_tool( runtime_base, bash_output_dir, workspace_root, - source_workdir_session, + workdir_tool_broker, registry, spawner_manifest, prompts, @@ -989,7 +993,7 @@ fn sub_worker_spawn_tool_impl( runtime_base: PathBuf, bash_output_dir: PathBuf, workspace_root: PathBuf, - source_workdir_session: Option, + workdir_tool_broker: Option, registry: Arc, spawner_manifest: WorkerManifest, prompts: Arc>, @@ -1021,7 +1025,7 @@ fn sub_worker_spawn_tool_impl( runtime_base.clone(), bash_output_dir.clone(), workspace_root.clone(), - source_workdir_session.clone(), + workdir_tool_broker.clone(), registry.clone(), spawner_manifest.clone(), prompts.load_full().source(), @@ -1054,12 +1058,12 @@ mod tests { }; #[test] - fn missing_active_workdir_session_fails_deterministically() { - let error = require_active_workdir_session(None).unwrap_err(); + fn missing_parent_workdir_tool_broker_fails_deterministically() { + let error = require_workdir_tool_broker(None).unwrap_err(); assert!(matches!( error, ToolError::InvalidArgument(message) - if message.contains("requires an active Workdir session") + if message.contains("requires parent-owned Workdir tools") )); } @@ -1096,6 +1100,7 @@ mod tests { let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({ "name":"reviewer","task":"review","profile":"builtin:reviewer", "scope":[{"target":"work","permission":"write"}], + "command":true, "review":{"ticket_id":"T1"} })) .unwrap(); @@ -1219,7 +1224,7 @@ enabled = false let fail_requests = Arc::new(AtomicBool::new(false)); let prompt_loader = PromptCatalogSource::builtins_only(); let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8); - let source_workdir_session = workdir::delegation_capable_session(Arc::new( + let workdir_tool_broker = workdir::WorkdirToolBroker::new(Arc::new( workdir::LocalWorkdirSession::materialized_bound( workdir::Workdir::new("test-workdir"), workspace_root.clone(), @@ -1238,7 +1243,7 @@ enabled = false runtime.path().to_path_buf(), bash_output_dir.clone(), workspace_root.clone(), - Some(source_workdir_session), + Some(workdir_tool_broker), registry.clone(), manifest.clone(), prompt_loader, @@ -1261,7 +1266,8 @@ enabled = false "target": ".", "permission": "write", "recursive": true - }] + }], + "command": true }); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); @@ -1296,15 +1302,6 @@ enabled = false let record = registry .get_internal("reviewer-child") .expect("Internal reviewer registry record"); - let child_bash_output_dir = bash_output_dir.join("sub-workers").join("reviewer-child"); - record - .workdir_delegation - .scoped_session - .stat(workdir::StatRequest { - path: WorkdirPath::new_scoped(child_bash_output_dir.to_string_lossy()).unwrap(), - }) - .await - .expect("local child retains read scope for its Bash output directory"); for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { assert!( record.installed_tools.iter().any(|name| name == required), @@ -1423,7 +1420,7 @@ enabled = false "Stopped terminal child must release its delegated Workdir session" ); assert!( - !record.workdir_delegation.is_active(), + !record.workdir_tool_scope.is_active(), "stopped child must revoke cloned scoped sessions" ); assert!(registry.get_internal("reviewer-child").is_some()); @@ -1478,7 +1475,7 @@ enabled = false Arc::new(AvailableWorkspaceClient), ); let remote_client = Arc::new(StrictRemoteWorkdirWorkspaceClient::default()); - let source_workdir_session = workdir::delegation_capable_session( + let workdir_tool_broker = workdir::WorkdirToolBroker::new( WorkspaceAttachedWorkdirSession::handle(remote_client.clone()), ); let calls = Arc::new(AtomicUsize::new(0)); @@ -1493,7 +1490,7 @@ enabled = false runtime.path().to_path_buf(), bash_output_dir.clone(), workspace_root.clone(), - Some(source_workdir_session), + Some(workdir_tool_broker), registry.clone(), manifest, PromptCatalogSource::builtins_only(), @@ -1533,51 +1530,12 @@ enabled = false record.session.wait_until_idle().await, crate::internal_worker::InternalWorkerSessionStatus::Idle ); + assert!(record.installed_tools.iter().any(|tool| tool == "Write")); + assert!(!record.installed_tools.iter().any(|tool| tool == "Bash")); assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!( - remote_client - .foreign_scope_rejections - .load(Ordering::SeqCst), - 0 - ); - let child_bash_output_dir = bash_output_dir.join("sub-workers").join("remote-child"); - assert!(child_bash_output_dir.is_dir()); - for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { - assert!( - record.installed_tools.iter().any(|name| name == required), - "remote write-scoped child is missing {required}: {:?}", - record.installed_tools - ); - } - - let remote_requests = remote_client.requests(); - let operate_requests = remote_requests - .iter() - .filter(|request| request.body.is_some()) - .collect::>(); - assert_eq!( - operate_requests.len(), - 1, - "remote requests: {remote_requests:?}" - ); - let operation_body: serde_json::Value = serde_json::from_str( - operate_requests[0] - .body - .as_deref() - .expect("remote operation body"), - ) - .unwrap(); - let rules = operation_body["delegations"][0]["rules"] - .as_array() - .expect("delegation rules"); - assert_eq!(rules.len(), 1, "remote operation body: {operation_body}"); - assert_eq!(rules[0]["target"], ""); assert!( - !operation_body.to_string().contains( - child_bash_output_dir - .to_str() - .expect("UTF-8 test output directory") - ) + remote_client.requests().is_empty(), + "spawning a child must not open or delegate a provider Workdir session" ); } @@ -1589,6 +1547,7 @@ enabled = false .and_then(serde_json::Value::as_object) .expect("schema properties"); assert!(properties.contains_key("cwd"), "schema: {schema}"); + assert!(properties.contains_key("command"), "schema: {schema}"); let required = schema .get("required") .and_then(serde_json::Value::as_array) @@ -1718,7 +1677,6 @@ enabled = false #[derive(Debug, Default)] struct StrictRemoteWorkdirWorkspaceClient { requests: Mutex>, - foreign_scope_rejections: AtomicUsize, } impl StrictRemoteWorkdirWorkspaceClient { @@ -1750,59 +1708,10 @@ enabled = false self.requests .lock() .expect("remote Workdir request lock") - .push(request.clone()); - if request.path.ends_with("/fence") { - return Ok(WorkspaceResponse { - status: 200, - body: serde_json::json!({ "value": "remote-fence-1" }).to_string(), - }); - } - - let body: serde_json::Value = serde_json::from_str( - request - .body - .as_deref() - .ok_or_else(|| WorkspaceClientError::Request("missing request body".into()))?, - ) - .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; - let has_foreign_scope = body - .get("delegations") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .flat_map(|delegation| { - delegation - .get("rules") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - }) - .filter_map(|rule| rule.get("target").and_then(serde_json::Value::as_str)) - .any(|target| Path::new(target).is_absolute()); - if has_foreign_scope { - self.foreign_scope_rejections.fetch_add(1, Ordering::SeqCst); - return Ok(WorkspaceResponse { - status: 403, - body: serde_json::json!({ - "code": "out_of_scope", - "message": "Worker-host path is outside the remote Workdir namespace" - }) - .to_string(), - }); - } - - Ok(WorkspaceResponse { - status: 200, - body: serde_json::json!({ - "operation": "stat", - "result": { - "path": "", - "kind": "directory", - "size": 0 - } - }) - .to_string(), - }) + .push(request); + Err(WorkspaceClientError::Request( + "SubWorker spawn must not call the remote Workdir provider".into(), + )) } } diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 35bf5193..ba1fada3 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -346,6 +346,7 @@ async fn shutdown_closes_bound_workdir_session() { command: "sleep 30".to_owned(), timeout_secs: 60, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: None, }) @@ -395,6 +396,7 @@ async fn controller_projects_workdir_command_events_and_snapshot_state() { command: "printf ready; sleep 0.3; printf done".to_owned(), timeout_secs: 5, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: Some("tool-command-1".into()), }) @@ -508,6 +510,7 @@ async fn controller_refreshes_command_snapshot_after_high_output_provider_lag() .to_owned(), timeout_secs: 10, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: Some("tool-high-output".into()), }) @@ -589,6 +592,7 @@ async fn controller_startup_failure_closes_bound_workdir_session() { command: "printf unreachable".to_owned(), timeout_secs: 5, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: None, }) diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 8c8dd902..a2cf8f87 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -539,6 +539,7 @@ pub enum WorkspaceAuthConfig { pub struct WorkspacePermissionSummary { pub manage_repositories: bool, pub manage_secrets: bool, + pub manage_runtimes: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1135,6 +1136,7 @@ pub struct ObjectiveLinkTicketRequest { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeSourceKind { EmbeddedWorkerRuntime, @@ -1142,6 +1144,7 @@ pub enum RuntimeSourceKind { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeSourceStatus { Active, @@ -1149,6 +1152,7 @@ pub enum RuntimeSourceStatus { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RuntimeIdentityAuthority { RuntimeRegistryProjection, @@ -1156,6 +1160,8 @@ pub enum RuntimeIdentityAuthority { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct RuntimeSourceSummary { pub kind: RuntimeSourceKind, pub status: RuntimeSourceStatus, @@ -1164,6 +1170,7 @@ pub struct RuntimeSourceSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RuntimeSummary { pub runtime_id: String, pub label: String, @@ -1180,6 +1187,8 @@ pub struct RuntimeSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct RuntimeManagementSummary { pub built_in: bool, pub config_managed: bool, @@ -1189,12 +1198,124 @@ pub struct RuntimeManagementSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct WorkspaceRuntimeResource { #[serde(flatten)] pub runtime: RuntimeSummary, pub management: RuntimeManagementSummary, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrustKeyStatus { + Unconfigured, + Active, + Revoked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeTrustKeyState { + pub status: RuntimeTrustKeyStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revoked_at: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrustAuditAction { + Created, + Replaced, + Reactivated, + Revoked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeTrustAuditEntry { + pub action: RuntimeTrustAuditAction, + pub actor_account_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_fingerprint: Option, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub revision: u64, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeDetail { + pub workspace_id: String, + pub runtime: WorkspaceRuntimeResource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + pub trust_key: RuntimeTrustKeyState, + #[serde(default)] + pub recent_audit: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeTrustKeyRevealResponse { + pub public_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PutRuntimeTrustKeyRequest { + pub public_key: String, + #[serde(default)] + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub expected_revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RevokeRuntimeTrustKeyRequest { + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub expected_revision: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTrustConflictKind { + StaleRevision, + FingerprintInUse, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeTrustConflictResponse { + pub error: RuntimeTrustConflictKind, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub current_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_fingerprint: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CreateRemoteRuntimeRequest { @@ -2394,6 +2515,23 @@ pub fn catalog_typescript() -> String { RepositoryListResponse::decl(&config), RepositoryDetailResponse::decl(&config), RepositoryLogResponse::decl(&config), + RuntimeSourceKind::decl(&config), + RuntimeSourceStatus::decl(&config), + RuntimeIdentityAuthority::decl(&config), + RuntimeSourceSummary::decl(&config), + RuntimeSummary::decl(&config), + RuntimeManagementSummary::decl(&config), + WorkspaceRuntimeResource::decl(&config), + RuntimeTrustKeyStatus::decl(&config), + RuntimeTrustKeyState::decl(&config), + RuntimeTrustAuditAction::decl(&config), + RuntimeTrustAuditEntry::decl(&config), + WorkspaceRuntimeDetail::decl(&config), + RuntimeTrustKeyRevealResponse::decl(&config), + PutRuntimeTrustKeyRequest::decl(&config), + RevokeRuntimeTrustKeyRequest::decl(&config), + RuntimeTrustConflictKind::decl(&config), + RuntimeTrustConflictResponse::decl(&config), RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestResponse::decl(&config), @@ -3022,7 +3160,8 @@ mod tests { }}, "permissions": { "manage_repositories": true, - "manage_secrets": true + "manage_secrets": true, + "manage_runtimes": true }, "extension_points": { "store": "sqlite", @@ -3086,6 +3225,81 @@ mod tests { assert!(serde_json::from_value::(stale).is_err()); } + #[test] + fn runtime_detail_and_trust_mutations_are_closed_and_typed() { + let detail = serde_json::json!({ + "workspace_id": "workspace-test", + "runtime": { + "runtime_id": "runtime-test", + "label": "Runtime Test", + "kind": "remote_http", + "status": "active", + "source": { + "kind": "remote_http", + "status": "active", + "identity_authority": "runtime_registry_projection", + "note": "active" + }, + "host_ids": [], + "worker_creation_available": true, + "os": "linux", + "arch": "x86_64", + "diagnostics": [], + "management": { + "built_in": false, + "config_managed": true, + "removable": true, + "endpoint_configured": true, + "token_ref_configured": false + } + }, + "endpoint": "https://runtime.example", + "trust_key": { + "status": "active", + "fingerprint": "SHA256:test", + "revision": 2, + "created_at": "2026-09-01T12:00:00Z", + "updated_at": "2026-09-01T13:00:00Z" + }, + "recent_audit": [{ + "action": "replaced", + "actor_account_id": "account-owner", + "old_fingerprint": "SHA256:old", + "new_fingerprint": "SHA256:test", + "revision": 2, + "at": "2026-09-01T13:00:00Z" + }] + }); + let parsed: WorkspaceRuntimeDetail = serde_json::from_value(detail.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), detail); + + let mut unknown = detail; + unknown["trust_key"]["private_key"] = serde_json::json!("forbidden"); + assert!(serde_json::from_value::(unknown).is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "public_key": "yoi-ed25519-pub:v1:key", + "private_key": "forbidden" + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "public_key": "key", + "expected_revision": 1, + "replace": true + })) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "expected_revision": 1, + "delete_runtime": true + })) + .is_err() + ); + } + #[test] fn runtime_connection_test_response_is_closed_and_typed() { let compatible = serde_json::json!({ diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index a7e5030f..720ff8ea 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -440,6 +440,7 @@ CREATE TABLE workspace_runtime_bindings ( base_url TEXT NOT NULL, public_key TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL, + binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, @@ -447,6 +448,22 @@ CREATE TABLE workspace_runtime_bindings ( UNIQUE (workspace_id, public_key_fingerprint), FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT ); +CREATE TABLE workspace_runtime_binding_audit ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_revision), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +); +CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); CREATE TABLE typed_ticket_artifacts ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, PRIMARY KEY (workspace_id, ticket_id, relative_path), diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 7f1cc685..496750a1 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -120,6 +120,15 @@ pub enum Error { WorkspaceConfigConflict(String), #[error("Runtime binding conflict: {0}")] RuntimeBindingConflict(String), + #[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")] + RuntimeBindingRevisionConflict { + expected: Option, + actual: Option, + }, + #[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")] + RuntimeBindingFingerprintConflict { fingerprint: String }, + #[error("Runtime binding was not found for {runtime_id}")] + RuntimeBindingNotFound { runtime_id: String }, #[error("Repository conflict: {0}")] RepositoryConflict(String), #[error("Registry inconsistency: {0}")] diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index b1e1d7f5..882d4436 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -324,6 +324,7 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box, } enum RegisteredWorkdirSession { @@ -396,7 +397,6 @@ impl WorkdirSessionRegistry { worker: RuntimeWorkerRef, source: WorkdirSessionHandle, provider_handle: CommandHandle, - delegations: Vec, ) -> CommandHandle { let external_handle = loop { let candidate = CommandHandle(Uuid::now_v7().to_string()); @@ -412,7 +412,6 @@ impl WorkdirSessionRegistry { WorkdirCommandSession { source, provider_handle, - delegations, }, ); external_handle @@ -585,6 +584,7 @@ pub struct WorkspaceApi { prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, authority: SqliteWorkspaceAuthority, runtime: Arc, + runtime_binding_expectations: Arc>>, companion: Arc, orchestrator_spawn_lock: Arc>, orchestrator_attention_fingerprint: Arc>>, @@ -1575,6 +1575,7 @@ impl WorkspaceApi { base_url: "in-process://embedded".to_owned(), public_key: embedded_identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: config.workspace_created_at.clone(), updated_at: config.workspace_created_at.clone(), revoked_at: None, @@ -1614,18 +1615,22 @@ impl WorkspaceApi { .then(|| (source.runtime_id.clone(), source.base_url.clone())) }) .collect::>(); - let expected_runtime_bindings = Arc::new( - store - .list_workspace_runtime_bindings(&config.workspace_id, false) - .await? - .into_iter() - .filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID) - .filter(|binding| { - configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url) - }) - .map(|binding| (binding.runtime_id.clone(), binding)) - .collect::>(), - ); + let expected_runtime_bindings = store + .list_workspace_runtime_bindings(&config.workspace_id, false) + .await? + .into_iter() + .filter(|binding| binding.runtime_id != EMBEDDED_RUNTIME_ID) + .filter(|binding| { + configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url) + }) + .map(|binding| { + ( + (binding.workspace_id.clone(), binding.runtime_id.clone()), + binding, + ) + }) + .collect::>(); + let workspace_id = config.workspace_id.clone(); let api = Self::new_with_execution_backend_and_broker( config, store, @@ -1634,15 +1639,34 @@ impl WorkspaceApi { Some(worker_remove_dispatcher), ) .await?; + *api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = expected_runtime_bindings; + let runtime_binding_expectations = Arc::clone(&api.runtime_binding_expectations); api.runtime.set_runtime_binding_gate(move |runtime_id| { - expected_runtime_bindings - .get(runtime_id) + runtime_binding_expectations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&(workspace_id.clone(), runtime_id.to_string())) .is_some_and(|expected| { runtime_binding_store .workspace_runtime_binding_matches(expected) .unwrap_or(false) }) }); + let active_expectations = api + .runtime_binding_expectations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for source in &api.config.remote_runtime_sources { + if !active_expectations + .contains_key(&(api.config.workspace_id.clone(), source.runtime_id.clone())) + { + api.runtime_subscription_broker + .unregister_runtime(&source.runtime_id); + } + } + drop(active_expectations); Ok(api) } @@ -1748,6 +1772,7 @@ impl WorkspaceApi { config, store, runtime, + runtime_binding_expectations: Arc::new(RwLock::new(HashMap::new())), companion, orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())), orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)), @@ -2625,10 +2650,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router { post(scoped_attach_current_worker_workdir) .delete(scoped_detach_current_worker_workdir), ) - .route( - "/api/w/{workspace_id}/workers/self/workdir-session/fence", - get(scoped_current_worker_workdir_session_fence), - ) .route( "/api/w/{workspace_id}/workers/self/workdir-session/operations", post(scoped_execute_current_worker_workdir_operation), @@ -2648,7 +2669,13 @@ fn build_inner_router(api: WorkspaceApi) -> Router { ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}", - delete(scoped_delete_remote_runtime), + get(scoped_get_runtime_detail).delete(scoped_delete_remote_runtime), + ) + .route( + "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", + get(scoped_reveal_runtime_trust_key) + .put(scoped_put_runtime_trust_key) + .delete(scoped_revoke_runtime_trust_key), ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", @@ -7380,46 +7407,11 @@ async fn scoped_detach_current_worker_workdir( })) } -async fn scoped_current_worker_workdir_session_fence( - State(api): State, - AxumPath(path): AxumPath, - headers: HeaderMap, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; - let session_lock = current_worker_session_lock(&api, &worker); - let _session_guard = session_lock.lock().await; - let link = current_worker_active_attachment(&api, &worker)?; - Ok(Json(WorkspaceWorkdirSessionFence { - value: current_worker_workdir_session_fence(&link), - })) -} - -fn current_worker_workdir_session_fence(link: &WorkerWorkdirLinkRecord) -> String { - format!("v1:{}\0{}", link.workdir_id, link.linked_at) -} - -fn validate_current_worker_workdir_session_fence( - link: &WorkerWorkdirLinkRecord, - expected: Option<&str>, -) -> Result<()> { - if expected.is_some_and(|expected| expected != current_worker_workdir_session_fence(link)) { - Err(Error::WorkdirAttachmentConflict( - "delegated Workdir session attachment changed".to_string(), - )) - } else { - Ok(()) - } -} - fn validated_current_worker_attachment( api: &WorkspaceApi, worker: &RuntimeWorkerRef, - expected_session_fence: Option<&str>, ) -> ApiResult { - let link = current_worker_active_attachment(api, worker)?; - validate_current_worker_workdir_session_fence(&link, expected_session_fence)?; - Ok(link) + current_worker_active_attachment(api, worker) } #[derive(Debug)] @@ -7474,23 +7466,13 @@ async fn scoped_execute_current_worker_workdir_operation( ) -> std::result::Result, WorkdirOperationApiError> { validate_workspace_scope(&api, &path.workspace_id)?; let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; - let expected_session_fence = request.expected_session_fence; - let delegations = request.delegations; let result = match request.operation { WorkdirSessionOperation::CommandStart(command) => { let session_lock = current_worker_session_lock(&api, &worker); let _session_guard = session_lock.lock().await; - let link = validated_current_worker_attachment( - &api, - &worker, - expected_session_fence.as_deref(), - )?; + let link = validated_current_worker_attachment(&api, &worker)?; let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?; - let applied = - apply_current_worker_delegations(&worker, source.clone(), delegations.clone()) - .await?; - let provider_handle = applied - .scoped_session + let provider_handle = source .start_command(command) .await .map_err(|error| current_worker_workdir_operation_error(&worker, error))?; @@ -7509,58 +7491,32 @@ async fn scoped_execute_current_worker_workdir_operation( .workdir_sessions .lock() .expect("Workdir session registry lock poisoned") - .register_command( - worker.clone(), - registered_source, - provider_handle, - delegations, - ); + .register_command(worker.clone(), registered_source, provider_handle); WorkdirSessionOperationResult::CommandStart(external_handle) } WorkdirSessionOperation::CommandStatus(external_handle) => { - let (session, provider_handle) = current_worker_command_session( - &api, - &worker, - &external_handle, - &delegations, - expected_session_fence.as_deref(), - ) - .await?; + let (session, provider_handle) = + current_worker_command_session(&api, &worker, &external_handle)?; session - .scoped_session .command_status(provider_handle) .await .map(WorkdirSessionOperationResult::CommandStatus) .map_err(|error| current_worker_workdir_operation_error(&worker, error))? } WorkdirSessionOperation::CommandOutput(mut output) => { - let (session, provider_handle) = current_worker_command_session( - &api, - &worker, - &output.handle, - &delegations, - expected_session_fence.as_deref(), - ) - .await?; + let (session, provider_handle) = + current_worker_command_session(&api, &worker, &output.handle)?; output.handle = provider_handle; session - .scoped_session .command_output(output) .await .map(WorkdirSessionOperationResult::CommandOutput) .map_err(|error| current_worker_workdir_operation_error(&worker, error))? } WorkdirSessionOperation::CommandCancel(external_handle) => { - let (session, provider_handle) = current_worker_command_session( - &api, - &worker, - &external_handle, - &delegations, - expected_session_fence.as_deref(), - ) - .await?; + let (session, provider_handle) = + current_worker_command_session(&api, &worker, &external_handle)?; session - .scoped_session .cancel_command(provider_handle) .await .map(|()| WorkdirSessionOperationResult::CommandCancel) @@ -7575,14 +7531,9 @@ async fn scoped_execute_current_worker_workdir_operation( | WorkdirSessionOperation::Grep(_)) => { let session_lock = current_worker_session_lock(&api, &worker); let _session_guard = session_lock.lock().await; - let link = validated_current_worker_attachment( - &api, - &worker, - expected_session_fence.as_deref(), - )?; + let link = validated_current_worker_attachment(&api, &worker)?; let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?; - let applied = apply_current_worker_delegations(&worker, source, delegations).await?; - execute_workdir_session_operation(&applied.scoped_session, operation) + execute_workdir_session_operation(&source, operation) .await .map_err(|error| current_worker_workdir_operation_error(&worker, error))? } @@ -7590,29 +7541,12 @@ async fn scoped_execute_current_worker_workdir_operation( Ok(Json(result)) } -async fn apply_current_worker_delegations( - worker: &RuntimeWorkerRef, - source: WorkdirSessionHandle, - delegations: Vec, -) -> Result { - workdir::apply_delegation_chain(source, delegations) - .await - .map_err(|error| Error::RuntimeOperationFailed { - runtime_id: worker.runtime_id.clone(), - code: "workdir_session_delegation_failed".to_string(), - message: error.to_string(), - }) -} - -async fn current_worker_command_session( +fn current_worker_command_session( api: &WorkspaceApi, worker: &RuntimeWorkerRef, external_handle: &CommandHandle, - delegations: &[workdir::WorkdirDelegationRequest], - expected_session_fence: Option<&str>, -) -> std::result::Result<(workdir::AppliedWorkdirDelegation, CommandHandle), WorkdirOperationApiError> -{ - let _link = validated_current_worker_attachment(api, worker, expected_session_fence)?; +) -> std::result::Result<(WorkdirSessionHandle, CommandHandle), WorkdirOperationApiError> { + let _link = validated_current_worker_attachment(api, worker)?; let command = api .workdir_sessions .lock() @@ -7624,15 +7558,7 @@ async fn current_worker_command_session( workdir::WorkdirError::UnknownCommand(external_handle.0.clone()), )) })?; - if command.delegations != delegations { - return Err(Error::WorkdirAttachmentConflict( - "command lifecycle delegation differs from CommandStart".to_string(), - ) - .into()); - } - let session = - apply_current_worker_delegations(worker, command.source, command.delegations).await?; - Ok((session, command.provider_handle)) + Ok((command.source, command.provider_handle)) } fn current_worker_workdir_operation_error( @@ -10958,11 +10884,249 @@ async fn scoped_create_remote_runtime( create_remote_runtime(State(api), Json(request)).await } +async fn scoped_get_runtime_detail( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json( + workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?, + )) +} + +async fn scoped_reveal_runtime_trust_key( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Runtime public key reveal", + ) + .await?; + if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_trust_managed_internally", + "the embedded Runtime trust key is managed by Server identity authority", + )); + } + let binding = api + .store + .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) + .await? + .ok_or_else(|| Error::RuntimeBindingNotFound { + runtime_id: path.runtime_id.clone(), + })?; + Ok(Json(RuntimeTrustKeyRevealResponse { + public_key: binding.public_key, + })) +} + +async fn scoped_put_runtime_trust_key( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> std::result::Result { + validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?; + let actor_account_id = actor.account_id.clone(); + if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_trust_managed_internally", + "the embedded Runtime trust key is managed by Server identity authority", + )); + } + if request.expected_revision == Some(0) { + return Err(settings_bad_request( + "invalid_runtime_binding_revision", + "expected_revision must be greater than zero when provided", + )); + } + if request.public_key.len() > 16 * 1024 { + return Err(settings_bad_request( + "runtime_public_key_too_large", + "public_key must be at most 16384 bytes", + )); + } + let existing = api + .store + .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) + .await?; + let source = api + .config + .remote_runtime_sources + .iter() + .find(|source| { + source.runtime_id == path.runtime_id + && source.workspace_id.as_deref() == Some(path.workspace_id.as_str()) + }) + .cloned(); + if let (Some(binding), Some(source)) = (&existing, &source) + && binding.base_url != source.base_url + { + return Err(settings_bad_request( + "runtime_endpoint_mismatch", + "the persisted Runtime endpoint no longer matches Server Runtime configuration; reconcile the endpoint before changing trust", + )); + } + let (display_name, base_url) = if let Some(binding) = &existing { + (binding.display_name.clone(), binding.base_url.clone()) + } else if let Some(source) = &source { + (source.display_name.clone(), source.base_url.clone()) + } else { + return Err(Error::UnknownRuntime(path.runtime_id.clone()).into()); + }; + let now = Utc::now().to_rfc3339(); + let record = WorkspaceRuntimeBinding { + workspace_id: path.workspace_id.clone(), + runtime_id: path.runtime_id.clone(), + display_name, + base_url, + public_key: request.public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + created_at: existing + .as_ref() + .map_or_else(|| now.clone(), |binding| binding.created_at.clone()), + updated_at: now, + revoked_at: None, + }; + let mutation = api + .store + .put_workspace_runtime_binding_key(record, request.expected_revision, &actor_account_id) + .await; + let (_, binding) = match mutation { + Ok(result) => result, + Err(error) => { + if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await { + return Ok(response); + } + return Err(error.into()); + } + }; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + (path.workspace_id.clone(), path.runtime_id.clone()), + binding, + ); + if let Some(source) = source { + api.runtime_subscription_broker + .register_remote_runtime(source); + } + Ok( + Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?) + .into_response(), + ) +} + +async fn scoped_revoke_runtime_trust_key( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> std::result::Result { + validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?; + let actor_account_id = actor.account_id.clone(); + if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_trust_managed_internally", + "the embedded Runtime trust key is managed by Server identity authority", + )); + } + if request.expected_revision == 0 { + return Err(settings_bad_request( + "invalid_runtime_binding_revision", + "expected_revision must be greater than zero", + )); + } + let now = Utc::now().to_rfc3339(); + let mutation = api + .store + .revoke_workspace_runtime_binding_key( + &path.workspace_id, + &path.runtime_id, + request.expected_revision, + &actor_account_id, + &now, + ) + .await; + let _ = match mutation { + Ok(result) => result, + Err(error) => { + if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await { + return Ok(response); + } + return Err(error.into()); + } + }; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(path.workspace_id.clone(), path.runtime_id.clone())); + api.runtime_subscription_broker + .unregister_runtime(&path.runtime_id); + Ok( + Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?) + .into_response(), + ) +} + +async fn runtime_trust_conflict_response( + api: &WorkspaceApi, + path: &ScopedRuntimePath, + error: &Error, +) -> Option { + let kind = match error { + Error::RuntimeBindingRevisionConflict { .. } => RuntimeTrustConflictKind::StaleRevision, + Error::RuntimeBindingFingerprintConflict { .. } => { + RuntimeTrustConflictKind::FingerprintInUse + } + _ => return None, + }; + let current = api + .store + .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) + .await + .ok() + .flatten(); + Some( + ( + StatusCode::CONFLICT, + Json(RuntimeTrustConflictResponse { + error: kind, + message: match kind { + RuntimeTrustConflictKind::StaleRevision => { + "the Runtime trust binding changed; reload before retrying".to_string() + } + RuntimeTrustConflictKind::FingerprintInUse => { + "the public key is already bound to another Runtime in this Workspace" + .to_string() + } + }, + current_revision: current.as_ref().map(|binding| binding.binding_revision), + current_fingerprint: current + .as_ref() + .map(|binding| binding.public_key_fingerprint.clone()), + }), + ) + .into_response(), + ) +} + async fn scoped_delete_remote_runtime( State(api): State, AxumPath(path): AxumPath, + Extension(actor): Extension, ) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime removal").await?; delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await } @@ -12215,6 +12379,7 @@ async fn get_workspace( permissions: WorkspacePermissionSummary { manage_repositories: is_owner, manage_secrets: is_owner, + manage_runtimes: is_owner, }, extension_points: WorkspaceExtensionPoints { store: "sqlite".to_string(), @@ -12455,7 +12620,7 @@ async fn create_remote_runtime( } Err(settings_bad_request( "runtime_public_key_required", - "remote Runtime registration requires an authenticated public key; use `yoi-server trust-runtime add` until the Workspace Runtime key API is available", + "remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered", )) } @@ -12473,8 +12638,13 @@ async fn delete_remote_runtime( .store .get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id) .await? - .filter(|binding| binding.revoked_at.is_none()) .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + if binding.revoked_at.is_none() { + return Err(Error::RuntimeBindingConflict( + "runtime trust is still active; revoke this Workspace's trust key with an expected revision before removing the inactive registration".to_string(), + ) + .into()); + } match api .runtime .unregister_if_idle(&runtime_id, api.config.max_records.min(200)) @@ -12503,14 +12673,6 @@ async fn delete_remote_runtime( )); } } - let now = Utc::now().to_rfc3339(); - if !api - .store - .revoke_workspace_runtime_binding_record(&binding.workspace_id, &binding.runtime_id, &now) - .await? - { - return Err(Error::UnknownRuntime(runtime_id).into()); - } Ok(StatusCode::NO_CONTENT) } @@ -14522,7 +14684,7 @@ async fn workspace_runtime_resources_response( let runtimes = api.runtime.list_runtimes(limit); let bindings = api .store - .list_workspace_runtime_bindings(workspace_id, false) + .list_workspace_runtime_bindings(workspace_id, true) .await?; let mut items = runtimes .items @@ -14599,6 +14761,130 @@ async fn workspace_runtime_resources_response( }) } +async fn workspace_runtime_detail( + api: &WorkspaceApi, + workspace_id: &str, + runtime_id: &str, +) -> ApiResult { + let binding = api + .store + .get_workspace_runtime_binding(workspace_id, runtime_id) + .await?; + let mut resource = workspace_runtime_resources_response(api, workspace_id) + .await? + .items + .into_iter() + .find(|resource| resource.runtime.runtime_id == runtime_id); + if resource.is_none() { + resource = binding.as_ref().map(|binding| WorkspaceRuntimeResource { + runtime: workspace_api::RuntimeSummary { + runtime_id: binding.runtime_id.clone(), + label: binding.display_name.clone(), + kind: "remote_http".to_string(), + status: "unavailable".to_string(), + source: workspace_api::RuntimeSourceSummary { + kind: workspace_api::RuntimeSourceKind::RemoteHttp, + status: workspace_api::RuntimeSourceStatus::Reserved, + identity_authority: + workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, + note: "The Runtime trust binding is not active in the Runtime registry." + .to_string(), + }, + host_ids: Vec::new(), + worker_creation_available: false, + os: String::new(), + arch: String::new(), + diagnostics: Vec::new(), + }, + management: RuntimeManagementSummary { + built_in: false, + config_managed: true, + removable: false, + endpoint_configured: !binding.base_url.trim().is_empty(), + token_ref_configured: false, + }, + }); + } + let mut resource = resource.ok_or_else(|| Error::UnknownRuntime(runtime_id.to_string()))?; + if let Some(binding) = &binding { + resource.management.config_managed = true; + resource.management.endpoint_configured = !binding.base_url.trim().is_empty(); + } + let endpoint = binding + .as_ref() + .map(|binding| binding.base_url.clone()) + .or_else(|| { + api.config + .remote_runtime_sources + .iter() + .find(|source| { + source.runtime_id == runtime_id + && source.workspace_id.as_deref() == Some(workspace_id) + }) + .map(|source| source.base_url.clone()) + }); + let trust_key = binding.as_ref().map_or( + RuntimeTrustKeyState { + status: RuntimeTrustKeyStatus::Unconfigured, + fingerprint: None, + revision: None, + created_at: None, + updated_at: None, + revoked_at: None, + }, + |binding| RuntimeTrustKeyState { + status: if binding.revoked_at.is_some() { + RuntimeTrustKeyStatus::Revoked + } else { + RuntimeTrustKeyStatus::Active + }, + fingerprint: Some(binding.public_key_fingerprint.clone()), + revision: Some(binding.binding_revision), + created_at: Some(binding.created_at.clone()), + updated_at: Some(binding.updated_at.clone()), + revoked_at: binding.revoked_at.clone(), + }, + ); + let recent_audit = api + .store + .list_workspace_runtime_binding_audit(workspace_id, runtime_id, 20) + .await? + .into_iter() + .map(project_runtime_trust_audit) + .collect::>>()?; + Ok(WorkspaceRuntimeDetail { + workspace_id: workspace_id.to_string(), + runtime: resource, + endpoint, + trust_key, + recent_audit, + }) +} + +fn project_runtime_trust_audit( + record: WorkspaceRuntimeBindingAuditRecord, +) -> Result { + let action = match record.action.as_str() { + "created" => RuntimeTrustAuditAction::Created, + "replaced" => RuntimeTrustAuditAction::Replaced, + "reactivated" => RuntimeTrustAuditAction::Reactivated, + "revoked" => RuntimeTrustAuditAction::Revoked, + other => { + return Err(Error::Store(format!( + "unsupported Runtime trust audit action {other}" + ))); + } + }; + Ok(RuntimeTrustAuditEntry { + action, + actor_account_id: record.actor_account_id, + old_fingerprint: record.old_fingerprint, + new_fingerprint: record.new_fingerprint, + revision: record.binding_revision, + at: record.at, + }) +} + fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> { validate_public_runtime_id(request.runtime_id.trim())?; let endpoint = request.endpoint.trim(); @@ -16151,6 +16437,8 @@ impl IntoResponse for ApiError { | Error::WorkdirAttachmentConflict(_) | Error::WorkspaceConfigConflict(_) | Error::RuntimeBindingConflict(_) + | Error::RuntimeBindingRevisionConflict { .. } + | Error::RuntimeBindingFingerprintConflict { .. } | Error::RepositoryConflict(_) => StatusCode::CONFLICT, Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { @@ -16179,6 +16467,7 @@ impl IntoResponse for ApiError { | Error::UnknownRuntime(_) | Error::UnknownWorker { .. } | Error::UnknownRepository(_) + | Error::RuntimeBindingNotFound { .. } | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { StatusCode::NOT_FOUND @@ -16567,6 +16856,7 @@ mod tests { command: "printf ready; sleep 30".to_string(), timeout_secs: 60, output_limit: 4096, + cwd: None, spill_dir: None, tool_call_id: Some("tool-call-command-session".to_string()), }) @@ -16576,12 +16866,8 @@ mod tests { let mut registry = WorkdirSessionRegistry::default(); registry.insert_attachment(worker.clone(), source.clone()); let registered_source = registry.remove_attachment(&worker).unwrap(); - let external_handle = registry.register_command( - worker.clone(), - registered_source, - provider_handle.clone(), - Vec::new(), - ); + let external_handle = + registry.register_command(worker.clone(), registered_source, provider_handle.clone()); assert_ne!(external_handle, provider_handle); let refreshed: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new( @@ -16726,6 +17012,7 @@ mod tests { base_url: "https://runtime.test".to_owned(), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "2026-01-01T00:00:00Z".to_owned(), updated_at: "2026-01-01T00:00:00Z".to_owned(), revoked_at: None, @@ -22219,6 +22506,192 @@ mod tests { assert_eq!(detail.provenance.id, "workspace:triage-errors"); } + #[tokio::test] + async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let owner_account_id = format!("account-{TEST_WORKSPACE_ID}"); + let owner = RequestActor { + user_id: "owner-user".to_string(), + account_id: owner_account_id.clone(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + auth_method: ActorAuthMethod::BrowserSession, + }; + let non_owner = RequestActor { + user_id: "other-user".to_string(), + account_id: "other-account".to_string(), + handle: "other".to_string(), + display_name: "Other".to_string(), + auth_method: ActorAuthMethod::ApiToken, + }; + let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let third = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let now = Utc::now().to_rfc3339(); + api.store + .put_workspace_runtime_binding_key( + WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + display_name: "Runtime A".to_string(), + base_url: "https://runtime.example".to_string(), + public_key: first.public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + created_at: now.clone(), + updated_at: now, + revoked_at: None, + }, + None, + &owner_account_id, + ) + .await + .unwrap(); + + let Json(detail) = scoped_get_runtime_detail( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(detail.trust_key.revision, Some(1)); + assert!(detail.trust_key.fingerprint.is_some()); + let Json(revealed) = scoped_reveal_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner.clone()), + ) + .await + .unwrap(); + assert!(revealed.public_key.starts_with("yoi-ed25519-pub:v1:")); + let denied_reveal = scoped_reveal_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(non_owner.clone()), + ) + .await + .unwrap_err(); + assert_eq!( + denied_reveal.into_response().status(), + StatusCode::FORBIDDEN + ); + + let response = scoped_put_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner.clone()), + Json(PutRuntimeTrustKeyRequest { + public_key: second.public_key, + expected_revision: Some(1), + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let detail: WorkspaceRuntimeDetail = serde_json::from_slice(&body).unwrap(); + assert_eq!(detail.trust_key.revision, Some(2)); + assert_eq!( + detail.recent_audit[0].action, + RuntimeTrustAuditAction::Replaced + ); + + let stale = scoped_put_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner.clone()), + Json(PutRuntimeTrustKeyRequest { + public_key: third.public_key, + expected_revision: Some(1), + }), + ) + .await + .unwrap(); + assert_eq!(stale.status(), StatusCode::CONFLICT); + let body = axum::body::to_bytes(stale.into_body(), usize::MAX) + .await + .unwrap(); + let conflict: RuntimeTrustConflictResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(conflict.error, RuntimeTrustConflictKind::StaleRevision); + assert_eq!(conflict.current_revision, Some(2)); + + let denied = scoped_revoke_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(non_owner), + Json(RevokeRuntimeTrustKeyRequest { + expected_revision: 2, + }), + ) + .await + .unwrap_err(); + assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN); + + let revoked = scoped_revoke_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner), + Json(RevokeRuntimeTrustKeyRequest { + expected_revision: 2, + }), + ) + .await + .unwrap(); + assert_eq!(revoked.status(), StatusCode::OK); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a") + .await + .unwrap() + .unwrap(); + assert_eq!(binding.binding_revision, 3); + assert!(binding.revoked_at.is_some()); + let listed = workspace_runtime_resources_response(&api, TEST_WORKSPACE_ID) + .await + .unwrap(); + let listed_runtime = listed + .items + .iter() + .find(|resource| resource.runtime.runtime_id == "runtime-a") + .expect("revoked binding must remain listed"); + assert!(listed_runtime.management.config_managed); + let detail = workspace_runtime_detail(&api, TEST_WORKSPACE_ID, "runtime-a") + .await + .unwrap(); + assert_eq!(detail.trust_key.status, RuntimeTrustKeyStatus::Revoked); + assert!(detail.runtime.management.config_managed); + assert!( + !api.runtime_binding_expectations + .read() + .unwrap() + .contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string())) + ); + } + #[tokio::test] async fn repository_secret_management_is_owner_only() { let temp = tempfile::tempdir().unwrap(); @@ -22266,6 +22739,16 @@ mod tests { test_api_with_recording_backend(workspace_root).await.0 } + fn test_owner_actor() -> RequestActor { + RequestActor { + user_id: "owner-user".to_string(), + account_id: format!("account-{TEST_WORKSPACE_ID}"), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + auth_method: ActorAuthMethod::BrowserSession, + } + } + fn test_repository_id(api: &WorkspaceApi) -> String { api.store .get_repository_by_key(TEST_WORKSPACE_ID, "test-repository") @@ -22753,6 +23236,7 @@ mod tests { base_url: "https://runtime.invalid".to_string(), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "2026-08-11T00:00:00Z".to_string(), updated_at: "2026-08-11T00:00:00Z".to_string(), revoked_at: None, @@ -23352,30 +23836,6 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } - #[test] - fn delegated_workdir_session_fence_rejects_reattached_link() { - let first = WorkerWorkdirLinkRecord { - workspace_id: "workspace-a".to_string(), - worker: workdir::workspace::RuntimeWorkerRef::new("runtime-a", "worker-a"), - workdir_id: "workdir-a".to_string(), - role: "primary".to_string(), - linked_at: "2026-01-01T00:00:00Z".to_string(), - unlinked_at: None, - }; - let expected = current_worker_workdir_session_fence(&first); - assert!(validate_current_worker_workdir_session_fence(&first, None).is_ok()); - assert!(validate_current_worker_workdir_session_fence(&first, Some(&expected)).is_ok()); - - let reattached = WorkerWorkdirLinkRecord { - linked_at: "2026-01-01T00:00:01Z".to_string(), - ..first - }; - assert!(matches!( - validate_current_worker_workdir_session_fence(&reattached, Some(&expected)), - Err(Error::WorkdirAttachmentConflict(_)) - )); - } - #[tokio::test] async fn backend_workdir_session_proxy_executes_typed_operations() { use manifest::Scope; @@ -24239,6 +24699,7 @@ mod tests { .unwrap() .public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -25356,6 +25817,7 @@ mod tests { base_url: "https://runtime.example.invalid".to_string(), public_key: identity.public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -25373,7 +25835,7 @@ mod tests { ) .unwrap(), ); - let app = build_inner_router(api); + let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor())); let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"); let initial = get_json(app.clone(), &runtimes_uri).await; @@ -25454,6 +25916,16 @@ mod tests { .expect("team runtime launch option"); assert_eq!(team_runtime["working_directory_required"], true); + api.store + .revoke_workspace_runtime_binding_key( + TEST_WORKSPACE_ID, + "team-runtime", + 1, + &format!("account-{TEST_WORKSPACE_ID}"), + &Utc::now().to_rfc3339(), + ) + .await + .unwrap(); let deleted = request_json( app.clone(), "DELETE", @@ -25505,6 +25977,7 @@ mod tests { .unwrap() .public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -25521,7 +25994,17 @@ mod tests { ) .unwrap(), ); - let app = build_inner_router(api); + api.store + .revoke_workspace_runtime_binding_key( + TEST_WORKSPACE_ID, + "busy-runtime", + 1, + &format!("account-{TEST_WORKSPACE_ID}"), + &Utc::now().to_rfc3339(), + ) + .await + .unwrap(); + let app = build_inner_router(api).layer(Extension(test_owner_actor())); let workers = get_json(app.clone(), "/api/workers").await; assert!( workers["items"] diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index db52cae2..0c7039b4 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::BTreeSet; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -15,9 +15,9 @@ use workspace_api::{RepositoryObservedStatus, RepositorySource}; use crate::{Error, Result}; -const PREVIOUS_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 51; -const RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; +const PREVIOUS_SCHEMA_VERSION: i64 = 51; +const LATEST_SCHEMA_VERSION: i64 = 52; +const RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; const MIGRATIONS: &[Migration] = &[Migration { version: LATEST_SCHEMA_VERSION, @@ -106,6 +106,7 @@ pub struct WorkspaceRuntimeBinding { pub base_url: String, pub public_key: String, pub public_key_fingerprint: String, + pub binding_revision: u64, pub created_at: String, pub updated_at: String, pub revoked_at: Option, @@ -118,6 +119,27 @@ pub enum WorkspaceRuntimeBindingUpsert { Replaced, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceRuntimeBindingMutation { + Created, + Unchanged, + Replaced, + Reactivated, + Revoked, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceRuntimeBindingAuditRecord { + pub workspace_id: String, + pub runtime_id: String, + pub actor_account_id: String, + pub action: String, + pub old_fingerprint: Option, + pub new_fingerprint: Option, + pub binding_revision: u64, + pub at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AccountRecord { pub account_id: String, @@ -567,6 +589,26 @@ pub trait ControlPlaneStore: Send + Sync { runtime_id: &str, revoked_at: &str, ) -> Result; + async fn put_workspace_runtime_binding_key( + &self, + record: WorkspaceRuntimeBinding, + expected_revision: Option, + actor_account_id: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>; + async fn revoke_workspace_runtime_binding_key( + &self, + workspace_id: &str, + runtime_id: &str, + expected_revision: u64, + actor_account_id: &str, + revoked_at: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>; + async fn list_workspace_runtime_binding_audit( + &self, + workspace_id: &str, + runtime_id: &str, + limit: usize, + ) -> Result>; async fn consume_worker_mutation_source_jti( &self, workspace_id: &str, @@ -1379,13 +1421,13 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let sql = if include_revoked { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 ORDER BY runtime_id ASC"# } else { r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND revoked_at IS NULL ORDER BY runtime_id ASC"# @@ -1407,7 +1449,7 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { conn.query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![workspace_id, runtime_id], @@ -1433,7 +1475,7 @@ impl SqliteWorkspaceStore { let existing = tx .query_row( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![record.workspace_id, record.runtime_id], @@ -1460,7 +1502,8 @@ impl SqliteWorkspaceStore { tx.execute( r#"UPDATE workspace_runtime_bindings SET display_name = ?3, base_url = ?4, public_key = ?5, - public_key_fingerprint = ?6, updated_at = ?7, revoked_at = ?8 + public_key_fingerprint = ?6, binding_revision = binding_revision + 1, + updated_at = ?7, revoked_at = ?8 WHERE workspace_id = ?1 AND runtime_id = ?2"#, params![ record.workspace_id, @@ -1480,8 +1523,8 @@ impl SqliteWorkspaceStore { tx.execute( r#"INSERT INTO workspace_runtime_bindings ( workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9)"#, params![ record.workspace_id, record.runtime_id, @@ -1512,13 +1555,311 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { let changed = conn.execute( r#"UPDATE workspace_runtime_bindings - SET revoked_at = ?3, updated_at = ?3 + SET revoked_at = ?3, updated_at = ?3, + binding_revision = binding_revision + 1 WHERE workspace_id = ?1 AND runtime_id = ?2 AND revoked_at IS NULL"#, params![workspace_id, runtime_id, revoked_at], )?; Ok(changed > 0) }) } + + pub fn put_workspace_runtime_binding_key( + &self, + mut record: WorkspaceRuntimeBinding, + expected_revision: Option, + actor_account_id: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + validate_identifier("workspace_id", &record.workspace_id)?; + validate_identifier("runtime_id", &record.runtime_id)?; + validate_identifier("actor_account_id", actor_account_id)?; + validate_non_empty("runtime display_name", &record.display_name)?; + validate_runtime_base_url(&record.base_url)?; + validate_non_empty("updated_at", &record.updated_at)?; + normalize_workspace_runtime_binding_key(&mut record)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = tx + .query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![record.workspace_id, record.runtime_id], + read_workspace_runtime_binding, + ) + .optional()?; + + if let Some(existing) = existing { + if existing.revoked_at.is_none() + && existing.public_key == record.public_key + && existing.public_key_fingerprint == record.public_key_fingerprint + { + tx.commit()?; + return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); + } + if expected_revision != Some(existing.binding_revision) { + return Err(Error::RuntimeBindingRevisionConflict { + expected: expected_revision, + actual: Some(existing.binding_revision), + }); + } + let fingerprint_owner = tx + .query_row( + r#"SELECT runtime_id FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND public_key_fingerprint = ?2 + AND runtime_id != ?3"#, + params![ + record.workspace_id, + record.public_key_fingerprint, + record.runtime_id + ], + |row| row.get::<_, String>(0), + ) + .optional()?; + if fingerprint_owner.is_some() { + return Err(Error::RuntimeBindingFingerprintConflict { + fingerprint: record.public_key_fingerprint, + }); + } + let action = if existing.revoked_at.is_some() { + WorkspaceRuntimeBindingMutation::Reactivated + } else { + WorkspaceRuntimeBindingMutation::Replaced + }; + let action_name = match action { + WorkspaceRuntimeBindingMutation::Reactivated => "reactivated", + WorkspaceRuntimeBindingMutation::Replaced => "replaced", + _ => unreachable!("action is selected above"), + }; + let next_revision = existing.binding_revision.checked_add(1).ok_or_else(|| { + Error::Store("Runtime binding revision overflow".to_string()) + })?; + tx.execute( + r#"UPDATE workspace_runtime_bindings + SET public_key = ?3, public_key_fingerprint = ?4, + binding_revision = ?5, updated_at = ?6, revoked_at = NULL + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![ + record.workspace_id, + record.runtime_id, + record.public_key, + record.public_key_fingerprint, + next_revision, + record.updated_at, + ], + )?; + insert_workspace_runtime_binding_audit( + &tx, + &record.workspace_id, + &record.runtime_id, + actor_account_id, + action_name, + Some(&existing.public_key_fingerprint), + Some(&record.public_key_fingerprint), + next_revision, + &record.updated_at, + )?; + let updated = tx.query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![record.workspace_id, record.runtime_id], + read_workspace_runtime_binding, + )?; + tx.commit()?; + return Ok((action, updated)); + } + + if expected_revision.is_some() { + return Err(Error::RuntimeBindingRevisionConflict { + expected: expected_revision, + actual: None, + }); + } + let fingerprint_owner = tx + .query_row( + r#"SELECT runtime_id FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND public_key_fingerprint = ?2"#, + params![record.workspace_id, record.public_key_fingerprint], + |row| row.get::<_, String>(0), + ) + .optional()?; + if fingerprint_owner.is_some() { + return Err(Error::RuntimeBindingFingerprintConflict { + fingerprint: record.public_key_fingerprint, + }); + } + record.binding_revision = 1; + record.revoked_at = None; + tx.execute( + r#"INSERT INTO workspace_runtime_bindings ( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, NULL)"#, + params![ + record.workspace_id, + record.runtime_id, + record.display_name, + record.base_url, + record.public_key, + record.public_key_fingerprint, + record.created_at, + record.updated_at, + ], + )?; + insert_workspace_runtime_binding_audit( + &tx, + &record.workspace_id, + &record.runtime_id, + actor_account_id, + "created", + None, + Some(&record.public_key_fingerprint), + 1, + &record.updated_at, + )?; + tx.commit()?; + Ok((WorkspaceRuntimeBindingMutation::Created, record)) + }) + } + + pub fn revoke_workspace_runtime_binding_key( + &self, + workspace_id: &str, + runtime_id: &str, + expected_revision: u64, + actor_account_id: &str, + revoked_at: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + validate_identifier("actor_account_id", actor_account_id)?; + validate_non_empty("revoked_at", revoked_at)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = tx + .query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_binding, + ) + .optional()? + .ok_or_else(|| Error::RuntimeBindingNotFound { + runtime_id: runtime_id.to_string(), + })?; + if existing.revoked_at.is_some() { + tx.commit()?; + return Ok((WorkspaceRuntimeBindingMutation::Unchanged, existing)); + } + if expected_revision != existing.binding_revision { + return Err(Error::RuntimeBindingRevisionConflict { + expected: Some(expected_revision), + actual: Some(existing.binding_revision), + }); + } + let next_revision = existing + .binding_revision + .checked_add(1) + .ok_or_else(|| Error::Store("Runtime binding revision overflow".to_string()))?; + tx.execute( + r#"UPDATE workspace_runtime_bindings + SET revoked_at = ?3, updated_at = ?3, binding_revision = ?4 + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id, revoked_at, next_revision], + )?; + insert_workspace_runtime_binding_audit( + &tx, + workspace_id, + runtime_id, + actor_account_id, + "revoked", + Some(&existing.public_key_fingerprint), + None, + next_revision, + revoked_at, + )?; + let updated = tx.query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_binding, + )?; + tx.commit()?; + Ok((WorkspaceRuntimeBindingMutation::Revoked, updated)) + }) + } + + pub fn list_workspace_runtime_binding_audit( + &self, + workspace_id: &str, + runtime_id: &str, + limit: usize, + ) -> Result> { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + let limit = limit.clamp(1, 50) as i64; + self.with_conn(|conn| { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, runtime_id, actor_account_id, action, + old_fingerprint, new_fingerprint, binding_revision, at + FROM workspace_runtime_binding_audit + WHERE workspace_id = ?1 AND runtime_id = ?2 + ORDER BY binding_revision DESC + LIMIT ?3"#, + )?; + let rows = stmt.query_map(params![workspace_id, runtime_id, limit], |row| { + Ok(WorkspaceRuntimeBindingAuditRecord { + workspace_id: row.get(0)?, + runtime_id: row.get(1)?, + actor_account_id: row.get(2)?, + action: row.get(3)?, + old_fingerprint: row.get(4)?, + new_fingerprint: row.get(5)?, + binding_revision: row.get(6)?, + at: row.get(7)?, + }) + })?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } +} + +fn insert_workspace_runtime_binding_audit( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + runtime_id: &str, + actor_account_id: &str, + action: &str, + old_fingerprint: Option<&str>, + new_fingerprint: Option<&str>, + binding_revision: u64, + at: &str, +) -> Result<()> { + tx.execute( + r#"INSERT INTO workspace_runtime_binding_audit ( + workspace_id, runtime_id, actor_account_id, action, + old_fingerprint, new_fingerprint, binding_revision, at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#, + params![ + workspace_id, + runtime_id, + actor_account_id, + action, + old_fingerprint, + new_fingerprint, + binding_revision, + at, + ], + )?; + Ok(()) } #[async_trait] @@ -1887,6 +2228,52 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) } + async fn put_workspace_runtime_binding_key( + &self, + record: WorkspaceRuntimeBinding, + expected_revision: Option, + actor_account_id: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + SqliteWorkspaceStore::put_workspace_runtime_binding_key( + self, + record, + expected_revision, + actor_account_id, + ) + } + + async fn revoke_workspace_runtime_binding_key( + &self, + workspace_id: &str, + runtime_id: &str, + expected_revision: u64, + actor_account_id: &str, + revoked_at: &str, + ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { + SqliteWorkspaceStore::revoke_workspace_runtime_binding_key( + self, + workspace_id, + runtime_id, + expected_revision, + actor_account_id, + revoked_at, + ) + } + + async fn list_workspace_runtime_binding_audit( + &self, + workspace_id: &str, + runtime_id: &str, + limit: usize, + ) -> Result> { + SqliteWorkspaceStore::list_workspace_runtime_binding_audit( + self, + workspace_id, + runtime_id, + limit, + ) + } + async fn consume_worker_mutation_source_jti( &self, workspace_id: &str, @@ -5264,9 +5651,10 @@ fn read_workspace_runtime_binding( base_url: row.get(3)?, public_key: row.get(4)?, public_key_fingerprint: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - revoked_at: row.get(8)?, + binding_revision: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + revoked_at: row.get(9)?, }) } @@ -6018,234 +6406,42 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations ( Ok(()) } -fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<()> { - migrate_workspace_runtime_bindings_v50_to_v51_with_verifier( - conn, - verify_workspace_runtime_binding_schema, - ) -} - -fn migrate_workspace_runtime_bindings_v50_to_v51_with_verifier( - conn: &Connection, - verify: F, -) -> Result<()> -where - F: FnOnce(&Connection) -> Result<()>, -{ - let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; - let legacy_columns = table_columns(&tx, "trusted_runtime_records")? - .into_iter() - .collect::>(); - let expected_columns = [ - "runtime_id", - "display_name", - "base_url", - "public_key", - "created_at", - "updated_at", - "revoked_at", - "workspace_id", - ] - .into_iter() - .map(str::to_string) - .collect::>(); - if legacy_columns != expected_columns { +fn migrate_workspace_runtime_bindings_v51_to_v52(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != PREVIOUS_SCHEMA_VERSION { return Err(Error::Store(format!( - "schema-{PREVIOUS_SCHEMA_VERSION} trusted_runtime_records columns are not canonical" + "expected schema version {PREVIOUS_SCHEMA_VERSION} before {RUNTIME_BINDINGS_MIGRATION_NAME} migration, found {current}" ))); } - let mut bindings = Vec::new(); - { - let mut stmt = tx.prepare( - r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, - created_at, updated_at, revoked_at - FROM trusted_runtime_records ORDER BY runtime_id"#, - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, Option>(7)?, - )) - })?; - for row in rows { - let ( - runtime_id, - workspace_id, - display_name, - base_url, - public_key, - created_at, - updated_at, - revoked_at, - ) = row?; - let workspace_id = workspace_id.filter(|value| !value.trim().is_empty()).ok_or_else(|| { - Error::Store(format!( - "Runtime `{runtime_id}` has no persisted Workspace ownership; refusing to guess during schema-{PREVIOUS_SCHEMA_VERSION} migration" - )) - })?; - let workspace_exists = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", - params![workspace_id], - |row| row.get::<_, i64>(0), - )? != 0; - if !workspace_exists { - return Err(Error::Store(format!( - "Runtime `{runtime_id}` references unknown Workspace `{workspace_id}`" - ))); - } - let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?; - bindings.push(WorkspaceRuntimeBinding { - workspace_id, - runtime_id, - display_name, - base_url, - public_key, - public_key_fingerprint: fingerprint, - created_at, - updated_at, - revoked_at, - }); - } - } - - let mut binding_keys = HashSet::new(); - let mut trust_keys = HashSet::new(); - for binding in &bindings { - if !binding_keys.insert((binding.workspace_id.clone(), binding.runtime_id.clone())) { - return Err(Error::Store(format!( - "duplicate Runtime binding `{}/{}` in schema-{PREVIOUS_SCHEMA_VERSION}", - binding.workspace_id, binding.runtime_id - ))); - } - let fingerprint = binding.public_key_fingerprint.clone(); - if !trust_keys.insert((binding.workspace_id.clone(), fingerprint.clone())) { - return Err(Error::Store(format!( - "duplicate Runtime trust fingerprint `{fingerprint}` in Workspace `{}`", - binding.workspace_id - ))); - } - } - - let mut consumed_jtis = Vec::new(); - { - let runtime_workspaces = bindings - .iter() - .map(|binding| (binding.runtime_id.as_str(), binding.workspace_id.as_str())) - .collect::>(); - let mut stmt = tx.prepare( - "SELECT runtime_id, jti, expires_at, consumed_at FROM worker_mutation_source_proof_jtis", - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, String>(3)?, - )) - })?; - for row in rows { - let (runtime_id, jti, expires_at, consumed_at) = row?; - let workspace_id = runtime_workspaces.get(runtime_id.as_str()).ok_or_else(|| { - Error::Store(format!( - "consumed Worker mutation proof for Runtime `{runtime_id}` has no provable Workspace binding" - )) - })?; - consumed_jtis.push(( - (*workspace_id).to_string(), - runtime_id, - jti, - expires_at, - consumed_at, - )); - } - } - + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; tx.execute_batch( r#" - ALTER TABLE worker_mutation_source_proof_jtis - RENAME TO worker_mutation_source_proof_jtis_v50; - ALTER TABLE trusted_runtime_records - RENAME TO trusted_runtime_records_v50; - - CREATE TABLE workspace_runtime_bindings ( + ALTER TABLE workspace_runtime_bindings + ADD COLUMN binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0); + CREATE TABLE workspace_runtime_binding_audit ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - display_name TEXT NOT NULL, - base_url TEXT NOT NULL, - public_key TEXT NOT NULL, - public_key_fingerprint TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT, - PRIMARY KEY (workspace_id, runtime_id), - UNIQUE (workspace_id, public_key_fingerprint), - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT - ); - CREATE INDEX idx_workspace_runtime_bindings_workspace - ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); - CREATE TABLE worker_mutation_source_proof_jtis ( - workspace_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - jti TEXT NOT NULL, - expires_at INTEGER NOT NULL, - consumed_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, runtime_id, jti) + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_revision INTEGER NOT NULL CHECK (binding_revision > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_revision), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT ); + CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC); "#, )?; - for binding in bindings { - tx.execute( - r#"INSERT INTO workspace_runtime_bindings ( - workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, - params![ - binding.workspace_id, - binding.runtime_id, - binding.display_name, - binding.base_url, - binding.public_key, - binding.public_key_fingerprint, - binding.created_at, - binding.updated_at, - binding.revoked_at, - ], - )?; - } - for (workspace_id, runtime_id, jti, expires_at, consumed_at) in consumed_jtis { - tx.execute( - "INSERT INTO worker_mutation_source_proof_jtis ( - workspace_id, runtime_id, jti, expires_at, consumed_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![workspace_id, runtime_id, jti, expires_at, consumed_at], - )?; - } - tx.execute_batch( - "DROP TABLE worker_mutation_source_proof_jtis_v50; - DROP TABLE trusted_runtime_records_v50;", - )?; - let foreign_key_failures = - tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get::<_, i64>(0) - })?; - if foreign_key_failures != 0 { - return Err(Error::Store(format!( - "schema-{LATEST_SCHEMA_VERSION} migration produced {foreign_key_failures} foreign-key violation(s)" - ))); - } - verify(&tx)?; + verify_workspace_runtime_binding_schema(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", params![LATEST_SCHEMA_VERSION, RUNTIME_BINDINGS_MIGRATION_NAME], )?; - verify_current_schema_history(&tx)?; tx.commit()?; Ok(()) } @@ -6261,6 +6457,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { "base_url", "public_key", "public_key_fingerprint", + "binding_revision", "created_at", "updated_at", "revoked_at", @@ -6270,7 +6467,18 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { .collect::>(); if columns != expected { return Err(Error::Store( - "workspace_runtime_bindings schema does not match schema-51".to_string(), + "workspace_runtime_bindings schema does not match schema-52".to_string(), + )); + } + let revision_default = conn.query_row( + "SELECT dflt_value FROM pragma_table_info('workspace_runtime_bindings') WHERE name = 'binding_revision'", + [], + |row| row.get::<_, Option>(0), + )?; + if revision_default.as_deref() != Some("1") { + return Err(Error::Store( + "workspace_runtime_bindings binding_revision default does not match schema-52" + .to_string(), )); } let sql = conn.query_row( @@ -6296,6 +6504,37 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { "workspace_runtime_bindings is missing its Workspace lookup index".to_string(), )); } + let audit_columns = table_columns(conn, "workspace_runtime_binding_audit")? + .into_iter() + .collect::>(); + let expected_audit_columns = [ + "workspace_id", + "runtime_id", + "actor_account_id", + "action", + "old_fingerprint", + "new_fingerprint", + "binding_revision", + "at", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + if audit_columns != expected_audit_columns { + return Err(Error::Store( + "workspace_runtime_binding_audit schema does not match schema-52".to_string(), + )); + } + let audit_index_exists = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_workspace_runtime_binding_audit_recent')", + [], + |row| row.get::<_, i64>(0), + )? != 0; + if !audit_index_exists { + return Err(Error::Store( + "workspace_runtime_binding_audit recent index is missing".to_string(), + )); + } let jti_sql = conn.query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'worker_mutation_source_proof_jtis'", [], @@ -6309,7 +6548,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { } let mut stmt = conn.prepare( r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, - public_key_fingerprint, created_at, updated_at, revoked_at + public_key_fingerprint, binding_revision, created_at, updated_at, revoked_at FROM workspace_runtime_bindings"#, )?; let rows = stmt.query_map([], read_workspace_runtime_binding)?; @@ -6842,7 +7081,7 @@ fn apply_migrations(conn: &Connection) -> Result<()> { } PREVIOUS_SCHEMA_VERSION => { verify_previous_schema_history(conn)?; - migrate_workspace_runtime_bindings_v50_to_v51(conn)?; + migrate_workspace_runtime_bindings_v51_to_v52(conn)?; verify_current_schema_history(conn)?; verify_workspace_runtime_binding_schema(conn) } @@ -6917,7 +7156,7 @@ mod tests { .unwrap(); } - fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) { + fn prepare_schema_v51(path: &Path) { let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); @@ -6925,28 +7164,12 @@ mod tests { create_latest_workspace_schema(&conn).unwrap(); conn.execute_batch( r#" - DROP TABLE worker_mutation_source_proof_jtis; - DROP TABLE workspace_runtime_bindings; - CREATE TABLE trusted_runtime_records ( - runtime_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - base_url TEXT NOT NULL, - public_key TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - revoked_at TEXT, - workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT - ); - CREATE TABLE worker_mutation_source_proof_jtis ( - runtime_id TEXT NOT NULL, - jti TEXT NOT NULL, - expires_at INTEGER NOT NULL, - consumed_at TEXT NOT NULL, - PRIMARY KEY (runtime_id, jti) - ); + DROP INDEX idx_workspace_runtime_binding_audit_recent; + DROP TABLE workspace_runtime_binding_audit; + ALTER TABLE workspace_runtime_bindings DROP COLUMN binding_revision; DELETE FROM __yoi_schema_migrations; INSERT INTO __yoi_schema_migrations(version, name) - VALUES (50, 'workspace schema baseline'); + VALUES (51, 'workspace schema baseline'); INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); INSERT INTO workspaces( @@ -6957,75 +7180,44 @@ mod tests { .unwrap(); let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let (_, fingerprint) = normalize_runtime_public_key(&identity.public_key).unwrap(); conn.execute( - r#"INSERT INTO trusted_runtime_records( - runtime_id, workspace_id, display_name, base_url, public_key, - created_at, updated_at, revoked_at - ) VALUES ('shared', ?1, 'Shared', 'https://runtime.test', ?2, '1', '1', NULL)"#, - params![workspace_id, identity.public_key], - ) - .unwrap(); - conn.execute( - r#"INSERT INTO worker_mutation_source_proof_jtis( - runtime_id, jti, expires_at, consumed_at - ) VALUES ('shared', 'jti-1', 10, '1')"#, - [], + r#"INSERT INTO workspace_runtime_bindings( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at + ) VALUES ('workspace-a', 'runtime-a', 'Runtime A', 'https://runtime.test', + ?1, ?2, '1', '1', NULL)"#, + params![identity.public_key, fingerprint], ) .unwrap(); } #[test] - fn schema_v50_runtime_trust_migrates_to_workspace_binding_atomically() { + fn schema_v51_runtime_binding_migrates_with_revision_and_empty_audit() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - prepare_schema_v50(&path, Some("workspace-a")); - Connection::open(&path) - .unwrap() - .execute( - "UPDATE trusted_runtime_records SET revoked_at = '2' WHERE runtime_id = 'shared'", - [], - ) - .unwrap(); + prepare_schema_v51(&path); let store = SqliteWorkspaceStore::open(&path).unwrap(); let binding = store - .get_workspace_runtime_binding("workspace-a", "shared") + .get_workspace_runtime_binding("workspace-a", "runtime-a") .unwrap() .unwrap(); - assert_eq!(binding.revoked_at.as_deref(), Some("2")); - assert!(!binding.public_key.is_empty()); - assert!(binding.public_key_fingerprint.starts_with("sha256:")); + assert_eq!(binding.binding_revision, 1); + assert!( + store + .list_workspace_runtime_binding_audit("workspace-a", "runtime-a", 50) + .unwrap() + .is_empty() + ); store .with_conn(|conn| { - let jti_workspace: String = conn.query_row( - "SELECT workspace_id FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'", - [], - |row| row.get(0), - )?; - assert_eq!(jti_workspace, "workspace-a"); - let workspace_foreign_keys: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_foreign_key_list('workspace_runtime_bindings') WHERE \"table\" = 'workspaces' AND \"from\" = 'workspace_id'", - [], - |row| row.get(0), - )?; - assert_eq!(workspace_foreign_keys, 1); - let unique_indexes: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_index_list('workspace_runtime_bindings') WHERE \"unique\" = 1", - [], - |row| row.get(0), - )?; - assert!(unique_indexes >= 2); - let lookup_index: i64 = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_workspace_runtime_bindings_workspace'", - [], - |row| row.get(0), - )?; - assert_eq!(lookup_index, 1); - let violations: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_foreign_key_check", - [], - |row| row.get(0), - )?; + let version = current_schema_version(conn)?; + assert_eq!(version, LATEST_SCHEMA_VERSION); + let violations: i64 = + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; assert_eq!(violations, 0); Ok(()) }) @@ -7033,82 +7225,29 @@ mod tests { } #[test] - fn schema_v50_runtime_without_workspace_fails_without_partial_mutation() { + fn schema_v51_runtime_binding_migration_rolls_back_all_changes_on_failure() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - prepare_schema_v50(&path, None); - - let error = match SqliteWorkspaceStore::open(&path) { - Ok(_) => panic!("missing Workspace ownership must fail migration"), - Err(error) => error, - }; - assert!(error.to_string().contains("refusing to guess"), "{error}"); + prepare_schema_v51(&path); let conn = Connection::open(&path).unwrap(); - let version: i64 = conn - .query_row( - "SELECT MAX(version) FROM __yoi_schema_migrations", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(version, PREVIOUS_SCHEMA_VERSION); - assert!( - !table_columns(&conn, "trusted_runtime_records") - .unwrap() - .is_empty() - ); - assert!( - table_columns(&conn, "workspace_runtime_bindings") - .unwrap() - .is_empty() - ); - } + conn.execute_batch( + "CREATE TABLE workspace_runtime_binding_audit (unexpected TEXT NOT NULL);", + ) + .unwrap(); + drop(conn); - #[test] - fn schema_v50_runtime_migration_rolls_back_when_final_verification_fails() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("server.db"); - prepare_schema_v50(&path, Some("workspace-a")); + let error = SqliteWorkspaceStore::open(&path) + .err() + .expect("migration must fail") + .to_string(); + assert!(error.contains("already exists"), "{error}"); let conn = Connection::open(&path).unwrap(); - configure_sqlite(&conn).unwrap(); - - let error = migrate_workspace_runtime_bindings_v50_to_v51_with_verifier(&conn, |_| { - Err(Error::Store( - "forced final verification failure".to_string(), - )) - }) - .unwrap_err(); assert!( - error - .to_string() - .contains("forced final verification failure") - ); - let version: i64 = conn - .query_row( - "SELECT MAX(version) FROM __yoi_schema_migrations", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(version, PREVIOUS_SCHEMA_VERSION); - assert!( - !table_columns(&conn, "trusted_runtime_records") + !table_columns(&conn, "workspace_runtime_bindings") .unwrap() - .is_empty() + .contains(&"binding_revision".to_string()) ); - assert!( - table_columns(&conn, "workspace_runtime_bindings") - .unwrap() - .is_empty() - ); - let jti: String = conn - .query_row( - "SELECT jti FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(jti, "jti-1"); + assert_eq!(current_schema_version(&conn).unwrap(), 51); } #[test] @@ -7139,6 +7278,7 @@ mod tests { base_url: format!("https://{workspace_id}.runtime.test"), public_key: identity.public_key.clone(), public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -7233,6 +7373,112 @@ mod tests { ); } + #[test] + fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + r#" + INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES ('owner', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at) + VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1'); + "#, + )?; + Ok(()) + }) + .unwrap(); + let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("first").unwrap(); + let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("second").unwrap(); + let binding = |public_key: String, at: &str| WorkspaceRuntimeBinding { + workspace_id: "workspace-a".to_string(), + runtime_id: "runtime-a".to_string(), + display_name: "Runtime A".to_string(), + base_url: "https://runtime.test".to_string(), + public_key, + public_key_fingerprint: String::new(), + binding_revision: 1, + created_at: at.to_string(), + updated_at: at.to_string(), + revoked_at: None, + }; + + let (created, created_binding) = store + .put_workspace_runtime_binding_key( + binding(first.public_key.clone(), "1"), + None, + "owner", + ) + .unwrap(); + assert_eq!(created, WorkspaceRuntimeBindingMutation::Created); + assert_eq!(created_binding.binding_revision, 1); + let (replayed, replayed_binding) = store + .put_workspace_runtime_binding_key(binding(first.public_key, "2"), None, "owner") + .unwrap(); + assert_eq!(replayed, WorkspaceRuntimeBindingMutation::Unchanged); + assert_eq!(replayed_binding.binding_revision, 1); + + let stale = store + .put_workspace_runtime_binding_key( + binding(second.public_key.clone(), "3"), + Some(0), + "owner", + ) + .unwrap_err(); + assert!(matches!( + stale, + Error::RuntimeBindingRevisionConflict { + expected: Some(0), + actual: Some(1) + } + )); + let (replaced, replaced_binding) = store + .put_workspace_runtime_binding_key( + binding(second.public_key.clone(), "3"), + Some(1), + "owner", + ) + .unwrap(); + assert_eq!(replaced, WorkspaceRuntimeBindingMutation::Replaced); + assert_eq!(replaced_binding.binding_revision, 2); + let (revoked, revoked_binding) = store + .revoke_workspace_runtime_binding_key("workspace-a", "runtime-a", 2, "owner", "4") + .unwrap(); + assert_eq!(revoked, WorkspaceRuntimeBindingMutation::Revoked); + assert_eq!(revoked_binding.binding_revision, 3); + assert_eq!(revoked_binding.revoked_at.as_deref(), Some("4")); + let (reactivated, reactivated_binding) = store + .put_workspace_runtime_binding_key( + binding(second.public_key.clone(), "5"), + Some(3), + "owner", + ) + .unwrap(); + assert_eq!(reactivated, WorkspaceRuntimeBindingMutation::Reactivated); + assert_eq!(reactivated_binding.binding_revision, 4); + + let mut duplicate = binding(second.public_key, "6"); + duplicate.runtime_id = "runtime-b".to_string(); + let duplicate_error = store + .put_workspace_runtime_binding_key(duplicate, None, "owner") + .unwrap_err(); + assert!(matches!( + duplicate_error, + Error::RuntimeBindingFingerprintConflict { .. } + )); + + let audit = store + .list_workspace_runtime_binding_audit("workspace-a", "runtime-a", 50) + .unwrap(); + assert_eq!(audit.len(), 4); + assert_eq!(audit[0].action, "reactivated"); + assert_eq!(audit[0].binding_revision, 4); + assert_eq!(audit[1].action, "revoked"); + assert_eq!(audit[2].action, "replaced"); + assert_eq!(audit[3].action, "created"); + } + #[test] fn embedded_runtime_binding_can_explicitly_rotate_restart_identity() { let store = SqliteWorkspaceStore::in_memory().unwrap(); @@ -7260,6 +7506,7 @@ mod tests { base_url: "in-process://embedded".to_string(), public_key, public_key_fingerprint: String::new(), + binding_revision: 1, created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -7289,7 +7536,7 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (49, 'legacy')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (50, 'legacy')", [], ) .unwrap(); @@ -8289,13 +8536,13 @@ INSERT INTO worker_registry ( let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (52, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (53, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 52 is newer"), "{error}"); + assert!(error.contains("schema version 53 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } diff --git a/resources/flows/coder-review.dcdl b/resources/flows/coder-review.dcdl index b42dbd76..4de10077 100644 --- a/resources/flows/coder-review.dcdl +++ b/resources/flows/coder-review.dcdl @@ -15,7 +15,7 @@ }; review = { - instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition."; + instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition."; transitions = { approved = { target = "complete"; diff --git a/resources/prompts/internal/sub_worker_spawn_tool_description.md b/resources/prompts/internal/sub_worker_spawn_tool_description.md index a652f551..d6d50df1 100644 --- a/resources/prompts/internal/sub_worker_spawn_tool_description.md +++ b/resources/prompts/internal/sub_worker_spawn_tool_description.md @@ -1,8 +1,8 @@ Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits. -Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children. +Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be a Workdir-relative existing directory covered by the child's readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children. -Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope. +Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is the child's only filesystem capability and replaces profile scope. `command` is a separate explicit grant, defaults to false, and is accepted only with a writable scope; writable scope alone does not grant command execution. Default profile: {{ default_profile }} Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope. diff --git a/web/workspace/deno.json b/web/workspace/deno.json index a4678f20..4fda14c1 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 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@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.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/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.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 src/lib/workspace/sidebar/override-stack.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-launch.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.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/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.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/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 5d96d4ea..09be8bf9 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -47,6 +47,7 @@ export type WorkspaceAuthConfig = { export type WorkspacePermissionSummary = { manage_repositories: boolean; manage_secrets: boolean; + manage_runtimes: boolean; }; export type DiagnosticSeverity = "info" | "warning" | "error"; @@ -221,6 +222,108 @@ export type RepositoryLogResponse = { diagnostics: Array; }; +export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http"; + +export type RuntimeSourceStatus = "active" | "reserved"; + +export type RuntimeIdentityAuthority = + | "runtime_registry_projection" + | "server_runtime_configuration"; + +export type RuntimeSourceSummary = { + kind: RuntimeSourceKind; + status: RuntimeSourceStatus; + identity_authority: RuntimeIdentityAuthority; + note: string; +}; + +export type RuntimeSummary = { + runtime_id: string; + label: string; + kind: string; + status: string; + source: RuntimeSourceSummary; + host_ids: Array; + worker_creation_available: boolean; + os: string; + arch: string; + diagnostics: Array; +}; + +export type RuntimeManagementSummary = { + built_in: boolean; + config_managed: boolean; + removable: boolean; + endpoint_configured: boolean; + token_ref_configured: boolean; +}; + +export type WorkspaceRuntimeResource = { + management: RuntimeManagementSummary; + runtime_id: string; + label: string; + kind: string; + status: string; + source: RuntimeSourceSummary; + host_ids: Array; + worker_creation_available: boolean; + os: string; + arch: string; + diagnostics: Array; +}; + +export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked"; + +export type RuntimeTrustKeyState = { + status: RuntimeTrustKeyStatus; + fingerprint?: string | null; + revision?: number | null; + created_at?: string | null; + updated_at?: string | null; + revoked_at?: string | null; +}; + +export type RuntimeTrustAuditAction = + | "created" + | "replaced" + | "reactivated" + | "revoked"; + +export type RuntimeTrustAuditEntry = { + action: RuntimeTrustAuditAction; + actor_account_id: string; + old_fingerprint?: string | null; + new_fingerprint?: string | null; + revision: number; + at: string; +}; + +export type WorkspaceRuntimeDetail = { + workspace_id: string; + runtime: WorkspaceRuntimeResource; + endpoint?: string | null; + trust_key: RuntimeTrustKeyState; + recent_audit: Array; +}; + +export type RuntimeTrustKeyRevealResponse = { public_key: string }; + +export type PutRuntimeTrustKeyRequest = { + public_key: string; + expected_revision: number | null; +}; + +export type RevokeRuntimeTrustKeyRequest = { expected_revision: number }; + +export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; + +export type RuntimeTrustConflictResponse = { + error: RuntimeTrustConflictKind; + message: string; + current_revision?: number; + current_fingerprint?: string | null; +}; + export type RuntimeConnectionTestStatus = "compatible" | "failed"; export type RuntimeConnectionTestFailureKind = diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts new file mode 100644 index 00000000..ac15d012 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -0,0 +1,816 @@ +import type { + Diagnostic, + PutRuntimeTrustKeyRequest, + RevokeRuntimeTrustKeyRequest, + RuntimeIdentityAuthority, + RuntimeManagementSummary, + RuntimeSourceKind, + RuntimeSourceStatus, + RuntimeSourceSummary, + RuntimeTrustAuditAction, + RuntimeTrustAuditEntry, + RuntimeTrustConflictKind, + RuntimeTrustConflictResponse, + RuntimeTrustKeyRevealResponse, + RuntimeTrustKeyState, + RuntimeTrustKeyStatus, + WorkspaceRuntimeDetail, + WorkspaceRuntimeResource, +} from "$lib/generated/workspace-api.ts"; +import type { ListResponse } from "$lib/workspace/sidebar/types"; +import { workspaceApiPath } from "./http.ts"; + +export type WorkspaceRuntimeList = ListResponse; + +const LIMITS = { + runtimeItems: 200, + auditEntries: 20, + hostIds: 128, + diagnostics: 64, + idBytes: 256, + labelBytes: 512, + kindBytes: 128, + statusBytes: 128, + noteBytes: 2_048, + endpointBytes: 4_096, + publicKeyBytes: 16 * 1_024, + fingerprintBytes: 512, + timestampBytes: 128, + diagnosticCodeBytes: 128, + diagnosticMessageBytes: 2_048, + conflictMessageBytes: 1_024, + responseBytes: 512 * 1_024, +} as const; + +const SOURCE_KINDS = new Set([ + "embedded_worker_runtime", + "remote_http", +]); +const SOURCE_STATUSES = new Set(["active", "reserved"]); +const IDENTITY_AUTHORITIES = new Set([ + "runtime_registry_projection", + "server_runtime_configuration", +]); +const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]); +const TRUST_STATUSES = new Set([ + "unconfigured", + "active", + "revoked", +]); +const AUDIT_ACTIONS = new Set([ + "created", + "replaced", + "reactivated", + "revoked", +]); +const CONFLICT_KINDS = new Set([ + "stale_revision", + "fingerprint_in_use", +]); + +const encoder = new TextEncoder(); +type JsonObject = Record; + +export class RuntimeManagementValidationError extends Error { + constructor(message: string) { + super(message.slice(0, 256)); + this.name = "RuntimeManagementValidationError"; + } +} + +export class RuntimeTrustConflictError extends Error { + readonly conflict: RuntimeTrustConflictResponse; + + constructor(conflict: RuntimeTrustConflictResponse) { + super(conflict.message); + this.name = "RuntimeTrustConflictError"; + this.conflict = conflict; + } +} + +export class RuntimeTrustRequestError extends Error { + readonly field: "public_key" | null; + + constructor(message: string, field: "public_key" | null = null) { + super(message.slice(0, 256)); + this.name = "RuntimeTrustRequestError"; + this.field = field; + } +} + +export type RuntimeTrustRouteOperation = Readonly<{ + runtimeId: string; + generation: number; +}>; + +export class RuntimeTrustRouteFence { + #runtimeId: string | null = null; + #generation = 0; + + enter(runtimeId: string): number { + if (this.#runtimeId !== runtimeId) { + this.#runtimeId = runtimeId; + this.#generation += 1; + } + return this.#generation; + } + + capture(runtimeId: string): RuntimeTrustRouteOperation { + return { runtimeId, generation: this.enter(runtimeId) }; + } + + isCurrent(operation: RuntimeTrustRouteOperation, runtimeId: string): boolean { + return operation.runtimeId === runtimeId && + operation.generation === this.#generation && + this.#runtimeId === runtimeId; + } +} + +function fail(path: string, message: string): never { + throw new RuntimeManagementValidationError(`${path} ${message}`); +} + +function object(value: unknown, path: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return fail(path, "must be an object"); + } + return value as JsonObject; +} + +function exactKeys( + value: JsonObject, + required: readonly string[], + optional: readonly string[], + path: string, +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + fail(`${path}.${key}`, "is not part of the wire contract"); + } + } + for (const key of required) { + if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required"); + } +} + +function array(value: unknown, path: string, max: number): unknown[] { + if (!Array.isArray(value)) return fail(path, "must be an array"); + if (value.length > max) { + return fail(path, `must contain at most ${max} items`); + } + return value; +} + +function boundedString( + value: unknown, + path: string, + maxBytes: number, + allowEmpty = false, +): string { + if (typeof value !== "string") return fail(path, "must be a string"); + if (!allowEmpty && value.length === 0) return fail(path, "must not be empty"); + if (encoder.encode(value).byteLength > maxBytes) { + return fail(path, `must be at most ${maxBytes} UTF-8 bytes`); + } + return value; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") return fail(path, "must be a boolean"); + return value; +} + +function safeInteger(value: unknown, path: string, minimum = 0): number { + if ( + typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum + ) { + return fail(path, `must be a safe integer of at least ${minimum}`); + } + return value; +} + +function safeRevision(value: unknown, path: string): number { + return safeInteger(value, path, 1); +} + +function optionalNullableString( + value: unknown, + path: string, + maxBytes: number, + allowEmpty = false, +): string | null | undefined { + if (value === undefined || value === null) return value; + return boundedString(value, path, maxBytes, allowEmpty); +} + +function optionalRevision( + value: unknown, + path: string, +): number | undefined { + if (value === undefined || value === null) return undefined; + return safeRevision(value, path); +} + +function optionalNullableRevision( + value: unknown, + path: string, +): number | null | undefined { + if (value === undefined || value === null) return value; + return safeRevision(value, path); +} + +function timestamp(value: unknown, path: string): string { + const result = boundedString(value, path, LIMITS.timestampBytes); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/ + .test(result) + ) { + return fail(path, "must be an RFC 3339 timestamp"); + } + return result; +} + +function optionalNullableTimestamp( + value: unknown, + path: string, +): string | null | undefined { + if (value === undefined || value === null) return value; + return timestamp(value, path); +} + +function enumValue( + value: unknown, + path: string, + variants: ReadonlySet, +): T { + const result = boundedString(value, path, LIMITS.kindBytes); + if (!variants.has(result as T)) { + return fail(path, "contains an unknown enum value"); + } + return result as T; +} + +function diagnostic(value: unknown, path: string): Diagnostic { + const item = object(value, path); + exactKeys(item, ["code", "severity", "message"], [], path); + const severity = enumValue( + item.severity, + `${path}.severity`, + DIAGNOSTIC_SEVERITIES, + ) as Diagnostic["severity"]; + return { + code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes), + severity, + message: boundedString( + item.message, + `${path}.message`, + LIMITS.diagnosticMessageBytes, + true, + ), + }; +} + +function runtimeSource(value: unknown, path: string): RuntimeSourceSummary { + const item = object(value, path); + exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path); + return { + kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS), + status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES), + identity_authority: enumValue( + item.identity_authority, + `${path}.identity_authority`, + IDENTITY_AUTHORITIES, + ), + note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true), + }; +} + +function runtimeManagement( + value: unknown, + path: string, +): RuntimeManagementSummary { + const item = object(value, path); + exactKeys( + item, + [ + "built_in", + "config_managed", + "removable", + "endpoint_configured", + "token_ref_configured", + ], + [], + path, + ); + return { + built_in: boolean(item.built_in, `${path}.built_in`), + config_managed: boolean(item.config_managed, `${path}.config_managed`), + removable: boolean(item.removable, `${path}.removable`), + endpoint_configured: boolean( + item.endpoint_configured, + `${path}.endpoint_configured`, + ), + token_ref_configured: boolean( + item.token_ref_configured, + `${path}.token_ref_configured`, + ), + }; +} + +function runtimeResource( + value: unknown, + path: string, +): WorkspaceRuntimeResource { + const item = object(value, path); + exactKeys( + item, + [ + "management", + "runtime_id", + "label", + "kind", + "status", + "source", + "host_ids", + "worker_creation_available", + "os", + "arch", + "diagnostics", + ], + [], + path, + ); + const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map( + (entry, index) => + boundedString( + entry, + `${path}.host_ids[${index}]`, + LIMITS.idBytes, + ), + ); + if (new Set(hostIds).size !== hostIds.length) { + fail(`${path}.host_ids`, "must not contain duplicate IDs"); + } + return { + management: runtimeManagement(item.management, `${path}.management`), + runtime_id: boundedString( + item.runtime_id, + `${path}.runtime_id`, + LIMITS.idBytes, + ), + label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes), + kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes), + status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes), + source: runtimeSource(item.source, `${path}.source`), + host_ids: hostIds, + worker_creation_available: boolean( + item.worker_creation_available, + `${path}.worker_creation_available`, + ), + os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true), + arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true), + diagnostics: array( + item.diagnostics, + `${path}.diagnostics`, + LIMITS.diagnostics, + ).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)), + }; +} + +function trustKey(value: unknown, path: string): RuntimeTrustKeyState { + const item = object(value, path); + exactKeys( + item, + ["status"], + ["fingerprint", "revision", "created_at", "updated_at", "revoked_at"], + path, + ); + const result: RuntimeTrustKeyState = { + status: enumValue(item.status, `${path}.status`, TRUST_STATUSES), + fingerprint: optionalNullableString( + item.fingerprint, + `${path}.fingerprint`, + LIMITS.fingerprintBytes, + ), + revision: optionalNullableRevision(item.revision, `${path}.revision`), + created_at: optionalNullableTimestamp( + item.created_at, + `${path}.created_at`, + ), + updated_at: optionalNullableTimestamp( + item.updated_at, + `${path}.updated_at`, + ), + revoked_at: optionalNullableTimestamp( + item.revoked_at, + `${path}.revoked_at`, + ), + }; + + const hasBinding = result.status !== "unconfigured"; + if ( + hasBinding && + (result.fingerprint == null || result.revision == null || + result.created_at == null || result.updated_at == null) + ) { + fail( + path, + "must include fingerprint, revision, created_at, and updated_at", + ); + } + if ( + !hasBinding && + Object.entries(result).some(([key, entry]) => + key !== "status" && entry != null + ) + ) { + fail(path, "must not include binding values while unconfigured"); + } + if (result.status === "revoked" && result.revoked_at == null) { + fail(`${path}.revoked_at`, "is required for a revoked key"); + } + if (result.status === "active" && result.revoked_at != null) { + fail(`${path}.revoked_at`, "must be absent for an active key"); + } + return result; +} + +function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry { + const item = object(value, path); + exactKeys( + item, + ["action", "actor_account_id", "revision", "at"], + ["old_fingerprint", "new_fingerprint"], + path, + ); + return { + action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS), + actor_account_id: boundedString( + item.actor_account_id, + `${path}.actor_account_id`, + LIMITS.idBytes, + ), + old_fingerprint: optionalNullableString( + item.old_fingerprint, + `${path}.old_fingerprint`, + LIMITS.fingerprintBytes, + ), + new_fingerprint: optionalNullableString( + item.new_fingerprint, + `${path}.new_fingerprint`, + LIMITS.fingerprintBytes, + ), + revision: safeRevision(item.revision, `${path}.revision`), + at: timestamp(item.at, `${path}.at`), + }; +} + +export function parseWorkspaceRuntimeList( + value: unknown, +): WorkspaceRuntimeList { + const response = object(value, "Runtime list response"); + exactKeys( + response, + ["workspace_id", "limit", "items", "source", "diagnostics"], + [], + "Runtime list response", + ); + const limit = safeInteger(response.limit, "Runtime list response.limit", 0); + if (limit > LIMITS.runtimeItems) { + fail( + "Runtime list response.limit", + `must not exceed ${LIMITS.runtimeItems}`, + ); + } + const items = array( + response.items, + "Runtime list response.items", + LIMITS.runtimeItems, + ).map((entry, index) => + runtimeResource(entry, `Runtime list response.items[${index}]`) + ); + if (items.length > limit) { + fail("Runtime list response.items", "must not exceed the declared limit"); + } + return { + workspace_id: boundedString( + response.workspace_id, + "Runtime list response.workspace_id", + LIMITS.idBytes, + ), + limit, + items, + source: boundedString( + response.source, + "Runtime list response.source", + LIMITS.kindBytes, + ), + diagnostics: array( + response.diagnostics, + "Runtime list response.diagnostics", + LIMITS.diagnostics, + ).map((entry, index) => + diagnostic(entry, `Runtime list response.diagnostics[${index}]`) + ), + }; +} + +export function parseWorkspaceRuntimeDetail( + value: unknown, +): WorkspaceRuntimeDetail { + const response = object(value, "Runtime detail response"); + exactKeys( + response, + ["workspace_id", "runtime", "trust_key", "recent_audit"], + ["endpoint"], + "Runtime detail response", + ); + return { + workspace_id: boundedString( + response.workspace_id, + "Runtime detail response.workspace_id", + LIMITS.idBytes, + ), + runtime: runtimeResource( + response.runtime, + "Runtime detail response.runtime", + ), + endpoint: optionalNullableString( + response.endpoint, + "Runtime detail response.endpoint", + LIMITS.endpointBytes, + ), + trust_key: trustKey( + response.trust_key, + "Runtime detail response.trust_key", + ), + recent_audit: array( + response.recent_audit, + "Runtime detail response.recent_audit", + LIMITS.auditEntries, + ).map((entry, index) => + auditEntry(entry, `Runtime detail response.recent_audit[${index}]`) + ), + }; +} + +export function parseRuntimeTrustKeyRevealResponse( + value: unknown, +): RuntimeTrustKeyRevealResponse { + const response = object(value, "Runtime trust key reveal response"); + exactKeys( + response, + ["public_key"], + [], + "Runtime trust key reveal response", + ); + return { + public_key: boundedString( + response.public_key, + "Runtime trust key reveal response.public_key", + LIMITS.publicKeyBytes, + ), + }; +} + +export function parseRuntimeTrustConflict( + value: unknown, +): RuntimeTrustConflictResponse { + const response = object(value, "Runtime trust conflict"); + exactKeys( + response, + ["error", "message"], + ["current_revision", "current_fingerprint"], + "Runtime trust conflict", + ); + return { + error: enumValue( + response.error, + "Runtime trust conflict.error", + CONFLICT_KINDS, + ), + message: boundedString( + response.message, + "Runtime trust conflict.message", + LIMITS.conflictMessageBytes, + ), + current_revision: optionalRevision( + response.current_revision, + "Runtime trust conflict.current_revision", + ), + current_fingerprint: optionalNullableString( + response.current_fingerprint, + "Runtime trust conflict.current_fingerprint", + LIMITS.fingerprintBytes, + ), + }; +} + +function revisionForJson(revision: number | null): number | null { + if (revision === null) return null; + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new RuntimeTrustRequestError( + "Runtime trust revision is not a safe integer", + ); + } + return revision; +} + +async function readBoundedJson(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const parsed = Number(contentLength); + if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) { + throw new RuntimeTrustRequestError( + "Runtime trust response exceeds its byte limit", + ); + } + } + const text = await response.text(); + if (encoder.encode(text).byteLength > LIMITS.responseBytes) { + throw new RuntimeTrustRequestError( + "Runtime trust response exceeds its byte limit", + ); + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new RuntimeTrustRequestError( + "Runtime trust response is not valid JSON", + ); + } +} + +function requestErrorFrom( + value: unknown, + status: number, +): RuntimeTrustRequestError { + try { + const response = object(value, "Runtime trust error"); + exactKeys( + response, + ["error", "message", "diagnostics"], + [], + "Runtime trust error", + ); + const diagnostics = array( + response.diagnostics, + "Runtime trust error.diagnostics", + LIMITS.diagnostics, + ).map((entry, index) => + diagnostic(entry, `Runtime trust error.diagnostics[${index}]`) + ); + const message = boundedString( + response.message, + "Runtime trust error.message", + LIMITS.conflictMessageBytes, + ); + const field = diagnostics.some((entry) => + entry.code.startsWith("runtime_public_key_") + ) + ? "public_key" + : null; + return new RuntimeTrustRequestError(message, field); + } catch { + return new RuntimeTrustRequestError( + `Runtime trust request failed (${status})`, + ); + } +} + +async function finishMutation( + response: Response, + workspaceId: string, + runtimeId: string, +): Promise { + const payload = await readBoundedJson(response); + if (response.status === 409) { + try { + throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload)); + } catch (error) { + if (error instanceof RuntimeTrustConflictError) throw error; + throw new RuntimeTrustRequestError( + "Runtime trust conflict response was invalid", + ); + } + } + if (!response.ok) throw requestErrorFrom(payload, response.status); + let detail: WorkspaceRuntimeDetail; + try { + detail = parseWorkspaceRuntimeDetail(payload); + } catch { + throw new RuntimeTrustRequestError("Runtime trust response was invalid"); + } + if ( + detail.workspace_id !== workspaceId || + detail.runtime.runtime_id !== runtimeId + ) { + throw new RuntimeTrustRequestError( + "Runtime trust response did not match the selected Runtime", + ); + } + return detail; +} + +export async function revealRuntimeTrustKey( + workspaceId: string, + runtimeId: string, +): Promise { + const response = await fetch( + workspaceApiPath( + workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/trust-key`, + ), + ); + const payload = await readBoundedJson(response); + if (!response.ok) throw requestErrorFrom(payload, response.status); + return parseRuntimeTrustKeyRevealResponse(payload); +} + +export async function previewRuntimePublicKeyFingerprint( + publicKey: string, +): Promise { + const normalized = publicKey.trim(); + const prefix = "yoi-ed25519-pub:v1:"; + if (!normalized.startsWith(prefix)) { + throw new RuntimeTrustRequestError( + `Public key must start with ${prefix}`, + ); + } + const encoded = normalized.slice(prefix.length); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { + throw new RuntimeTrustRequestError("Public key encoding is invalid"); + } + const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") + + "=".repeat((4 - (encoded.length % 4)) % 4); + let decoded: string; + try { + decoded = atob(padded); + } catch { + throw new RuntimeTrustRequestError("Public key encoding is invalid"); + } + if (decoded.length !== 32) { + throw new RuntimeTrustRequestError("Public key must contain 32 bytes"); + } + const bytes = Uint8Array.from( + decoded, + (character) => character.charCodeAt(0), + ); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return `sha256:${hex}`; +} + +export async function putRuntimeTrustKey( + workspaceId: string, + runtimeId: string, + request: PutRuntimeTrustKeyRequest, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl( + workspaceApiPath( + workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/trust-key`, + ), + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + public_key: request.public_key, + expected_revision: revisionForJson(request.expected_revision), + }), + }, + ); + return await finishMutation(response, workspaceId, runtimeId); +} + +export async function revokeRuntimeTrustKey( + workspaceId: string, + runtimeId: string, + request: RevokeRuntimeTrustKeyRequest, + currentFingerprint: string, + confirmation: string, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!currentFingerprint || confirmation.trim() !== currentFingerprint) { + throw new RuntimeTrustRequestError( + "Enter the current fingerprint exactly before revoking Workspace trust.", + ); + } + const response = await fetchImpl( + workspaceApiPath( + workspaceId, + `/runtimes/${encodeURIComponent(runtimeId)}/trust-key`, + ), + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + expected_revision: revisionForJson(request.expected_revision), + }), + }, + ); + return await finishMutation(response, workspaceId, runtimeId); +} diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index b6701aa0..2088a18f 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -367,13 +367,18 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig { function permissions(value: unknown, path: string): WorkspacePermissionSummary { const item = object(value, path); - exactKeys(item, ["manage_repositories", "manage_secrets"], path); + exactKeys( + item, + ["manage_repositories", "manage_secrets", "manage_runtimes"], + path, + ); return { manage_repositories: boolean( item.manage_repositories, `${path}.manage_repositories`, ), manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`), + manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`), }; } diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index abd53396..8b45f90c 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -620,7 +620,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority" consolePage.includes("preserveExactText: value.textPastes.length > 0") && consolePage.includes("composerDrafts.set(activeComposerTargetKey") && consolePage.includes("switchComposerTarget(target)") && - consolePage.includes('sendControl({ method: "cancel" }, "Stop")'), + consolePage.includes('sendWorkerControl("cancel")'), "Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority", ); }); @@ -787,7 +787,10 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac consolePage.includes( 'const composerEditable = $derived(protocolState === "open" && !sending);', ) && - consolePage.includes('sendControl({ method: "cancel" }, "Stop")') && + consolePage.includes('sendWorkerControl("cancel")') && + consolePage.includes("lifecycleMethod(command)") && + consolePage.includes("expected_worker_state_revision") && + consolePage.includes("expected_execution_generation") && consolePage.includes("onsubmit={handleComposerSubmit}") && consolePage.includes("disabled={!composerEditable}") && consolePage.includes("class:stop={workerRunning}") && diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index ded7886b..03b4c3ad 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -342,6 +342,224 @@ .settings-test-result.failed { border-inline-start: 3px solid var(--danger); } + + .runtime-detail-page { + display: grid; + gap: var(--space-5); + } + + .runtime-detail-section { + display: grid; + gap: var(--space-3); + padding-top: var(--space-4); + border-top: 1px solid var(--line); + } + + .runtime-detail-section h2, + .runtime-detail-section p { + margin: 0; + } + + .runtime-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); + gap: var(--space-3) var(--space-5); + margin: 0; + } + + .runtime-detail-grid div { + min-width: 0; + } + + .runtime-detail-grid dt { + margin-bottom: var(--space-1); + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .runtime-detail-grid dd { + margin: 0; + color: var(--text-strong); + overflow-wrap: anywhere; + } + + .runtime-public-key-actions, + .runtime-revoke-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + } + + .runtime-public-key-actions { + justify-content: flex-start; + } + + .runtime-public-key-actions button, + .runtime-revoke-row button, + .runtime-trust-form button { + border: 0; + border-radius: 0.6rem; + padding: 0.5rem 0.75rem; + background: var(--accent); + color: var(--bg); + font-weight: 700; + cursor: pointer; + } + + .runtime-public-key-actions button.secondary { + border: 1px solid var(--line); + background: transparent; + color: var(--text-strong); + } + + .runtime-public-key-actions button:disabled, + .runtime-revoke-row button:disabled, + .runtime-trust-form button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .runtime-public-key, + .runtime-trust-form textarea, + .runtime-trust-form input, + .runtime-revoke-row input { + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg-raised); + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.78rem; + } + + .runtime-public-key { + max-height: 14rem; + margin: 0; + padding: var(--space-3); + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .runtime-trust-form { + display: grid; + gap: var(--space-2); + max-width: 56rem; + } + + .runtime-trust-form label, + .runtime-revoke-row label { + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; + } + + .runtime-trust-form textarea, + .runtime-trust-form input, + .runtime-revoke-row input { + width: 100%; + padding: 0.65rem 0.75rem; + } + + .runtime-trust-form textarea { + resize: vertical; + } + + .runtime-trust-form small, + .runtime-revoke-row small { + color: var(--text-muted); + } + + .runtime-trust-comparison { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin: 0; + padding: var(--space-3) 0; + border-block: 1px solid var(--line); + } + + .runtime-trust-comparison div { + min-width: 0; + } + + .runtime-trust-comparison dt { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + } + + .runtime-trust-comparison dd { + margin: var(--space-1) 0 0; + overflow-wrap: anywhere; + } + + .runtime-trust-form .field-error, + .runtime-detail-page .section-state.error { + color: var(--danger); + } + + .runtime-detail-page .section-state.success { + color: var(--success); + } + + .runtime-revoke-row { + padding-top: var(--space-3); + border-top: 1px solid var(--line); + } + + .runtime-revoke-row div { + display: grid; + gap: var(--space-1); + } + + .runtime-revoke-row p { + color: var(--text-muted); + } + + .runtime-revoke-row button.danger { + background: var(--danger); + } + + .runtime-audit-table-wrap { + overflow-x: auto; + } + + .runtime-audit-table { + width: 100%; + min-width: 48rem; + border-collapse: collapse; + } + + .runtime-audit-table th, + .runtime-audit-table td { + padding: 0.7rem 0.5rem; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + } + + .runtime-audit-table th { + color: var(--text-muted); + font-size: 0.72rem; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .runtime-audit-table code { + overflow-wrap: anywhere; + } + + @media (max-width: 760px) { + .runtime-revoke-row { + align-items: stretch; + } + } + .settings-page { display: grid; gap: var(--space-5); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte index 4e9380b0..35c87b2c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -1,9 +1,11 @@ + + + {data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace + + + +
+
+
+ Runtimes +

{data.runtimeDetail?.runtime.label ?? data.runtimeId}

+

{data.runtimeId}

+
+ + Workdirs + +
+ + {#if data.runtimeDetailError} +

{data.runtimeDetailError}

+ {:else if !data.runtimeDetail} +

Loading Runtime…

+ {:else} + {@const detail = data.runtimeDetail} + {@const runtime = detail.runtime} + {@const trust = detail.trust_key} + {@const currentAction = trustAction(trust.status)} + +
+

Identity and binding

+
+
Runtime ID
{runtime.runtime_id}
+
Kind
{runtime.kind}
+
Endpoint
{detail.endpoint ?? 'Not configured'}
+
Status
{runtime.status}
+
Binding status
{trust.status}
+
Fingerprint
{trust.fingerprint ?? '—'}
+
Revision
{trust.revision?.toString() ?? '—'}
+
Created
{formatTimestamp(trust.created_at)}
+
Updated
{formatTimestamp(trust.updated_at)}
+
Revoked
{formatTimestamp(trust.revoked_at)}
+
+ {#if runtime.diagnostics.length > 0} +
    + {#each runtime.diagnostics as diagnostic} +
  • + {diagnostic.code} + {diagnostic.message} +
  • + {/each} +
+ {/if} +
+ + {#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in} +
+

Workspace trust

+ + {#if trust.status !== 'unconfigured'} +
+ + +
+ {#if showPublicKey && revealedPublicKey} +
{revealedPublicKey}
+ {/if} + {/if} + +
+ + + +
+
+
Current fingerprint
+
{trust.fingerprint ?? 'Not configured'}
+
+
+
Replacement fingerprint
+
{replacementFingerprint ?? 'Enter a valid public key'}
+
+
+ {#if replacementFingerprintError} +

{replacementFingerprintError}

+ {/if} + + {#if currentAction !== 'create'} + + + Enter {trust.fingerprint ?? 'the current fingerprint'} exactly. + {/if} + + {#if fieldError} +

{fieldError}

+ {/if} +
+ +
+
+ +
+
+ Revoke Workspace trust +

Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.

+ +
+ +
+ + {#if requestError} + + {/if} + {#if successMessage} +

{successMessage}

+ {/if} +
+ {/if} + +
+

Recent trust audit

+ {#if detail.recent_audit.length === 0} +

No trust changes are recorded.

+ {:else} +
+ + + + + + {#each detail.recent_audit as entry} + + + + + + + + {/each} + +
ActionRevisionFingerprintActorTime
{entry.action}{entry.revision.toString()}{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}{entry.actor_account_id}{formatTimestamp(entry.at)}
+
+ {/if} +
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts new file mode 100644 index 00000000..100e82f5 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts @@ -0,0 +1,31 @@ +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { parseWorkspaceRuntimeDetail } from "$lib/workspace/api/runtime-management"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + const detail = await loadJson( + fetch, + workspaceApiPath( + params.workspaceId, + `/runtimes/${encodeURIComponent(params.runtimeId)}`, + ), + undefined, + (value) => { + const response = parseWorkspaceRuntimeDetail(value); + if ( + response.workspace_id !== params.workspaceId || + response.runtime.runtime_id !== params.runtimeId + ) { + throw new Error("Runtime detail did not match the route"); + } + return response; + }, + ); + + return { + workspaceId: params.workspaceId, + runtimeId: params.runtimeId, + runtimeDetail: detail.data, + runtimeDetailError: detail.error, + }; +}; diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts new file mode 100644 index 00000000..1aaee2c1 --- /dev/null +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -0,0 +1,157 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; + readTextFile(path: URL): Promise; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("Runtime Settings routes validate unknown JSON through the shared Runtime parser", async () => { + const [listLoader, detailLoader] = await Promise.all([ + Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/+page.ts", + import.meta.url, + ), + ), + Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts", + import.meta.url, + ), + ), + ]); + + assert( + listLoader.includes("parseWorkspaceRuntimeList(value)"), + "Runtime list loader should validate unknown JSON", + ); + assert( + detailLoader.includes("parseWorkspaceRuntimeDetail(value)"), + "Runtime detail loader should validate unknown JSON", + ); + for (const source of [listLoader, detailLoader]) { + assert( + !source.includes("loadJson<"), + "Runtime loaders must not cast response JSON to a handwritten DTO", + ); + } +}); + +Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => { + const page = await Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte", + import.meta.url, + ), + ); + + assert( + page.includes( + "/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}", + ), + "Runtime name should link to canonical detail", + ); + assert( + page.includes("testRuntime(runtime)"), + "connection Test should remain available", + ); + assert(page.includes("Add Runtime"), "Add Runtime should remain available"); + assert( + page.includes("data.workspace.permissions.manage_runtimes"), + "Add Runtime should be hidden from non-owners", + ); + assert( + !page.includes("deleteRuntime"), + "inline Runtime delete logic must be removed", + ); + assert( + !page.includes(">Delete"), + "inline Runtime delete control must be removed", + ); +}); + +Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", async () => { + const page = await Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte", + import.meta.url, + ), + ); + + const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes"); + const reveal = page.indexOf("Reveal public key"); + const mutation = page.indexOf('id="runtime-public-key-input"'); + assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes"); + assert( + page.includes("Current fingerprint"), + "current fingerprint must be explicit", + ); + assert( + page.includes("Replacement fingerprint"), + "replacement fingerprint must be previewed before confirmation", + ); + assert( + page.includes("!runtime.management.built_in"), + "Runtime trust controls should be hidden for the built-in Runtime", + ); + assert( + ownerGate < reveal && ownerGate < mutation, + "owner gate should wrap key controls", + ); + + for ( + const token of [ + "Create Workspace trust", + "Replace trusted key", + "Reactivate with this key", + "Confirm current fingerprint", + "Revoke Workspace trust", + "Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", + "RuntimeTrustConflictError", + "RuntimeTrustRouteFence", + "routeFence.enter(data.runtimeId)", + "showPublicKey = false", + "revealedPublicKey = null", + "publicKey = ''", + "fingerprintConfirmation = ''", + "revokeFingerprintConfirmation = ''", + "requestError = null", + "successMessage = null", + "isCurrentRoute(operation)", + "revealRuntimeTrustKey", + "revokeFingerprintConfirmation.trim() !== trust.fingerprint", + "await reloadAuthority()", + "busyAction !== null", + "Workdirs", + "Recent trust audit", + ] + ) { + assert(page.includes(token), `Runtime detail should include ${token}`); + } +}); + +Deno.test("Runtime detail uses flat sections instead of nested cards", async () => { + const [page, css] = await Promise.all([ + Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte", + import.meta.url, + ), + ), + Deno.readTextFile( + new URL("../src/lib/workspace/styles/settings.css", import.meta.url), + ), + ]); + + assert( + !page.includes('class="card"') && !page.includes("settings-card"), + "Runtime detail should not add card nesting", + ); + assert( + css.includes(".runtime-detail-section") && + css.includes("border-top: 1px solid var(--line)"), + "Runtime detail hierarchy should use flat section separators", + ); +}); diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts new file mode 100644 index 00000000..3d5c83b6 --- /dev/null +++ b/web/workspace/tests/runtime-management.test.ts @@ -0,0 +1,287 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +import { + parseRuntimeTrustConflict, + parseRuntimeTrustKeyRevealResponse, + parseWorkspaceRuntimeDetail, + parseWorkspaceRuntimeList, + previewRuntimePublicKeyFingerprint, + putRuntimeTrustKey, + revokeRuntimeTrustKey, + RuntimeTrustConflictError, + RuntimeTrustRouteFence, +} from "../src/lib/workspace/api/runtime-management.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertThrows(operation: () => unknown, expected: string): void { + try { + operation(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(expected)) return; + throw new Error( + `expected error containing ${expected}, received ${message}`, + ); + } + throw new Error("expected operation to throw"); +} + +function runtime() { + return { + management: { + built_in: false, + config_managed: true, + removable: false, + endpoint_configured: true, + token_ref_configured: false, + }, + runtime_id: "arcadia", + label: "Arcadia", + kind: "remote", + status: "started", + source: { + kind: "remote_http", + status: "active", + identity_authority: "server_runtime_configuration", + note: "Configured by Server authority", + }, + host_ids: ["host-a"], + worker_creation_available: true, + os: "linux", + arch: "x86_64", + diagnostics: [], + }; +} + +function detail() { + return { + workspace_id: "workspace-a", + runtime: runtime(), + endpoint: "https://runtime.example.test", + trust_key: { + status: "active", + fingerprint: "SHA256:current", + revision: 3, + created_at: "2026-09-01T12:00:00Z", + updated_at: "2026-09-01T13:00:00Z", + revoked_at: null, + }, + recent_audit: [{ + action: "created", + actor_account_id: "account-a", + old_fingerprint: null, + new_fingerprint: "SHA256:current", + revision: 3, + at: "2026-09-01T13:00:00Z", + }], + }; +} + +Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", () => { + const list = parseWorkspaceRuntimeList({ + workspace_id: "workspace-a", + limit: 200, + items: [runtime()], + source: "workspace-control-plane", + diagnostics: [], + }); + assert( + list.items[0]?.runtime_id === "arcadia", + "Runtime ID was not preserved", + ); + + const parsed = parseWorkspaceRuntimeDetail(detail()); + assert( + parsed.trust_key.revision === 3, + "revision was not preserved as a safe integer", + ); + assert( + parsed.recent_audit[0]?.revision === 3, + "audit revision was not normalized", + ); +}); + +Deno.test("Runtime validators reject unknown object keys and enum variants", () => { + assertThrows( + () => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }), + "head_tree is not part", + ); + + const futureSource = structuredClone(detail()); + futureSource.runtime.source.kind = "future_transport"; + assertThrows( + () => parseWorkspaceRuntimeDetail(futureSource), + "contains an unknown enum value", + ); + + assertThrows( + () => + parseRuntimeTrustConflict({ + error: "future_conflict", + message: "conflict", + current_revision: 4, + current_fingerprint: "SHA256:new", + }), + "contains an unknown enum value", + ); +}); + +Deno.test("Runtime validators reject unsafe revisions and bounded collection overflow", () => { + const unsafeRevision = structuredClone(detail()); + unsafeRevision.trust_key.revision = Number.MAX_SAFE_INTEGER + 1; + assertThrows( + () => parseWorkspaceRuntimeDetail(unsafeRevision), + "must be a safe integer", + ); + + const tooMuchAudit = structuredClone(detail()); + tooMuchAudit.recent_audit = Array.from( + { length: 21 }, + () => structuredClone(detail().recent_audit[0]), + ); + assertThrows( + () => parseWorkspaceRuntimeDetail(tooMuchAudit), + "must contain at most 20 items", + ); + + const tooManyItems = Array.from({ length: 201 }, () => runtime()); + assertThrows( + () => + parseWorkspaceRuntimeList({ + workspace_id: "workspace-a", + limit: 200, + items: tooManyItems, + source: "workspace-control-plane", + diagnostics: [], + }), + "must contain at most 200 items", + ); +}); + +Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => { + assertThrows( + () => + parseRuntimeTrustKeyRevealResponse({ + public_key: "x".repeat(16 * 1024 + 1), + }), + "must be at most 16384 UTF-8 bytes", + ); + + const activeWithoutFingerprint = structuredClone(detail()) as Record< + string, + unknown + >; + (activeWithoutFingerprint.trust_key as Record).fingerprint = + null; + assertThrows( + () => parseWorkspaceRuntimeDetail(activeWithoutFingerprint), + "must include fingerprint", + ); +}); + +Deno.test("mismatched revoke fingerprint never sends a request", async () => { + let requests = 0; + const fetchImpl: typeof fetch = () => { + requests += 1; + return Promise.reject(new Error("request must not be sent")); + }; + let rejected = false; + try { + await revokeRuntimeTrustKey( + "workspace-a", + "runtime-a", + { expected_revision: 3 }, + "sha256:current", + "sha256:different", + fetchImpl, + ); + } catch (error) { + rejected = error instanceof Error && + error.message.includes("current fingerprint exactly"); + } + assert(rejected, "mismatched fingerprint should be rejected locally"); + assert(requests === 0, "mismatched fingerprint sent a revoke request"); +}); + +Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => { + const fence = new RuntimeTrustRouteFence(); + fence.enter("runtime-a"); + const operation = fence.capture("runtime-a"); + let renderedKey: string | null = null; + let resolveReveal!: (key: string) => void; + const delayedReveal = new Promise((resolve) => { + resolveReveal = resolve; + }).then((key) => { + if (fence.isCurrent(operation, "runtime-b")) renderedKey = key; + }); + + fence.enter("runtime-b"); + resolveReveal("runtime-a-public-key"); + await delayedReveal; + assert( + renderedKey === null, + "Runtime A key rendered after navigating to Runtime B", + ); +}); + +Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => { + const fingerprint = await previewRuntimePublicKeyFingerprint( + "yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + assert( + fingerprint === + "sha256:66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925", + "fingerprint preview drifted from the Server SHA-256 contract", + ); +}); + +Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => { + let sentBody: unknown = null; + const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => { + sentBody = JSON.parse(String(init?.body)) as unknown; + return Promise.resolve( + new Response( + JSON.stringify({ + error: "stale_revision", + message: "Runtime trust changed", + current_revision: 4, + current_fingerprint: "SHA256:new", + }), + { status: 409, headers: { "content-type": "application/json" } }, + ), + ); + }) as typeof fetch; + + try { + await putRuntimeTrustKey( + "workspace-a", + "arcadia", + { public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 }, + fetchImpl, + ); + throw new Error("expected mutation to reject"); + } catch (error) { + assert( + error instanceof RuntimeTrustConflictError, + "expected typed conflict", + ); + assert( + error.conflict.current_revision === 4, + "authoritative revision was lost", + ); + } + + assert( + JSON.stringify(sentBody) === + JSON.stringify({ + public_key: "ssh-ed25519 AAAA-new", + expected_revision: 3, + }), + "request should serialize the generated bigint revision as a safe JSON integer", + ); +});