From 68f00bc948af0d09c114b30324f269594a182926 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 02:17:44 +0900 Subject: [PATCH 01/11] refactor: broker SubWorker Workdir tools through parent --- crates/tools/src/bash.rs | 1 + crates/workdir/src/http.rs | 44 +- crates/workdir/src/lib.rs | 44 +- crates/workdir/src/local.rs | 92 +- crates/workdir/src/operation.rs | 4 + .../workdir/src/{delegation.rs => scope.rs} | 865 +++++++++++------- crates/workdir/src/workspace.rs | 10 - crates/worker-runtime/src/http_server.rs | 92 +- crates/worker/src/controller.rs | 24 +- .../src/feature/builtin/manage_workdir.rs | 221 +---- crates/worker/src/internal_worker.rs | 8 +- crates/worker/src/spawn/registry.rs | 19 +- crates/worker/src/spawn/tool.rs | 222 ++--- crates/worker/tests/controller_test.rs | 4 + crates/workspace-server/src/server.rs | 176 +--- resources/flows/coder-review.dcdl | 2 +- .../sub_worker_spawn_tool_description.md | 4 +- 17 files changed, 715 insertions(+), 1117 deletions(-) rename crates/workdir/src/{delegation.rs => scope.rs} (58%) 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/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/delegation.rs b/crates/workdir/src/scope.rs similarity index 58% rename from crates/workdir/src/delegation.rs rename to crates/workdir/src/scope.rs index 17598dab..7b024696 100644 --- a/crates/workdir/src/delegation.rs +++ b/crates/workdir/src/scope.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, Weak}; @@ -18,94 +18,153 @@ use crate::{ #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] -pub enum WorkdirDelegationPermission { +pub enum WorkdirToolScopePermission { Read, Write, } #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] -pub struct WorkdirDelegationRule { +pub struct WorkdirToolScopeRule { pub target: FsPath, - pub permission: WorkdirDelegationPermission, + pub permission: WorkdirToolScopePermission, pub recursive: bool, } #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] -pub struct WorkdirDelegationRequest { - pub rules: Vec, +pub struct WorkdirToolScope { + pub rules: Vec, pub cwd: FsPath, + pub command: bool, } -pub struct WorkdirDelegation { - pub scoped_session: WorkdirSessionHandle, +#[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), + owned_commands: Arc::new(Mutex::new(HashSet::new())), + command_events, + closes_source: true, + }); + 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, } -impl std::fmt::Debug for WorkdirDelegation { +impl std::fmt::Debug for WorkdirScopeLease { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WorkdirDelegation") - .field("workdir", &self.scoped_session.workdir()) + f.debug_struct("WorkdirScopeLease") + .field("workdir", self.broker.session.workdir()) .field("capabilities", &self.capabilities) .field("active", &self.is_active()) .finish() } } -impl WorkdirDelegation { +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 fn is_active(&self) -> bool { self.validity.is_active() } pub fn release(&self) { self.validity.active.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 Drop for WorkdirDelegation { +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.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, @@ -136,23 +195,25 @@ impl SessionValidity { #[derive(Clone, Debug)] struct ActiveWriteLease { validity: Weak, - rules: Vec, + rules: Vec, } -struct DelegatingWorkdirSession { +struct ScopedWorkdirSession { source: WorkdirSessionHandle, cwd: FsPath, - scope: Option>, + scope: Option>, capabilities: WorkdirSessionCapabilities, validity: Arc, child_write_leases: Mutex>, next_lease_id: AtomicU64, + owned_commands: Arc>>, + command_events: broadcast::Sender, closes_source: bool, } -impl std::fmt::Debug for DelegatingWorkdirSession { +impl std::fmt::Debug for ScopedWorkdirSession { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DelegatingWorkdirSession") + f.debug_struct("ScopedWorkdirSession") .field("workdir", &self.source.workdir()) .field("scope", &self.scope) .field("capabilities", &self.capabilities) @@ -161,22 +222,7 @@ impl std::fmt::Debug for DelegatingWorkdirSession { } } -/// 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 { +impl ScopedWorkdirSession { fn ensure_active(&self) -> Result<(), WorkdirError> { if self.validity.is_active() { Ok(()) @@ -195,7 +241,7 @@ impl DelegatingWorkdirSession { Ok(()) } else { Err(WorkdirError::Denied(format!( - "delegated workdir session does not permit {operation}" + "scoped Workdir tools do not permit {operation}" ))) } } @@ -203,7 +249,7 @@ impl DelegatingWorkdirSession { fn ensure_path( &self, path: &FsPath, - permission: WorkdirDelegationPermission, + permission: WorkdirToolScopePermission, ) -> Result<(), WorkdirError> { self.ensure_active()?; if let Some(scope) = &self.scope { @@ -212,11 +258,11 @@ impl DelegatingWorkdirSession { .any(|rule| rule_allows_path(rule, path, permission)) { return Err(WorkdirError::Denied(format!( - "logical workdir path `{path}` is outside the delegated {permission:?} scope" + "logical workdir path `{path}` is outside the scoped {permission:?} scope" ))); } } - if permission == WorkdirDelegationPermission::Write { + if permission == WorkdirToolScopePermission::Write { self.ensure_parent_write_available(path)?; } Ok(()) @@ -239,7 +285,7 @@ impl DelegatingWorkdirSession { capability: WorkdirSessionCapability, ) -> Result<(), WorkdirError> { self.ensure_capability(capability, "read operations")?; - self.ensure_path(path, WorkdirDelegationPermission::Read) + self.ensure_path(path, WorkdirToolScopePermission::Read) } fn ensure_write( @@ -248,58 +294,133 @@ impl DelegatingWorkdirSession { capability: WorkdirSessionCapability, ) -> Result<(), WorkdirError> { self.ensure_capability(capability, "write operations")?; - self.ensure_path(path, WorkdirDelegationPermission::Write) + 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 ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> { let mut leases = self .child_write_leases .lock() - .expect("workdir delegation lease mutex poisoned"); + .expect("Workdir tool scope 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) + rule.permission == WorkdirToolScopePermission::Write + && rule_allows_path(rule, path, WorkdirToolScopePermission::Write) }) }) { Err(WorkdirError::Denied(format!( - "logical workdir path `{path}` is leased to a child session" + "logical workdir path `{path}` is leased to child Workdir tools" ))) } else { Ok(()) } } - fn validate_delegation_rules( + 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: &[WorkdirDelegationRule], + 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 delegation requires at least one logical scope rule".into(), + "workdir tool scope requires at least one logical scope rule".into(), )); } let writable = rules .iter() - .any(|rule| rule.permission == WorkdirDelegationPermission::Write); + .any(|rule| rule.permission == WorkdirToolScopePermission::Write); if !self.capabilities.supports(WorkdirSessionCapability::Read) || (writable && (!self.capabilities.supports(WorkdirSessionCapability::Write) - || !self.capabilities.supports(WorkdirSessionCapability::Edit) - || !self - .capabilities - .supports(WorkdirSessionCapability::Command))) + || !self.capabilities.supports(WorkdirSessionCapability::Edit))) { return Err(WorkdirError::Denied( - "parent workdir session cannot delegate the requested capabilities".into(), + "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 @@ -307,7 +428,7 @@ impl DelegatingWorkdirSession { .any(|parent| rule_contains_rule(parent, requested)) { return Err(WorkdirError::Denied(format!( - "logical workdir scope `{}` exceeds the parent delegation", + "logical workdir scope `{}` exceeds the parent tool scope", requested.target ))); } @@ -325,69 +446,40 @@ impl DelegatingWorkdirSession { if writable { delegated.push(WorkdirSessionCapability::Write); delegated.push(WorkdirSessionCapability::Edit); + } + if command { 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)?; + 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, WorkdirDelegationPermission::Read)) + .any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read)) { return Err(WorkdirError::Denied(format!( - "delegated cwd `{}` is outside the delegated readable scope", + "scoped tool cwd `{}` is outside the readable scope", request.cwd ))); } - let source = self.source.capture_delegation_source(&request).await?; + self.ensure_scope_targets_do_not_traverse_symlinks(&request.rules) + .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) + .any(|rule| rule.permission == WorkdirToolScopePermission::Write) { self.child_write_leases .lock() - .expect("workdir delegation lease mutex poisoned") + .expect("Workdir tool scope lease mutex poisoned") .insert( id, ActiveWriteLease { @@ -396,99 +488,125 @@ impl WorkdirSession for DelegatingWorkdirSession { }, ); } - let child: WorkdirSessionHandle = Arc::new(DelegatingWorkdirSession { - source, + let owned_commands = 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(), + command_events.clone(), + ) + .map(|handle| Arc::new(Mutex::new(Some(handle)))); + 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), + owned_commands, + command_events, closes_source: false, }); - let scoped_session: WorkdirSessionHandle = - if capabilities == WorkdirSessionCapabilities::READ_ONLY { - Arc::new(ReadOnlyWorkdirSession::new(child)) - } else { - child - }; - Ok(WorkdirDelegation { - scoped_session, + let broker = WorkdirToolBroker { + session: child.clone(), + authority: child, + event_forwarder, + }; + Ok(WorkdirScopeLease { + broker, capabilities, validity, }) } +} + +#[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_path(&request.path)?; + let path = self.resolve_operation_path(&request.path).await?; self.ensure_read(&path, WorkdirSessionCapability::Read)?; - if !self.source.transports_delegation_context() { - request.path = path; - } + request.path = path; self.source.stat(request).await } async fn read(&self, mut request: ReadRequest) -> Result { - let path = self.resolve_path(&request.path)?; + let path = self.resolve_operation_path(&request.path).await?; self.ensure_read(&path, WorkdirSessionCapability::Read)?; - if !self.source.transports_delegation_context() { - request.path = path; - } + request.path = path; self.source.read(request).await } async fn write(&self, mut request: WriteRequest) -> Result { - let path = self.resolve_path(&request.path)?; + let path = self.resolve_operation_path(&request.path).await?; self.ensure_write(&path, WorkdirSessionCapability::Write)?; - if !self.source.transports_delegation_context() { - request.path = path; - } + request.path = path; self.source.write(request).await } async fn edit(&self, mut request: EditRequest) -> Result { - let path = self.resolve_path(&request.path)?; + let path = self.resolve_operation_path(&request.path).await?; self.ensure_write(&path, WorkdirSessionCapability::Edit)?; - if !self.source.transports_delegation_context() { - request.path = path; - } + request.path = path; self.source.edit(request).await } async fn list(&self, mut request: ListRequest) -> Result { - let path = self.resolve_path(&request.path)?; + let path = self.resolve_operation_path(&request.path).await?; self.ensure_read(&path, WorkdirSessionCapability::Read)?; - if !self.source.transports_delegation_context() { - request.path = path; - } + request.path = path; self.source.list(request).await } async fn glob(&self, mut request: GlobRequest) -> Result { - let path = self.resolve_path(&request.path)?; + let path = self.resolve_operation_path(&request.path).await?; self.ensure_read(&path, WorkdirSessionCapability::Glob)?; - if !self.source.transports_delegation_context() { - request.path = path; - } + request.path = path; self.source.glob(request).await } async fn grep(&self, mut request: GrepRequest) -> Result { - let path = self.resolve_path(&request.path)?; + let path = self.resolve_operation_path(&request.path).await?; self.ensure_read(&path, WorkdirSessionCapability::Grep)?; - if !self.source.transports_delegation_context() { - request.path = path; - } + request.path = path; self.source.grep(request).await } - async fn start_command(&self, request: CommandRequest) -> Result { + async fn start_command( + &self, + mut request: CommandRequest, + ) -> Result { self.ensure_command()?; - self.source.start_command(request).await + 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(), + }); + } + let handle = self.source.start_command(request).await?; + self.owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .insert(handle.0.clone()); + let _ = self.command_events.send(CommandEvent::Started { + command_id: handle.0.clone(), + tool_call_id, + observed_at_ms: unix_timestamp_ms(), + }); + Ok(handle) } async fn command_status(&self, handle: CommandHandle) -> Result { - self.ensure_command()?; + self.ensure_owned_command(&handle)?; self.source.command_status(handle).await } @@ -496,29 +614,56 @@ impl WorkdirSession for DelegatingWorkdirSession { &self, request: CommandOutputRequest, ) -> Result { - self.ensure_command()?; - self.source.command_output(request).await + 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.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_command()?; + self.ensure_owned_command(&handle)?; 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() + 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 - .ensure_capability(WorkdirSessionCapability::Command, "command observation") - .is_err() + if !self + .capabilities + .supports(WorkdirSessionCapability::Command) { return Vec::new(); } - self.source.command_snapshot() + 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> { @@ -531,7 +676,7 @@ impl WorkdirSession for DelegatingWorkdirSession { } } -/// A fail-closed read-only view over an already scoped delegated session. +/// A fail-closed read-only view over an already scoped scoped tool route. #[derive(Debug)] pub struct ReadOnlyWorkdirSession { inner: WorkdirSessionHandle, @@ -553,30 +698,6 @@ impl WorkdirSession for ReadOnlyWorkdirSession { 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 } @@ -629,20 +750,57 @@ impl WorkdirSession for ReadOnlyWorkdirSession { } } +fn forward_owned_command_events( + receiver: Option>, + owned_commands: 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 = match &event { + CommandEvent::Started { .. } => continue, + CommandEvent::Output { command_id, .. } + | CommandEvent::Terminal { command_id, .. } => command_id.clone(), + }; + let owned = owned_commands + .lock() + .expect("scoped command set mutex poisoned") + .contains(&command_id); + if owned { + 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 rule_allows_path( - rule: &WorkdirDelegationRule, + rule: &WorkdirToolScopeRule, path: &FsPath, - required: WorkdirDelegationPermission, + required: WorkdirToolScopePermission, ) -> bool { - if required == WorkdirDelegationPermission::Write - && rule.permission != WorkdirDelegationPermission::Write + if required == WorkdirToolScopePermission::Write + && rule.permission != WorkdirToolScopePermission::Write { return false; } path_in_rule(rule, path) } -fn path_in_rule(rule: &WorkdirDelegationRule, path: &FsPath) -> bool { +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 { @@ -655,9 +813,9 @@ fn path_in_rule(rule: &WorkdirDelegationRule, path: &FsPath) -> bool { rule.recursive || depth <= 1 } -fn rule_contains_rule(parent: &WorkdirDelegationRule, child: &WorkdirDelegationRule) -> bool { - if child.permission == WorkdirDelegationPermission::Write - && parent.permission != WorkdirDelegationPermission::Write +fn rule_contains_rule(parent: &WorkdirToolScopeRule, child: &WorkdirToolScopeRule) -> bool { + if child.permission == WorkdirToolScopePermission::Write + && parent.permission != WorkdirToolScopePermission::Write { return false; } @@ -684,7 +842,7 @@ mod tests { FsPath::new(path).unwrap() } - fn session(root: &Path) -> WorkdirSessionHandle { + fn session(root: &Path) -> WorkdirToolBroker { let scope = SharedScope::new( Scope::from_config(&ScopeConfig { allow: vec![ScopeRule { @@ -696,7 +854,7 @@ mod tests { }) .unwrap(), ); - delegation_capable_session(Arc::new(LocalWorkdirSession::materialized_bound( + WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::materialized_bound( Workdir::new("delegation-test"), root.to_path_buf(), root.to_path_buf(), @@ -705,14 +863,15 @@ mod tests { ))) } - fn request(path: &str, permission: WorkdirDelegationPermission) -> WorkdirDelegationRequest { - WorkdirDelegationRequest { - rules: vec![WorkdirDelegationRule { + 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, } } @@ -743,6 +902,7 @@ mod tests { command: command.into(), timeout_secs: 5, output_limit: 1024, + cwd: None, spill_dir: None, tool_call_id: Some(tool_call_id.into()), }) @@ -760,7 +920,7 @@ mod tests { } #[tokio::test] - async fn delegation_capable_session_forwards_command_telemetry() { + async fn workdir_tool_broker_session_forwards_command_telemetry() { let root = TempDir::new().unwrap(); let parent = session(root.path()); let mut events = parent @@ -771,6 +931,7 @@ mod tests { 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()), }) @@ -807,11 +968,109 @@ mod tests { 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 = WorkdirDelegationRule { + let rule = WorkdirToolScopeRule { target: fs_path("docs"), - permission: WorkdirDelegationPermission::Read, + permission: WorkdirToolScopePermission::Read, recursive: false, }; assert!(path_in_rule(&rule, &fs_path("docs"))); @@ -829,21 +1088,16 @@ mod tests { let parent = session(root.path()); let child = parent - .delegate(request("docs", WorkdirDelegationPermission::Read)) + .scope(request("docs", WorkdirToolScopePermission::Read)) .await .unwrap(); assert_eq!(child.capabilities, WorkdirSessionCapabilities::READ_ONLY); assert_eq!( - child - .scoped_session - .read(read("readme.md")) - .await - .unwrap() - .bytes, + child.read(read("readme.md")).await.unwrap().bytes, b"visible" ); assert!(matches!( - child.scoped_session.write(write("new.md", "no")).await, + child.write(write("new.md", "no")).await, Err(WorkdirError::Denied(_)) )); assert!( @@ -851,15 +1105,15 @@ mod tests { .capabilities .supports(WorkdirSessionCapability::Command) ); - assert!(child.scoped_session.subscribe_command_events().is_none()); - assert!(child.scoped_session.command_snapshot().is_empty()); + assert!(child.subscribe_command_events().is_none()); + assert!(child.command_snapshot().is_empty()); assert!(matches!( child - .scoped_session .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()), }) @@ -880,11 +1134,11 @@ mod tests { symlink("../secret/key", root.path().join("granted/link")).unwrap(); let parent = session(root.path()); let child = parent - .delegate(request("granted", WorkdirDelegationPermission::Read)) + .scope(request("granted", WorkdirToolScopePermission::Read)) .await .unwrap(); - let result = child.scoped_session.read(read("link")).await; + let result = child.read(read("link")).await; assert!( result.is_err(), "symlink read escaped provider scope: {result:?}" @@ -902,14 +1156,11 @@ mod tests { symlink("../secret", root.path().join("granted/outside")).unwrap(); let parent = session(root.path()); let child = parent - .delegate(request("granted", WorkdirDelegationPermission::Write)) + .scope(request("granted", WorkdirToolScopePermission::Write)) .await .unwrap(); - let result = child - .scoped_session - .write(write("outside/new", "forbidden")) - .await; + let result = child.write(write("outside/new", "forbidden")).await; assert!( result.is_err(), "symlink write escaped provider scope: {result:?}" @@ -930,9 +1181,9 @@ mod tests { assert!(matches!( parent - .delegate(request( + .scope(request( "granted/outside", - WorkdirDelegationPermission::Write + WorkdirToolScopePermission::Write )) .await, Err(WorkdirError::Denied(_)) @@ -950,7 +1201,7 @@ mod tests { fs::create_dir_all(root.path().join("other")).unwrap(); let parent = session(root.path()); let child = parent - .delegate(request("leased", WorkdirDelegationPermission::Write)) + .scope(request("leased", WorkdirToolScopePermission::Write)) .await .unwrap(); assert!( @@ -958,12 +1209,8 @@ mod tests { .capabilities .supports(WorkdirSessionCapability::Command) ); - let child_output = run_command( - &child.scoped_session, - "printf child-command", - "delegated-child-command", - ) - .await; + 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, @@ -983,19 +1230,15 @@ mod tests { Err(WorkdirError::Denied(_)) )); parent.write(write("other/file", "parent")).await.unwrap(); - child - .scoped_session - .write(write("file", "child")) - .await - .unwrap(); + child.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, + cwd: None, spill_dir: None, tool_call_id: Some("revoked-child-command".into()), }) @@ -1007,7 +1250,7 @@ mod tests { .await .unwrap(); assert!(matches!( - child.scoped_session.read(read("file")).await, + child.read(read("file")).await, Err(WorkdirError::SessionClosed) )); } @@ -1021,34 +1264,31 @@ mod tests { 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)) + .scope(request("docs", WorkdirToolScopePermission::Read)) .await .unwrap(); let nested = child - .scoped_session - .delegate(request("docs/sub", WorkdirDelegationPermission::Read)) + .scope(request("docs/sub", WorkdirToolScopePermission::Read)) .await .unwrap(); - nested.scoped_session.read(read("a")).await.unwrap(); + nested.read(read("a")).await.unwrap(); assert!( child - .scoped_session - .delegate(request("other", WorkdirDelegationPermission::Read)) + .scope(request("other", WorkdirToolScopePermission::Read)) .await .is_err() ); assert!( child - .scoped_session - .delegate(request("docs/sub", WorkdirDelegationPermission::Write)) + .scope(request("docs/sub", WorkdirToolScopePermission::Write)) .await .is_err() ); child.release(); assert!(matches!( - nested.scoped_session.read(read("a")).await, + nested.read(read("a")).await, Err(WorkdirError::SessionClosed) )); } @@ -1059,22 +1299,21 @@ mod tests { 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)) + .scope(request("docs", WorkdirToolScopePermission::Write)) .await .unwrap(); let nested = child - .scoped_session - .delegate(request("docs/sub", WorkdirDelegationPermission::Write)) + .scope(request("docs/sub", WorkdirToolScopePermission::Write)) .await .unwrap(); for (session, label) in [ - (&root_session, "root"), - (&child.scoped_session, "child"), - (&nested.scoped_session, "nested"), + (root_session.tool_session(), "root"), + (child.tool_session(), "child"), + (nested.tool_session(), "nested"), ] { let output = run_command( - session, + &session, format!("printf {label}"), format!("{label}-command-during-nested-write"), ) @@ -1088,83 +1327,23 @@ mod tests { Err(WorkdirError::Denied(_)) )); assert!(matches!( - child - .scoped_session - .write(write("sub/child", "blocked")) - .await, + child.write(write("sub/child", "blocked")).await, Err(WorkdirError::Denied(_)) )); - nested - .scoped_session - .write(write("nested", "allowed")) - .await - .unwrap(); + nested.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() { + 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 - .delegate(request("docs", WorkdirDelegationPermission::Read)) + .scope(request("docs", WorkdirToolScopePermission::Read)) .await .unwrap(); @@ -1175,15 +1354,17 @@ mod tests { 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) )); - assert!(matches!( - child.scoped_session.read(read("a")).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 2217e973..51e7b7a1 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -718,8 +718,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 { @@ -2080,8 +2079,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, }; #[test] @@ -2502,16 +2501,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"), @@ -2545,7 +2534,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"), }), @@ -2562,7 +2550,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(), @@ -2585,78 +2572,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 70584074..214b1d55 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -514,6 +514,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() { @@ -911,6 +912,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, @@ -919,21 +921,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(); @@ -1157,7 +1164,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(); @@ -1199,7 +1205,7 @@ where runtime_base.clone(), bash_output_dir.clone(), spawner_workspace_root, - source_workdir_session, + workdir_tool_broker, spawned_registry.clone(), spawner_manifest, prompts, diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index f00093eb..ab252835 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -12,7 +12,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, @@ -156,8 +156,6 @@ struct WorkspaceHttpWorkdirBackend { pub struct WorkspaceAttachedWorkdirSession { client: Arc, workdir: Workdir, - expected_session_fence: Option, - delegations: Vec, } impl WorkspaceAttachedWorkdirSession { @@ -165,8 +163,6 @@ impl WorkspaceAttachedWorkdirSession { Arc::new(Self { client, workdir: Workdir::new("workspace-attachment"), - expected_session_fence: None, - delegations: Vec::new(), }) } @@ -183,16 +179,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 +234,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), @@ -1155,6 +1095,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 +1119,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 +1162,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] diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index ec7ef586..52427800 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -709,7 +709,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 }) @@ -746,13 +746,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(); @@ -887,6 +890,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..a9d670b3 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -25,7 +25,7 @@ use session_store::{ }; use tokio::sync::broadcast; use tracing::warn; -use workdir::WorkdirDelegation; +use workdir::WorkdirScopeLease; use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility}; use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord}; @@ -68,7 +68,7 @@ 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, @@ -86,7 +86,7 @@ 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, change_tracker: Option, @@ -94,7 +94,7 @@ impl InternalSpawnedWorkerRecord { 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, @@ -690,7 +690,7 @@ impl SpawnedWorkerRegistry { if !record.claim_scope_reclaim() { return Ok(false); } - record.workdir_delegation.release(); + record.workdir_tool_scope.release(); let result = if let Some(parent_scope) = &self.parent_scope { parent_scope .update(|current| current.with_removed_deny_rules(delegated_write_rules(record))) @@ -966,7 +966,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 +976,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(); diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index e6b8f872..3091642e 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)] @@ -267,8 +269,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 @@ -295,7 +297,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, @@ -308,7 +310,7 @@ impl SubWorkerSpawnTool { runtime_base, bash_output_dir, workspace_root, - source_workdir_session, + workdir_tool_broker, registry, spawner_manifest, prompt_loader, @@ -341,6 +343,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(()) } @@ -370,7 +377,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 @@ -380,28 +387,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| { @@ -490,7 +484,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(), @@ -510,6 +503,7 @@ impl Tool for SubWorkerSpawnTool { self.runtime_base.clone(), child_registry.clone(), None, + Some(child_workdir_tool_broker.clone()), ) .await .map_err(|error| { @@ -552,6 +546,7 @@ impl Tool for SubWorkerSpawnTool { ); parent_notifications.notify(message, true); })), + Some(child_workdir_tool_broker.clone()), ) .await; let session = session_result.map_err(|error| { @@ -621,7 +616,7 @@ 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(), @@ -674,18 +669,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, }) @@ -693,22 +688,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(), ) }) @@ -946,7 +943,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>, @@ -958,7 +955,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, @@ -972,7 +969,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>, @@ -1004,7 +1001,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(), @@ -1037,12 +1034,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") )); } @@ -1079,6 +1076,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(); @@ -1173,7 +1171,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(), @@ -1189,7 +1187,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, @@ -1212,7 +1210,8 @@ enabled = false "target": ".", "permission": "write", "recursive": true - }] + }], + "command": true }); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); @@ -1247,15 +1246,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), @@ -1371,7 +1361,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()); @@ -1426,7 +1416,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)); @@ -1438,7 +1428,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(), @@ -1478,51 +1468,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" ); } @@ -1534,6 +1485,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) @@ -1663,7 +1615,6 @@ enabled = false #[derive(Debug, Default)] struct StrictRemoteWorkdirWorkspaceClient { requests: Mutex>, - foreign_scope_rejections: AtomicUsize, } impl StrictRemoteWorkdirWorkspaceClient { @@ -1695,59 +1646,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 143c1da9..42bba8f6 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -332,6 +332,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, }) @@ -376,6 +377,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()), }) @@ -484,6 +486,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()), }) @@ -560,6 +563,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-server/src/server.rs b/crates/workspace-server/src/server.rs index 3599f26d..aeee139e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -48,8 +48,7 @@ use workdir::http::{ }; use workdir::workspace::{ MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy, - WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionFence, - WorkspaceWorkdirSessionOperationRequest, + WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionOperationRequest, }; use workdir::{CommandHandle, WorkdirSessionHandle}; use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; @@ -355,7 +354,6 @@ static EMBEDDED_RUNTIME_REQUEST_IDENTITY: std::sync::LazyLock< struct WorkdirCommandSession { source: WorkdirSessionHandle, provider_handle: CommandHandle, - delegations: Vec, } enum RegisteredWorkdirSession { @@ -398,7 +396,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()); @@ -414,7 +411,6 @@ impl WorkdirSessionRegistry { WorkdirCommandSession { source, provider_handle, - delegations, }, ); external_handle @@ -2586,10 +2582,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), @@ -7341,46 +7333,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)] @@ -7435,23 +7392,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))?; @@ -7470,58 +7417,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) @@ -7536,14 +7457,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))? } @@ -7551,29 +7467,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() @@ -7585,15 +7484,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( @@ -16775,6 +16666,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()), }) @@ -16784,12 +16676,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( @@ -23503,30 +23391,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; 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. From e7079e223feac3e80a94c5d756a93b2f2824002b Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 02:37:13 +0900 Subject: [PATCH 02/11] fix: close scoped SubWorker command authority --- crates/workdir/src/scope.rs | 205 ++++++++++++++++++++++++++-- crates/worker/src/spawn/registry.rs | 7 +- 2 files changed, 196 insertions(+), 16 deletions(-) diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index 7b024696..cf6db8cc 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -106,6 +106,7 @@ pub struct WorkdirScopeLease { broker: WorkdirToolBroker, pub capabilities: WorkdirSessionCapabilities, validity: Arc, + cleanup_pending: Arc, } impl std::fmt::Debug for WorkdirScopeLease { @@ -134,12 +135,74 @@ impl WorkdirScopeLease { self.broker.scope(request).await } + pub async fn close(&self) -> Result<(), WorkdirError> { + 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(_)) + | (Ok(()), Err(WorkdirError::UnknownCommand(_))) + | (Err(WorkdirError::UnknownCommand(_)), Err(WorkdirError::UnknownCommand(_))) => { + 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() } - pub fn release(&self) { + /// 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() @@ -161,7 +224,7 @@ impl std::ops::Deref for WorkdirScopeLease { impl Drop for WorkdirScopeLease { fn drop(&mut self) { - self.release(); + self.finish_release(); } } @@ -195,6 +258,7 @@ impl SessionValidity { #[derive(Clone, Debug)] struct ActiveWriteLease { validity: Weak, + cleanup_pending: Weak, rules: Vec, } @@ -471,22 +535,52 @@ impl ScopedWorkdirSession { 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) { - self.child_write_leases + let mut leases = self + .child_write_leases .lock() - .expect("Workdir tool scope lease mutex poisoned") - .insert( - id, - ActiveWriteLease { - validity: Arc::downgrade(&validity), - rules: request.rules.clone(), - }, - ); + .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 (command_events, _) = broadcast::channel(64); @@ -517,6 +611,7 @@ impl ScopedWorkdirSession { broker, capabilities, validity, + cleanup_pending, }) } } @@ -787,6 +882,13 @@ fn unix_timestamp_ms() -> u64 { .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, @@ -1231,7 +1333,7 @@ mod tests { )); parent.write(write("other/file", "parent")).await.unwrap(); child.write(write("file", "child")).await.unwrap(); - child.release(); + child.close().await.unwrap(); assert!(matches!( child .start_command(CommandRequest { @@ -1255,6 +1357,79 @@ mod tests { )); } + #[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 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(); @@ -1286,7 +1461,7 @@ mod tests { .is_err() ); - child.release(); + child.close().await.unwrap(); assert!(matches!( nested.read(read("a")).await, Err(WorkdirError::SessionClosed) @@ -1332,8 +1507,8 @@ mod tests { )); nested.write(write("nested", "allowed")).await.unwrap(); - nested.release(); - child.release(); + nested.close().await.unwrap(); + child.close().await.unwrap(); } #[tokio::test] diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index a9d670b3..c270243f 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -690,7 +690,7 @@ impl SpawnedWorkerRegistry { if !record.claim_scope_reclaim() { return Ok(false); } - record.workdir_tool_scope.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))) @@ -731,6 +731,11 @@ impl SpawnedWorkerRegistry { .stop() .await .map_err(|error| io::Error::other(error.to_string()))?; + 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 = From 0f8d61188aad1842f14e5ebf94a3be9f48564225 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 03:12:55 +0900 Subject: [PATCH 03/11] fix: order SubWorker cleanup before Workdir release --- crates/workdir/src/scope.rs | 254 ++++++++++++++++-- crates/worker/src/controller.rs | 37 ++- .../src/feature/builtin/manage_workdir.rs | 124 ++++++++- crates/worker/src/spawn/registry.rs | 54 +++- crates/worker/src/spawn/tool.rs | 17 +- 5 files changed, 443 insertions(+), 43 deletions(-) diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index cf6db8cc..c48c1541 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -70,6 +70,9 @@ impl WorkdirToolBroker { child_write_leases: Mutex::new(HashMap::new()), next_lease_id: AtomicU64::new(1), 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_terminals: Arc::new(Mutex::new(HashSet::new())), command_events, closes_source: true, }); @@ -107,6 +110,7 @@ pub struct WorkdirScopeLease { pub capabilities: WorkdirSessionCapabilities, validity: Arc, cleanup_pending: Arc, + close_lock: Arc>, } impl std::fmt::Debug for WorkdirScopeLease { @@ -136,6 +140,10 @@ impl WorkdirScopeLease { } 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 @@ -167,9 +175,28 @@ impl WorkdirScopeLease { }) .await; match (cancel, terminal) { - (_, Ok(_)) - | (Ok(()), Err(WorkdirError::UnknownCommand(_))) + (_, 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, + ); + 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 @@ -271,6 +298,9 @@ struct ScopedWorkdirSession { child_write_leases: Mutex>, next_lease_id: AtomicU64, owned_commands: Arc>>, + pending_command_events: Arc>>>, + starting_tool_calls: Arc>>, + forwarded_terminals: Arc>>, command_events: broadcast::Sender, closes_source: bool, } @@ -380,12 +410,42 @@ impl ScopedWorkdirSession { } } + fn publish_terminal_if_missing( + &self, + command_id: &str, + status: CommandStatus, + exit_code: Option, + offset: u64, + ) { + publish_owned_command_event( + &self.command_events, + &self.forwarded_terminals, + 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(|v| v.is_active())); + 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 @@ -583,10 +643,16 @@ impl ScopedWorkdirSession { ); } 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_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_terminals.clone(), command_events.clone(), ) .map(|handle| Arc::new(Mutex::new(Some(handle)))); @@ -599,6 +665,9 @@ impl ScopedWorkdirSession { child_write_leases: Mutex::new(HashMap::new()), next_lease_id: AtomicU64::new(1), owned_commands, + pending_command_events, + starting_tool_calls, + forwarded_terminals, command_events, closes_source: false, }); @@ -612,6 +681,7 @@ impl ScopedWorkdirSession { capabilities, validity, cleanup_pending, + close_lock: Arc::new(tokio::sync::Mutex::new(())), }) } } @@ -687,16 +757,59 @@ impl WorkdirSession for ScopedWorkdirSession { None => self.cwd.clone(), }); } - let handle = self.source.start_command(request).await?; - self.owned_commands + 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") - .insert(handle.0.clone()); - let _ = self.command_events.send(CommandEvent::Started { - command_id: handle.0.clone(), - tool_call_id, - observed_at_ms: unix_timestamp_ms(), - }); + .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_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_terminals, event); + } Ok(handle) } @@ -713,6 +826,12 @@ impl WorkdirSession for ScopedWorkdirSession { 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, + ); self.owned_commands .lock() .expect("scoped command set mutex poisoned") @@ -848,6 +967,9 @@ impl WorkdirSession for ReadOnlyWorkdirSession { fn forward_owned_command_events( receiver: Option>, owned_commands: Arc>>, + pending_command_events: Arc>>>, + starting_tool_calls: Arc>>, + forwarded_terminals: Arc>>, sender: broadcast::Sender, ) -> Option> { let mut receiver = receiver?; @@ -858,22 +980,73 @@ fn forward_owned_command_events( Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(broadcast::error::RecvError::Closed) => break, }; - let command_id = match &event { - CommandEvent::Started { .. } => continue, - CommandEvent::Output { command_id, .. } - | CommandEvent::Terminal { command_id, .. } => command_id.clone(), - }; - let owned = owned_commands + let command_id = command_event_id(&event).to_string(); + let mut owned = owned_commands .lock() - .expect("scoped command set mutex poisoned") - .contains(&command_id); - if owned { - let _ = sender.send(event); + .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_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_terminals: &Mutex>, + event: CommandEvent, +) { + if let CommandEvent::Terminal { command_id, .. } = &event + && !forwarded_terminals + .lock() + .expect("forwarded terminal command mutex poisoned") + .insert(command_id.clone()) + { + return; + } + let _ = sender.send(event); +} + fn unix_timestamp_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1382,6 +1555,43 @@ mod tests { 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!(kinds.contains(&"output")); + assert!(streamed.contains("fast-output")); + child.close().await.unwrap(); + } + #[tokio::test] async fn closing_scope_cancels_and_terminalizes_owned_commands() { let root = TempDir::new().unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 214b1d55..a73a175a 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1101,8 +1101,15 @@ where "manage Workdir tools require Backend Workspace API authority", )); } + let child_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_before_workdir_release( + workspace_client, + Arc::new(move || { + let child_registry = child_registry.clone(); + Box::pin(async move { child_registry.shutdown_internal().await }) + }), + ), ); } if feature_config.workspace_worker_discovery.enabled { @@ -1726,7 +1733,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"); @@ -2604,4 +2620,21 @@ mod tests { other => panic!("expected compact rejection error, got {other:?}"), } } + + #[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 ab252835..24cbc0a9 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}; @@ -52,16 +54,43 @@ 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>; + 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, +} + +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, + } + } + + pub(crate) fn with_before_workdir_release( + client: Arc, + before_workdir_release: BeforeWorkdirRelease, + ) -> Self { + Self { + client, + before_workdir_release: Some(before_workdir_release), + } } } @@ -81,7 +110,8 @@ 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_before_workdir_release(self.before_workdir_release.clone()); for (name, definition) in [ ( LIST_TOOL, @@ -142,9 +172,20 @@ impl FeatureModule for ManageWorkdirFeature { } } -#[derive(Clone, Debug)] +#[derive(Clone)] struct WorkspaceHttpWorkdirBackend { client: Arc, + before_workdir_release: 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. @@ -327,7 +368,18 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { impl WorkspaceHttpWorkdirBackend { fn new(client: Arc) -> Self { - Self { client } + Self { + client, + before_workdir_release: None, + } + } + + fn with_before_workdir_release( + mut self, + before_workdir_release: Option, + ) -> Self { + self.before_workdir_release = before_workdir_release; + self } fn workspace_id(&self) -> Result<&str, ToolError> { @@ -510,6 +562,13 @@ impl Tool for WorkspaceHttpWorkdirTool { .attach(parse_input::(input_json)?), 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 @@ -705,6 +764,7 @@ struct WorkdirDeleteInput { #[cfg(test)] mod tests { use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; use crate::feature::{FeatureModule, FeatureRegistryBuilder}; @@ -1259,4 +1319,58 @@ 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_before_workdir_release(Some(before_release)), + 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_before_workdir_release(Some(before_release)), + 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()); + } } diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index c270243f..3db26267 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -679,13 +679,6 @@ 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); @@ -705,6 +698,35 @@ 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); + }; + 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 = self + .internal_records + .lock() + .expect("internal Worker registry lock poisoned") + .iter() + .map(|record| record.worker_name.clone()) + .collect::>(); + let mut first_error = None; + for name in names { + if let Err(error) = self.remove_internal(&name).await { + first_error.get_or_insert(error); + } + } + first_error.map_or(Ok(()), Err) + } + /// Stop one direct Internal SubWorker and discard its registry/scope state. /// /// The child actor must acknowledge its stop before the registry is removed. @@ -1236,6 +1258,24 @@ 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 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 3091642e..294983b0 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -532,13 +532,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!( From 7b1cf854f276ad766074d00db60cb5926e99df78 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 03:29:50 +0900 Subject: [PATCH 04/11] fix: serialize scoped command teardown --- crates/workdir/src/scope.rs | 201 ++++++++++++++++++++++++++++++++---- 1 file changed, 182 insertions(+), 19 deletions(-) diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index c48c1541..e730d9f0 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -10,9 +10,11 @@ use fs_operation::{ }; use tokio::sync::broadcast; +const MAX_SCOPED_COMMANDS: usize = 16; + use crate::{ CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, - CommandSnapshot, CommandStatus, Workdir, WorkdirError, WorkdirSession, + CommandSnapshot, CommandStatus, CommandStream, Workdir, WorkdirError, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, }; @@ -69,12 +71,15 @@ impl WorkdirToolBroker { 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_terminals: Arc::new(Mutex::new(HashSet::new())), command_events, closes_source: true, + #[cfg(test)] + command_start_gate: Mutex::new(None), }); Self { session: authority.clone(), @@ -181,6 +186,7 @@ impl WorkdirScopeLease { output.status, output.exit_code, output.next_cursor.unwrap_or(output.content.len()) as u64, + &output.content, ); self.broker .authority @@ -196,6 +202,7 @@ impl WorkdirScopeLease { CommandStatus::Cancelled, None, 0, + "", ); self.broker .authority @@ -289,6 +296,12 @@ struct ActiveWriteLease { rules: Vec, } +#[cfg(test)] +struct TestCommandStartGate { + entered: tokio::sync::Notify, + release: tokio::sync::Notify, +} + struct ScopedWorkdirSession { source: WorkdirSessionHandle, cwd: FsPath, @@ -297,12 +310,15 @@ struct ScopedWorkdirSession { 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_terminals: Arc>>, command_events: broadcast::Sender, closes_source: bool, + #[cfg(test)] + command_start_gate: Mutex>>, } impl std::fmt::Debug for ScopedWorkdirSession { @@ -416,19 +432,33 @@ impl ScopedWorkdirSession { status: CommandStatus, exit_code: Option, offset: u64, + fallback_output: &str, ) { - publish_owned_command_event( - &self.command_events, - &self.forwarded_terminals, - CommandEvent::Terminal { + 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(), - status, - exit_code, - stdout_end_offset: offset, - stderr_end_offset: 0, + 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> { @@ -656,6 +686,7 @@ impl ScopedWorkdirSession { 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, @@ -664,12 +695,15 @@ impl ScopedWorkdirSession { 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_terminals, command_events, closes_source: false, + #[cfg(test)] + command_start_gate: Mutex::new(None), }); let broker = WorkdirToolBroker { session: child.clone(), @@ -681,7 +715,7 @@ impl ScopedWorkdirSession { capabilities, validity, cleanup_pending, - close_lock: Arc::new(tokio::sync::Mutex::new(())), + close_lock, }) } } @@ -749,7 +783,36 @@ impl WorkdirSession for ScopedWorkdirSession { &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() { @@ -831,6 +894,7 @@ impl WorkdirSession for ScopedWorkdirSession { output.status, output.exit_code, output.next_cursor.unwrap_or(output.content.len()) as u64, + &output.content, ); self.owned_commands .lock() @@ -1036,14 +1100,20 @@ fn publish_owned_command_event( forwarded_terminals: &Mutex>, event: CommandEvent, ) { - if let CommandEvent::Terminal { command_id, .. } = &event - && !forwarded_terminals - .lock() - .expect("forwarded terminal command mutex poisoned") - .insert(command_id.clone()) - { - return; + 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 { .. } | CommandEvent::Output { .. } + if terminals.contains(command_id) => + { + return; + } + _ => {} } + drop(terminals); let _ = sender.send(event); } @@ -1592,6 +1662,99 @@ mod tests { 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(); From ca5fddf89b2f38e54ca8096ada6689404d058897 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 03:48:54 +0900 Subject: [PATCH 05/11] fix: fence recursive SubWorker shutdown --- crates/worker/src/controller.rs | 8 +- .../src/feature/builtin/manage_workdir.rs | 65 +++++++- crates/worker/src/spawn/registry.rs | 157 ++++++++++++++++-- crates/worker/src/spawn/tool.rs | 23 ++- 4 files changed, 217 insertions(+), 36 deletions(-) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index a73a175a..a56c7c59 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1101,14 +1101,16 @@ where "manage Workdir tools require Backend Workspace API authority", )); } - let child_registry = spawned_registry.clone(); + let shutdown_registry = spawned_registry.clone(); + let reopen_registry = spawned_registry.clone(); feature_registry.add_module( - crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_before_workdir_release( + crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle( workspace_client, Arc::new(move || { - let child_registry = child_registry.clone(); + let child_registry = shutdown_registry.clone(); Box::pin(async move { child_registry.shutdown_internal().await }) }), + Arc::new(move || reopen_registry.reopen_internal()), ), ); } diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 24cbc0a9..eb950bbc 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -56,6 +56,7 @@ const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. Th 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."; @@ -63,6 +64,7 @@ const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by i pub struct ManageWorkdirFeature { client: Arc, before_workdir_release: Option, + after_workdir_attach: Option, } impl std::fmt::Debug for ManageWorkdirFeature { @@ -80,16 +82,19 @@ impl ManageWorkdirFeature { Self { client, before_workdir_release: None, + after_workdir_attach: None, } } - pub(crate) fn with_before_workdir_release( + 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), } } } @@ -110,8 +115,10 @@ impl FeatureModule for ManageWorkdirFeature { } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { - let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()) - .with_before_workdir_release(self.before_workdir_release.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, @@ -176,6 +183,7 @@ impl FeatureModule for ManageWorkdirFeature { struct WorkspaceHttpWorkdirBackend { client: Arc, before_workdir_release: Option, + after_workdir_attach: Option, } impl std::fmt::Debug for WorkspaceHttpWorkdirBackend { @@ -371,14 +379,17 @@ impl WorkspaceHttpWorkdirBackend { Self { client, before_workdir_release: None, + after_workdir_attach: None, } } - fn with_before_workdir_release( + 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 } @@ -557,9 +568,17 @@ 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 { @@ -1338,7 +1357,7 @@ mod tests { }); let tool = WorkspaceHttpWorkdirTool { backend: WorkspaceHttpWorkdirBackend::new(client.clone()) - .with_before_workdir_release(Some(before_release)), + .with_child_lifecycle(Some(before_release), None), operation: WorkdirOperation::Detach, }; @@ -1361,7 +1380,7 @@ mod tests { Arc::new(|| Box::pin(async { Err(std::io::Error::other("child cleanup failed")) })); let tool = WorkspaceHttpWorkdirTool { backend: WorkspaceHttpWorkdirBackend::new(client.clone()) - .with_before_workdir_release(Some(before_release)), + .with_child_lifecycle(Some(before_release), None), operation: WorkdirOperation::Detach, }; @@ -1373,4 +1392,32 @@ mod tests { 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/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 3db26267..cba26327 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -72,6 +72,7 @@ pub(crate) struct InternalSpawnedWorkerRecord { #[cfg(test)] pub installed_tools: Arc<[String]>, pub session: InternalWorkerSessionHandle, + pub child_registry: Arc, change_tracker: Option, started_at: Instant, stop_lock: Arc>, @@ -89,6 +90,7 @@ impl InternalSpawnedWorkerRecord { workdir_tool_scope: WorkdirScopeLease, #[cfg(test)] installed_tools: Vec, session: InternalWorkerSessionHandle, + child_registry: Arc, change_tracker: Option, ) -> Self { Self { @@ -98,6 +100,7 @@ impl InternalSpawnedWorkerRecord { #[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,39 @@ pub(crate) struct InternalSpawnReservation { } impl InternalSpawnReservation { - pub(crate) fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> { + pub(crate) fn commit( + mut self, + record: InternalSpawnedWorkerRecord, + ) -> Result<(), (io::Error, InternalSpawnedWorkerRecord)> { if record.worker_name != self.worker_name { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "internal SubWorker reservation name does not match record name", + return Err(( + io::Error::new( + io::ErrorKind::InvalidInput, + "internal SubWorker reservation name does not match record name", + ), + record, )); } - self.registry - .internal_records - .lock() - .map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))? - .push(record.clone()); + let mut records = match self.registry.internal_records.lock() { + Ok(records) => records, + Err(_) => { + return Err(( + io::Error::other("internal spawned-worker registry lock poisoned"), + record, + )); + } + }; + if self.registry.internal_shutting_down.load(Ordering::Acquire) { + return Err(( + io::Error::new( + io::ErrorKind::Interrupted, + "internal SubWorker registry is shutting down", + ), + record, + )); + } + records.push(record.clone()); + drop(records); self.registry.start_protocol_forwarding(record); self.committed = true; Ok(()) @@ -267,6 +291,7 @@ pub struct SpawnedWorkerRegistry { internal_records: std::sync::Mutex>, service_records: std::sync::Mutex>, internal_names: std::sync::Mutex>, + internal_shutting_down: AtomicBool, parent_scope: Option, parent_protocol: Mutex, String)>>, } @@ -283,6 +308,7 @@ 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), parent_scope: None, parent_protocol: Mutex::new(None), }) @@ -294,6 +320,7 @@ 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), parent_scope: None, parent_protocol: Mutex::new(None), }) @@ -304,6 +331,7 @@ 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), parent_scope: Some(parent_scope), parent_protocol: Mutex::new(None), }) @@ -383,6 +411,7 @@ 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), parent_scope, parent_protocol: Mutex::new(None), }), @@ -394,6 +423,12 @@ impl SpawnedWorkerRegistry { self: &Arc, worker_name: String, ) -> io::Result { + 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() @@ -702,6 +737,7 @@ impl SpawnedWorkerRegistry { let Some(record) = self.get_internal(name) else { return Ok(false); }; + Box::pin(record.child_registry.shutdown_internal()).await?; record .workdir_tool_scope .close() @@ -711,13 +747,17 @@ impl SpawnedWorkerRegistry { } pub(crate) async fn shutdown_internal(&self) -> io::Result<()> { - let names = self - .internal_records - .lock() - .expect("internal Worker registry lock poisoned") - .iter() - .map(|record| record.worker_name.clone()) - .collect::>(); + 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::>() + }; let mut first_error = None; for name in names { if let Err(error) = self.remove_internal(&name).await { @@ -727,6 +767,10 @@ impl SpawnedWorkerRegistry { first_error.map_or(Ok(()), Err) } + pub(crate) fn reopen_internal(&self) { + self.internal_shutting_down.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. @@ -753,6 +797,7 @@ 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() @@ -1021,6 +1066,7 @@ mod tests { delegation, Vec::new(), session, + registry(), None, ), sender, @@ -1276,6 +1322,85 @@ mod tests { 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(std::sync::Barrier::new(2)); + let commit_barrier = barrier.clone(); + let commit = tokio::task::spawn_blocking(move || { + commit_barrier.wait(); + reservation.commit(record) + }); + let shutdown_registry = registry.clone(); + let shutdown = tokio::spawn(async move { + barrier.wait(); + shutdown_registry.shutdown_internal().await + }); + + let commit = commit.await.unwrap(); + shutdown.await.unwrap().unwrap(); + if let Err((_error, record)) = commit { + record.session.stop().await.unwrap(); + record.child_registry.shutdown_internal().await.unwrap(); + record.workdir_tool_scope.close().await.unwrap(); + } + + 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; + + registry.shutdown_internal().await.unwrap(); + let (error, record) = reservation.commit(record).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Interrupted); + record.session.stop().await.unwrap(); + record.child_registry.shutdown_internal().await.unwrap(); + record.workdir_tool_scope.close().await.unwrap(); + } + + #[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 294983b0..547d939d 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -600,15 +600,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 @@ -623,10 +627,13 @@ impl Tool for SubWorkerSpawnTool { #[cfg(test)] installed_tools, session.clone(), + child_registry, child_change_tracker, ); - if let Err(error) = name_reservation.commit(record) { + if let Err((error, record)) = name_reservation.commit(record) { let _ = session.stop().await; + let _ = record.child_registry.shutdown_internal().await; + let _ = record.workdir_tool_scope.close().await; return Err(ToolError::ExecutionFailed(format!( "register Internal Worker session: {error}" ))); From 052d60bd7d2f0f9cbe3da19ced380e3ce6af82d9 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 04:10:23 +0900 Subject: [PATCH 06/11] fix: fence late command events and spawn rollback --- crates/workdir/src/scope.rs | 34 ++++- crates/worker/src/spawn/registry.rs | 188 +++++++++++++++++++++------- crates/worker/src/spawn/tool.rs | 5 +- 3 files changed, 173 insertions(+), 54 deletions(-) diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index e730d9f0..131ae6cf 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -75,6 +75,7 @@ impl WorkdirToolBroker { 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, @@ -314,6 +315,7 @@ struct ScopedWorkdirSession { owned_commands: Arc>>, pending_command_events: Arc>>>, starting_tool_calls: Arc>>, + forwarded_starts: Arc>>, forwarded_terminals: Arc>>, command_events: broadcast::Sender, closes_source: bool, @@ -675,6 +677,7 @@ impl ScopedWorkdirSession { 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( @@ -682,6 +685,7 @@ impl ScopedWorkdirSession { owned_commands.clone(), pending_command_events.clone(), starting_tool_calls.clone(), + forwarded_starts.clone(), forwarded_terminals.clone(), command_events.clone(), ) @@ -699,6 +703,7 @@ impl ScopedWorkdirSession { owned_commands, pending_command_events, starting_tool_calls, + forwarded_starts, forwarded_terminals, command_events, closes_source: false, @@ -862,6 +867,7 @@ impl WorkdirSession for ScopedWorkdirSession { { publish_owned_command_event( &self.command_events, + &self.forwarded_starts, &self.forwarded_terminals, CommandEvent::Started { command_id: handle.0.clone(), @@ -871,7 +877,12 @@ impl WorkdirSession for ScopedWorkdirSession { ); } for event in pending { - publish_owned_command_event(&self.command_events, &self.forwarded_terminals, event); + publish_owned_command_event( + &self.command_events, + &self.forwarded_starts, + &self.forwarded_terminals, + event, + ); } Ok(handle) } @@ -1033,6 +1044,7 @@ fn forward_owned_command_events( owned_commands: Arc>>, pending_command_events: Arc>>>, starting_tool_calls: Arc>>, + forwarded_starts: Arc>>, forwarded_terminals: Arc>>, sender: broadcast::Sender, ) -> Option> { @@ -1082,7 +1094,7 @@ fn forward_owned_command_events( } drop(pending); drop(owned); - publish_owned_command_event(&sender, &forwarded_terminals, event); + publish_owned_command_event(&sender, &forwarded_starts, &forwarded_terminals, event); } })) } @@ -1097,6 +1109,7 @@ fn command_event_id(event: &CommandEvent) -> &str { fn publish_owned_command_event( sender: &broadcast::Sender, + forwarded_starts: &Mutex>, forwarded_terminals: &Mutex>, event: CommandEvent, ) { @@ -1106,11 +1119,16 @@ fn publish_owned_command_event( .expect("forwarded terminal command mutex poisoned"); match &event { CommandEvent::Terminal { .. } if !terminals.insert(command_id.to_string()) => return, - CommandEvent::Started { .. } | CommandEvent::Output { .. } - if terminals.contains(command_id) => + 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); @@ -1657,8 +1675,16 @@ mod tests { } 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(); } diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index cba26327..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,7 +23,7 @@ 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::WorkdirScopeLease; @@ -238,39 +238,56 @@ pub(crate) struct InternalSpawnReservation { } impl InternalSpawnReservation { - pub(crate) fn commit( - mut self, - record: InternalSpawnedWorkerRecord, - ) -> Result<(), (io::Error, InternalSpawnedWorkerRecord)> { - if record.worker_name != self.worker_name { - return Err(( - io::Error::new( - io::ErrorKind::InvalidInput, - "internal SubWorker reservation name does not match record name", - ), - record, - )); - } - let mut records = match self.registry.internal_records.lock() { - Ok(records) => records, - Err(_) => { - return Err(( - io::Error::other("internal spawned-worker registry lock poisoned"), - record, - )); + 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 self.registry.internal_shutting_down.load(Ordering::Acquire) { - return Err(( - io::Error::new( - io::ErrorKind::Interrupted, - "internal SubWorker registry is shutting down", - ), - record, - )); + 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("; ") + ))); } - records.push(record.clone()); - drop(records); self.registry.start_protocol_forwarding(record); self.committed = true; Ok(()) @@ -284,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(); } } @@ -292,6 +313,9 @@ pub struct SpawnedWorkerRegistry { 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)>>, } @@ -309,6 +333,9 @@ impl SpawnedWorkerRegistry { 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), }) @@ -321,6 +348,9 @@ impl SpawnedWorkerRegistry { 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), }) @@ -332,6 +362,9 @@ impl SpawnedWorkerRegistry { 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), }) @@ -412,6 +445,9 @@ impl SpawnedWorkerRegistry { 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), }), @@ -423,6 +459,10 @@ 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, @@ -439,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, @@ -758,17 +800,31 @@ impl SpawnedWorkerRegistry { .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. @@ -1342,24 +1398,22 @@ mod tests { let (record, _events) = record("racing-child", InternalWorkerVisibility::ParentClient).await; let scope = record.workdir_tool_scope.clone(); - let barrier = Arc::new(std::sync::Barrier::new(2)); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); let commit_barrier = barrier.clone(); - let commit = tokio::task::spawn_blocking(move || { - commit_barrier.wait(); - reservation.commit(record) + 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(); + barrier.wait().await; shutdown_registry.shutdown_internal().await }); let commit = commit.await.unwrap(); shutdown.await.unwrap().unwrap(); - if let Err((_error, record)) = commit { - record.session.stop().await.unwrap(); - record.child_registry.shutdown_internal().await.unwrap(); - record.workdir_tool_scope.close().await.unwrap(); + if let Err(error) = commit { + assert_eq!(error.kind(), io::ErrorKind::Interrupted); } assert!(registry.list_internal().is_empty()); @@ -1375,12 +1429,54 @@ mod tests { let (record, _events) = record("racing-child", InternalWorkerVisibility::ParentClient).await; - registry.shutdown_internal().await.unwrap(); - let (error, record) = reservation.commit(record).unwrap_err(); + 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); - record.session.stop().await.unwrap(); - record.child_registry.shutdown_internal().await.unwrap(); - record.workdir_tool_scope.close().await.unwrap(); + 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] diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 547d939d..6370653f 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -630,10 +630,7 @@ impl Tool for SubWorkerSpawnTool { child_registry, child_change_tracker, ); - if let Err((error, record)) = name_reservation.commit(record) { - let _ = session.stop().await; - let _ = record.child_registry.shutdown_internal().await; - let _ = record.workdir_tool_scope.close().await; + if let Err(error) = name_reservation.commit(record).await { return Err(ToolError::ExecutionFailed(format!( "register Internal Worker session: {error}" ))); From 78d571ed140bf54ae90a755e29f24f63293184d5 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 05:06:37 +0900 Subject: [PATCH 07/11] feat: add workspace runtime trust key management --- crates/client/src/workspace_product.rs | 43 +- crates/workspace-api/src/lib.rs | 204 +++- crates/workspace-server/src/latest_schema.sql | 17 + crates/workspace-server/src/lib.rs | 9 + crates/workspace-server/src/main.rs | 2 + crates/workspace-server/src/server.rs | 641 +++++++++++- crates/workspace-server/src/store.rs | 990 +++++++++++------- web/workspace/deno.json | 2 +- .../src/lib/generated/workspace-api.ts | 102 ++ .../lib/workspace/api/runtime-management.ts | 723 +++++++++++++ .../src/lib/workspace/api/workspace-model.ts | 7 +- .../src/lib/workspace/styles/settings.css | 190 ++++ .../settings/runtimes/+page.svelte | 59 +- .../[workspaceId]/settings/runtimes/+page.ts | 12 +- .../runtimes/[runtimeId]/+page.svelte | 325 ++++++ .../settings/runtimes/[runtimeId]/+page.ts | 31 + .../tests/runtime-management-source.test.ts | 133 +++ .../tests/runtime-management.test.ts | 227 ++++ 18 files changed, 3258 insertions(+), 459 deletions(-) create mode 100644 web/workspace/src/lib/workspace/api/runtime-management.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts create mode 100644 web/workspace/tests/runtime-management-source.test.ts create mode 100644 web/workspace/tests/runtime-management.test.ts diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 70b6a6af..bdc97f1f 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, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, + TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, + WorkspaceRuntimeResource, }; use crate::{BackendApiClient, BackendWorkspaceClientError}; @@ -241,6 +243,43 @@ 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 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/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 8c8dd902..6cc62968 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,119 @@ 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 public_key: Option, + #[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 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 +2510,22 @@ 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), + PutRuntimeTrustKeyRequest::decl(&config), + RevokeRuntimeTrustKeyRequest::decl(&config), + RuntimeTrustConflictKind::decl(&config), + RuntimeTrustConflictResponse::decl(&config), RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestResponse::decl(&config), @@ -3022,7 +3154,8 @@ mod tests { }}, "permissions": { "manage_repositories": true, - "manage_secrets": true + "manage_secrets": true, + "manage_runtimes": true }, "extension_points": { "store": "sqlite", @@ -3086,6 +3219,75 @@ 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", + "public_key": "ssh-ed25519 AAAA runtime-test", + "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": "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..e9f86a18 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 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, + runtime_binding_expectations: Arc>>, companion: Arc, orchestrator_spawn_lock: Arc>, orchestrator_attention_fingerprint: Arc>>, @@ -1575,6 +1578,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 +1618,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 +1642,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 +1775,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)), @@ -2648,7 +2676,11 @@ 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", + put(scoped_put_runtime_trust_key).delete(scoped_revoke_runtime_trust_key), ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests", @@ -10956,11 +10988,225 @@ 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, + Extension(actor): Extension, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let workspace = api + .store + .get_workspace(&path.workspace_id) + .await? + .ok_or(Error::WorkspaceIdMismatch)?; + let is_owner = workspace.owner_account_id == actor.account_id; + Ok(Json( + workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, is_owner).await?, + )) +} + +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, true).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, true).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 } @@ -12211,6 +12457,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(), @@ -12451,7 +12698,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", )) } @@ -12469,8 +12716,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)) @@ -12499,14 +12751,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) } @@ -14488,6 +14732,133 @@ async fn workspace_runtime_resources_response( }) } +async fn workspace_runtime_detail( + api: &WorkspaceApi, + workspace_id: &str, + runtime_id: &str, + include_public_key: bool, +) -> 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, + public_key: None, + 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 + }, + public_key: include_public_key.then(|| binding.public_key.clone()), + 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(); @@ -16040,6 +16411,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(_) => { @@ -16068,6 +16441,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 @@ -16573,6 +16947,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, @@ -22068,6 +22443,166 @@ 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(owner_detail) = scoped_get_runtime_detail( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(owner.clone()), + ) + .await + .unwrap(); + assert!(owner_detail.trust_key.public_key.is_some()); + assert_eq!(owner_detail.trust_key.revision, Some(1)); + let Json(reader_detail) = scoped_get_runtime_detail( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "runtime-a".to_string(), + }), + Extension(non_owner.clone()), + ) + .await + .unwrap(); + assert!(reader_detail.trust_key.public_key.is_none()); + assert!(reader_detail.trust_key.fingerprint.is_some()); + + 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()); + 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(); @@ -22115,6 +22650,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") @@ -22602,6 +23147,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, @@ -24088,6 +24634,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, @@ -25205,6 +25752,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, @@ -25222,7 +25770,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; @@ -25303,6 +25851,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", @@ -25354,6 +25912,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, @@ -25370,7 +25929,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..44de5aa5 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,7 @@ 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 sql = conn.query_row( @@ -6296,6 +6493,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 +6537,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 +7070,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 +7145,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 +7153,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 +7169,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 +7214,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 +7267,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 +7362,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 +7495,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 +7525,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 +8525,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/web/workspace/deno.json b/web/workspace/deno.json index cf49316b..eff2525d 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 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 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..f28915e7 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,107 @@ 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; + public_key?: string | null; + 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 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..944a5238 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -0,0 +1,723 @@ +import type { + Diagnostic, + PutRuntimeTrustKeyRequest, + RevokeRuntimeTrustKeyRequest, + RuntimeIdentityAuthority, + RuntimeManagementSummary, + RuntimeSourceKind, + RuntimeSourceStatus, + RuntimeSourceSummary, + RuntimeTrustAuditAction, + RuntimeTrustAuditEntry, + RuntimeTrustConflictKind, + RuntimeTrustConflictResponse, + 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; + } +} + +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"], + [ + "public_key", + "fingerprint", + "revision", + "created_at", + "updated_at", + "revoked_at", + ], + path, + ); + const result: RuntimeTrustKeyState = { + status: enumValue(item.status, `${path}.status`, TRUST_STATUSES), + public_key: optionalNullableString( + item.public_key, + `${path}.public_key`, + LIMITS.publicKeyBytes, + ), + 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 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 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, + fetchImpl: typeof fetch = fetch, +): Promise { + 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/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index ded7886b..e6f363b3 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -342,6 +342,196 @@ .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 { + 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 { + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; + } + + .runtime-trust-form textarea, + .runtime-trust-form input { + width: 100%; + padding: 0.65rem 0.75rem; + } + + .runtime-trust-form textarea { + resize: vertical; + } + + .runtime-trust-form small { + color: var(--text-muted); + } + + .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} +
+

Workspace trust

+ + {#if trust.public_key} +
+ + +
+ {#if revealPublicKey} +
{trust.public_key}
+ {/if} + {:else if trust.status !== 'unconfigured'} +

The public key was not included in this authorized response.

+ {/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..f455191e --- /dev/null +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -0,0 +1,133 @@ +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( + 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", + "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..cc938db9 --- /dev/null +++ b/web/workspace/tests/runtime-management.test.ts @@ -0,0 +1,227 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +import { + parseRuntimeTrustConflict, + parseWorkspaceRuntimeDetail, + parseWorkspaceRuntimeList, + putRuntimeTrustKey, + RuntimeTrustConflictError, +} 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", + public_key: "ssh-ed25519 AAAA-test", + 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", () => { + const largeKey = structuredClone(detail()); + largeKey.trust_key.public_key = "x".repeat(16 * 1024 + 1); + assertThrows( + () => parseWorkspaceRuntimeDetail(largeKey), + "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("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", + ); +}); From 2cd57a32b2f091559377ca1ae4ef6067db98ee68 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 05:17:27 +0900 Subject: [PATCH 08/11] fix: align runtime trust schema and built-in controls --- crates/workspace-server/src/latest_schema.sql | 2 +- crates/workspace-server/src/store.rs | 11 +++++++++++ .../settings/runtimes/[runtimeId]/+page.svelte | 2 +- web/workspace/tests/runtime-management-source.test.ts | 4 ++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index e9f86a18..720ff8ea 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -440,7 +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 CHECK (binding_revision > 0), + binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revoked_at TEXT, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 44de5aa5..0c7039b4 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -6470,6 +6470,17 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { "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( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workspace_runtime_bindings'", [], diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index c7ad9c21..e05b9663 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -219,7 +219,7 @@ {/if} - {#if data.workspace.permissions.manage_runtimes} + {#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}

Workspace trust

diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts index f455191e..034b4626 100644 --- a/web/workspace/tests/runtime-management-source.test.ts +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -84,6 +84,10 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as 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("!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", From 5686bbc9fd49f561fad0fb2dd67207b80a7171ba Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 05:25:03 +0900 Subject: [PATCH 09/11] fix: preview runtime trust rotation fingerprint --- .../lib/workspace/api/runtime-management.ts | 35 ++++++++++++++ .../src/lib/workspace/styles/settings.css | 24 ++++++++++ .../runtimes/[runtimeId]/+page.svelte | 48 ++++++++++++++++++- .../tests/runtime-management-source.test.ts | 8 ++++ .../tests/runtime-management.test.ts | 12 +++++ 5 files changed, 126 insertions(+), 1 deletion(-) diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 944a5238..cefb9785 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -677,6 +677,41 @@ async function finishMutation( return detail; } +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, diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index e6f363b3..f085acea 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -470,6 +470,30 @@ 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); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index e05b9663..13a36d5e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -6,6 +6,7 @@ RuntimeTrustKeyStatus, } from '$lib/generated/workspace-api'; import { + previewRuntimePublicKeyFingerprint, putRuntimeTrustKey, revokeRuntimeTrustKey, RuntimeTrustConflictError, @@ -23,6 +24,29 @@ let fieldError = $state(null); let requestError = $state(null); let successMessage = $state(null); + let replacementFingerprint = $state(null); + let replacementFingerprintError = $state(null); + let fingerprintGeneration = 0; + + $effect(() => { + const key = publicKey.trim(); + const generation = ++fingerprintGeneration; + replacementFingerprint = null; + replacementFingerprintError = null; + if (!key) return; + void previewRuntimePublicKeyFingerprint(key).then( + (fingerprint) => { + if (generation === fingerprintGeneration) replacementFingerprint = fingerprint; + }, + (error) => { + if (generation === fingerprintGeneration) { + replacementFingerprintError = error instanceof Error + ? error.message + : String(error); + } + }, + ); + }); function trustAction(status: RuntimeTrustKeyStatus): TrustAction { if (status === 'unconfigured') return 'create'; @@ -69,6 +93,14 @@ fieldError = 'Public key must be at most 16 KiB of UTF-8 text.'; return; } + if (replacementFingerprintError) { + fieldError = replacementFingerprintError; + return; + } + if (!replacementFingerprint) { + fieldError = 'Wait for the replacement fingerprint preview before saving.'; + return; + } const trust = data.runtimeDetail.trust_key; const action = trustAction(trust.status); @@ -249,9 +281,23 @@ spellcheck="false" aria-describedby={fieldError ? 'runtime-public-key-error' : undefined} aria-invalid={fieldError ? 'true' : undefined} - placeholder="ssh-ed25519 …" + placeholder="yoi-ed25519-pub:v1:…" > +
+
+
Current fingerprint
+
{trust.fingerprint ?? 'Not configured'}
+
+
+
Replacement fingerprint
+
{replacementFingerprint ?? 'Enter a valid public key'}
+
+
+ {#if replacementFingerprintError} +

{replacementFingerprintError}

+ {/if} + {#if currentAction !== 'create'} = 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", diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index cc938db9..daf80b71 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -6,6 +6,7 @@ import { parseRuntimeTrustConflict, parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeList, + previewRuntimePublicKeyFingerprint, putRuntimeTrustKey, RuntimeTrustConflictError, } from "../src/lib/workspace/api/runtime-management.ts"; @@ -180,6 +181,17 @@ Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", ); }); +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) => { From 5fd2ccf0840c7e6f723f8edc884416f177e671df Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 05:50:29 +0900 Subject: [PATCH 10/11] fix: gate runtime key reveal and revoke confirmation --- crates/client/src/workspace_product.rs | 16 ++- crates/workspace-api/src/lib.rs | 18 +++- crates/workspace-server/src/server.rs | 86 +++++++++++----- .../src/lib/generated/workspace-api.ts | 3 +- .../lib/workspace/api/runtime-management.ts | 56 ++++++++--- .../src/lib/workspace/styles/settings.css | 12 ++- .../runtimes/[runtimeId]/+page.svelte | 97 +++++++++++++++---- .../tests/runtime-management-source.test.ts | 2 + .../tests/runtime-management.test.ts | 34 ++++++- 9 files changed, 253 insertions(+), 71 deletions(-) diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index bdc97f1f..99d72538 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -13,9 +13,9 @@ use workspace_api::{ CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest, - RevokeRuntimeTrustKeyRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, - WorkspaceRuntimeResource, + RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse, + TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, + WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, }; use crate::{BackendApiClient, BackendWorkspaceClientError}; @@ -256,6 +256,16 @@ impl BackendWorkspaceProductClient { 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, diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 6cc62968..a2cf8f87 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -1220,8 +1220,6 @@ pub enum RuntimeTrustKeyStatus { pub struct RuntimeTrustKeyState { pub status: RuntimeTrustKeyStatus, #[serde(default, skip_serializing_if = "Option::is_none")] - pub public_key: Option, - #[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"))] @@ -1272,6 +1270,13 @@ pub struct WorkspaceRuntimeDetail { 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)] @@ -2522,6 +2527,7 @@ pub fn catalog_typescript() -> String { RuntimeTrustAuditAction::decl(&config), RuntimeTrustAuditEntry::decl(&config), WorkspaceRuntimeDetail::decl(&config), + RuntimeTrustKeyRevealResponse::decl(&config), PutRuntimeTrustKeyRequest::decl(&config), RevokeRuntimeTrustKeyRequest::decl(&config), RuntimeTrustConflictKind::decl(&config), @@ -3250,7 +3256,6 @@ mod tests { "endpoint": "https://runtime.example", "trust_key": { "status": "active", - "public_key": "ssh-ed25519 AAAA runtime-test", "fingerprint": "SHA256:test", "revision": 2, "created_at": "2026-09-01T12:00:00Z", @@ -3271,6 +3276,13 @@ mod tests { 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", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 41492447..ba0fa922 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -78,10 +78,11 @@ use workspace_api::{ RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry, - RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyState, - RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, - UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, - WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, + RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse, + RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, + TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse, + WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, + WorkerLaunchWorkerSummary, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, @@ -2672,7 +2673,9 @@ fn build_inner_router(api: WorkspaceApi) -> Router { ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", - put(scoped_put_runtime_trust_key).delete(scoped_revoke_runtime_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", @@ -10884,20 +10887,44 @@ async fn scoped_create_remote_runtime( async fn scoped_get_runtime_detail( State(api): State, AxumPath(path): AxumPath, - Extension(actor): Extension, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let workspace = api - .store - .get_workspace(&path.workspace_id) - .await? - .ok_or(Error::WorkspaceIdMismatch)?; - let is_owner = workspace.owner_account_id == actor.account_id; Ok(Json( - workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, is_owner).await?, + 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, @@ -10993,7 +11020,7 @@ async fn scoped_put_runtime_trust_key( .register_remote_runtime(source); } Ok( - Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?) + Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?) .into_response(), ) } @@ -11046,7 +11073,7 @@ async fn scoped_revoke_runtime_trust_key( api.runtime_subscription_broker .unregister_runtime(&path.runtime_id); Ok( - Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?) + Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?) .into_response(), ) } @@ -14738,7 +14765,6 @@ async fn workspace_runtime_detail( api: &WorkspaceApi, workspace_id: &str, runtime_id: &str, - include_public_key: bool, ) -> ApiResult { let binding = api .store @@ -14800,7 +14826,6 @@ async fn workspace_runtime_detail( let trust_key = binding.as_ref().map_or( RuntimeTrustKeyState { status: RuntimeTrustKeyStatus::Unconfigured, - public_key: None, fingerprint: None, revision: None, created_at: None, @@ -14813,7 +14838,6 @@ async fn workspace_runtime_detail( } else { RuntimeTrustKeyStatus::Active }, - public_key: include_public_key.then(|| binding.public_key.clone()), fingerprint: Some(binding.public_key_fingerprint.clone()), revision: Some(binding.binding_revision), created_at: Some(binding.created_at.clone()), @@ -22529,7 +22553,18 @@ mod tests { .await .unwrap(); - let Json(owner_detail) = scoped_get_runtime_detail( + 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(), @@ -22539,9 +22574,8 @@ mod tests { ) .await .unwrap(); - assert!(owner_detail.trust_key.public_key.is_some()); - assert_eq!(owner_detail.trust_key.revision, Some(1)); - let Json(reader_detail) = scoped_get_runtime_detail( + 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(), @@ -22550,9 +22584,11 @@ mod tests { Extension(non_owner.clone()), ) .await - .unwrap(); - assert!(reader_detail.trust_key.public_key.is_none()); - assert!(reader_detail.trust_key.fingerprint.is_some()); + .unwrap_err(); + assert_eq!( + denied_reveal.into_response().status(), + StatusCode::FORBIDDEN + ); let response = scoped_put_runtime_trust_key( State(api.clone()), diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index f28915e7..09be8bf9 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -276,7 +276,6 @@ export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked"; export type RuntimeTrustKeyState = { status: RuntimeTrustKeyStatus; - public_key?: string | null; fingerprint?: string | null; revision?: number | null; created_at?: string | null; @@ -307,6 +306,8 @@ export type WorkspaceRuntimeDetail = { recent_audit: Array; }; +export type RuntimeTrustKeyRevealResponse = { public_key: string }; + export type PutRuntimeTrustKeyRequest = { public_key: string; expected_revision: number | null; diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index cefb9785..2b6a8ee3 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -11,6 +11,7 @@ import type { RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse, + RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, WorkspaceRuntimeDetail, @@ -354,23 +355,11 @@ function trustKey(value: unknown, path: string): RuntimeTrustKeyState { exactKeys( item, ["status"], - [ - "public_key", - "fingerprint", - "revision", - "created_at", - "updated_at", - "revoked_at", - ], + ["fingerprint", "revision", "created_at", "updated_at", "revoked_at"], path, ); const result: RuntimeTrustKeyState = { status: enumValue(item.status, `${path}.status`, TRUST_STATUSES), - public_key: optionalNullableString( - item.public_key, - `${path}.public_key`, - LIMITS.publicKeyBytes, - ), fingerprint: optionalNullableString( item.fingerprint, `${path}.fingerprint`, @@ -538,6 +527,25 @@ export function parseWorkspaceRuntimeDetail( }; } +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 { @@ -677,6 +685,21 @@ async function finishMutation( 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 { @@ -739,8 +762,15 @@ 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, diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index f085acea..03b4c3ad 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -426,7 +426,8 @@ .runtime-public-key, .runtime-trust-form textarea, - .runtime-trust-form input { + .runtime-trust-form input, + .runtime-revoke-row input { border: 1px solid var(--line); border-radius: 0.5rem; background: var(--bg-raised); @@ -450,14 +451,16 @@ max-width: 56rem; } - .runtime-trust-form label { + .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-trust-form input, + .runtime-revoke-row input { width: 100%; padding: 0.65rem 0.75rem; } @@ -466,7 +469,8 @@ resize: vertical; } - .runtime-trust-form small { + .runtime-trust-form small, + .runtime-revoke-row small { color: var(--text-muted); } diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index 13a36d5e..2aa5d7be 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -8,6 +8,7 @@ import { previewRuntimePublicKeyFingerprint, putRuntimeTrustKey, + revealRuntimeTrustKey, revokeRuntimeTrustKey, RuntimeTrustConflictError, RuntimeTrustRequestError, @@ -17,10 +18,12 @@ type TrustAction = 'create' | 'replace' | 'reactivate'; let { data }: PageProps = $props(); - let revealPublicKey = $state(false); + let showPublicKey = $state(false); + let revealedPublicKey = $state(null); let publicKey = $state(''); let fingerprintConfirmation = $state(''); - let busyAction = $state<'save' | 'revoke' | 'copy' | null>(null); + let revokeFingerprintConfirmation = $state(''); + let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null); let fieldError = $state(null); let requestError = $state(null); let successMessage = $state(null); @@ -125,7 +128,9 @@ await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request); publicKey = ''; fingerprintConfirmation = ''; - revealPublicKey = false; + revokeFingerprintConfirmation = ''; + showPublicKey = false; + revealedPublicKey = null; successMessage = action === 'create' ? 'Workspace trust was created.' : action === 'replace' @@ -154,6 +159,13 @@ requestError = 'Only active Workspace trust can be revoked.'; return; } + if ( + !trust.fingerprint || + revokeFingerprintConfirmation.trim() !== trust.fingerprint + ) { + fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.'; + return; + } fieldError = null; requestError = null; @@ -164,10 +176,18 @@ }; try { - await revokeRuntimeTrustKey(data.workspaceId, data.runtimeId, request); + await revokeRuntimeTrustKey( + data.workspaceId, + data.runtimeId, + request, + trust.fingerprint, + revokeFingerprintConfirmation, + ); publicKey = ''; fingerprintConfirmation = ''; - revealPublicKey = false; + revokeFingerprintConfirmation = ''; + showPublicKey = false; + revealedPublicKey = null; successMessage = 'Workspace trust was revoked.'; await reloadAuthority(); } catch (error) { @@ -182,16 +202,40 @@ } } + async function togglePublicKeyReveal(): Promise { + if (showPublicKey) { + showPublicKey = false; + revealedPublicKey = null; + return; + } + if (busyAction !== null) return; + busyAction = 'reveal'; + requestError = null; + successMessage = null; + try { + const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId); + revealedPublicKey = response.public_key; + showPublicKey = true; + } catch (error) { + requestError = error instanceof Error ? error.message : 'Public key reveal failed.'; + } finally { + busyAction = null; + } + } + async function copyPublicKey(): Promise { - const key = data.runtimeDetail?.trust_key.public_key; - if (!key || busyAction !== null) return; + if (busyAction !== null) return; busyAction = 'copy'; requestError = null; + successMessage = null; try { - await navigator.clipboard.writeText(key); + const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId); + await navigator.clipboard.writeText(response.public_key); successMessage = 'Public key copied.'; - } catch { - requestError = 'The browser could not copy the public key.'; + } catch (error) { + requestError = error instanceof Error + ? error.message + : 'The browser could not copy the public key.'; } finally { busyAction = null; } @@ -255,20 +299,23 @@

Workspace trust

- {#if trust.public_key} + {#if trust.status !== 'unconfigured'}
-
- {#if revealPublicKey} -
{trust.public_key}
+ {#if showPublicKey && revealedPublicKey} +
{revealedPublicKey}
{/if} - {:else if trust.status !== 'unconfigured'} -

The public key was not included in this authorized response.

{/if}
@@ -324,11 +371,25 @@
Revoke Workspace trust

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

+
diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts index ac08da59..d1ef4af4 100644 --- a/web/workspace/tests/runtime-management-source.test.ts +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -110,6 +110,8 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as "Revoke Workspace trust", "Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", "RuntimeTrustConflictError", + "revealRuntimeTrustKey", + "revokeFingerprintConfirmation.trim() !== trust.fingerprint", "await reloadAuthority()", "busyAction !== null", "Workdirs", diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index daf80b71..9e9daf5b 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -4,10 +4,12 @@ declare const Deno: { import { parseRuntimeTrustConflict, + parseRuntimeTrustKeyRevealResponse, parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeList, previewRuntimePublicKeyFingerprint, putRuntimeTrustKey, + revokeRuntimeTrustKey, RuntimeTrustConflictError, } from "../src/lib/workspace/api/runtime-management.ts"; @@ -62,7 +64,6 @@ function detail() { endpoint: "https://runtime.example.test", trust_key: { status: "active", - public_key: "ssh-ed25519 AAAA-test", fingerprint: "SHA256:current", revision: 3, created_at: "2026-09-01T12:00:00Z", @@ -162,10 +163,11 @@ Deno.test("Runtime validators reject unsafe revisions and bounded collection ove }); Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => { - const largeKey = structuredClone(detail()); - largeKey.trust_key.public_key = "x".repeat(16 * 1024 + 1); assertThrows( - () => parseWorkspaceRuntimeDetail(largeKey), + () => + parseRuntimeTrustKeyRevealResponse({ + public_key: "x".repeat(16 * 1024 + 1), + }), "must be at most 16384 UTF-8 bytes", ); @@ -181,6 +183,30 @@ Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", ); }); +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 public key preview matches the Server fingerprint contract", async () => { const fingerprint = await previewRuntimePublicKeyFingerprint( "yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", From 668a9062b3116784d98ac18d333689cfd78f3fc5 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 06:11:23 +0900 Subject: [PATCH 11/11] fix: fence runtime detail route state --- crates/workspace-server/src/server.rs | 16 +++++- .../lib/workspace/api/runtime-management.ts | 28 ++++++++++ .../runtimes/[runtimeId]/+page.svelte | 55 ++++++++++++++++--- .../tests/runtime-management-source.test.ts | 10 ++++ .../tests/runtime-management.test.ts | 22 ++++++++ 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ba0fa922..41db9ae9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -14684,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 @@ -22674,6 +22674,20 @@ mod tests { .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() diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 2b6a8ee3..ac15d012 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -98,6 +98,34 @@ export class RuntimeTrustRequestError extends Error { } } +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}`); } diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index 2aa5d7be..bceedc8f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -11,7 +11,9 @@ revealRuntimeTrustKey, revokeRuntimeTrustKey, RuntimeTrustConflictError, + RuntimeTrustRouteFence, RuntimeTrustRequestError, + type RuntimeTrustRouteOperation, } from '$lib/workspace/api/runtime-management'; import type { PageProps } from './$types'; @@ -30,6 +32,26 @@ let replacementFingerprint = $state(null); let replacementFingerprintError = $state(null); let fingerprintGeneration = 0; + const routeFence = new RuntimeTrustRouteFence(); + let routeGeneration = 0; + + $effect(() => { + const nextGeneration = routeFence.enter(data.runtimeId); + if (nextGeneration === routeGeneration) return; + routeGeneration = nextGeneration; + fingerprintGeneration += 1; + showPublicKey = false; + revealedPublicKey = null; + publicKey = ''; + fingerprintConfirmation = ''; + revokeFingerprintConfirmation = ''; + busyAction = null; + fieldError = null; + requestError = null; + successMessage = null; + replacementFingerprint = null; + replacementFingerprintError = null; + }); $effect(() => { const key = publicKey.trim(); @@ -79,6 +101,10 @@ await invalidateAll(); } + function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean { + return routeFence.isCurrent(operation, data.runtimeId); + } + async function saveTrustKey(event: SubmitEvent): Promise { event.preventDefault(); if (busyAction !== null || !data.runtimeDetail) return; @@ -123,9 +149,11 @@ expected_revision: trust.revision ?? null, }; + const operation = routeFence.capture(data.runtimeId); busyAction = 'save'; try { - await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request); + await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request); + if (!isCurrentRoute(operation)) return; publicKey = ''; fingerprintConfirmation = ''; revokeFingerprintConfirmation = ''; @@ -138,6 +166,7 @@ : 'Workspace trust was reactivated.'; await reloadAuthority(); } catch (error) { + if (!isCurrentRoute(operation)) return; fingerprintConfirmation = ''; if (error instanceof RuntimeTrustConflictError) { requestError = `${error.message} Authoritative Runtime trust has been reloaded.`; @@ -148,7 +177,7 @@ requestError = error instanceof Error ? error.message : 'Runtime trust update failed.'; } } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } @@ -170,6 +199,7 @@ fieldError = null; requestError = null; successMessage = null; + const operation = routeFence.capture(data.runtimeId); busyAction = 'revoke'; const request: RevokeRuntimeTrustKeyRequest = { expected_revision: trust.revision, @@ -178,11 +208,12 @@ try { await revokeRuntimeTrustKey( data.workspaceId, - data.runtimeId, + operation.runtimeId, request, trust.fingerprint, revokeFingerprintConfirmation, ); + if (!isCurrentRoute(operation)) return; publicKey = ''; fingerprintConfirmation = ''; revokeFingerprintConfirmation = ''; @@ -191,6 +222,7 @@ successMessage = 'Workspace trust was revoked.'; await reloadAuthority(); } catch (error) { + if (!isCurrentRoute(operation)) return; if (error instanceof RuntimeTrustConflictError) { requestError = `${error.message} Authoritative Runtime trust has been reloaded.`; await reloadAuthority(); @@ -198,7 +230,7 @@ requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.'; } } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } @@ -209,35 +241,42 @@ return; } if (busyAction !== null) return; + const operation = routeFence.capture(data.runtimeId); busyAction = 'reveal'; requestError = null; successMessage = null; try { - const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId); + const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId); + if (!isCurrentRoute(operation)) return; revealedPublicKey = response.public_key; showPublicKey = true; } catch (error) { + if (!isCurrentRoute(operation)) return; requestError = error instanceof Error ? error.message : 'Public key reveal failed.'; } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } async function copyPublicKey(): Promise { if (busyAction !== null) return; + const operation = routeFence.capture(data.runtimeId); busyAction = 'copy'; requestError = null; successMessage = null; try { - const response = await revealRuntimeTrustKey(data.workspaceId, data.runtimeId); + const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId); + if (!isCurrentRoute(operation)) return; await navigator.clipboard.writeText(response.public_key); + if (!isCurrentRoute(operation)) return; successMessage = 'Public key copied.'; } catch (error) { + if (!isCurrentRoute(operation)) return; requestError = error instanceof Error ? error.message : 'The browser could not copy the public key.'; } finally { - busyAction = null; + if (isCurrentRoute(operation)) busyAction = null; } } diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts index d1ef4af4..1aaee2c1 100644 --- a/web/workspace/tests/runtime-management-source.test.ts +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -110,6 +110,16 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as "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()", diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index 9e9daf5b..3d5c83b6 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -11,6 +11,7 @@ import { putRuntimeTrustKey, revokeRuntimeTrustKey, RuntimeTrustConflictError, + RuntimeTrustRouteFence, } from "../src/lib/workspace/api/runtime-management.ts"; function assert(condition: unknown, message: string): asserts condition { @@ -207,6 +208,27 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => { 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",