diff --git a/crates/manifest/README.md b/crates/manifest/README.md index 7832b9ead..82ccd0aa7 100644 --- a/crates/manifest/README.md +++ b/crates/manifest/README.md @@ -25,6 +25,8 @@ Does not own: Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles. +`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out. + ## See also - [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 910eacfe1..f75441f36 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -51,8 +51,9 @@ pub struct WorkerManifestConfig { /// is disabled; `Some` requires `default_action` during final resolve. #[serde(default)] pub permissions: Option, - /// Explicit built-in feature/tool-surface enablement. Absent flags resolve - /// disabled after cascade merge. + /// Built-in feature/tool-surface enablement. Absent tool flags resolve + /// disabled after cascade merge; the read-only WIP workdir catalog defaults + /// enabled without granting authority. #[serde(default)] pub feature: FeatureConfigPartial, /// Explicit Model Context Protocol provider declarations. Config parsing @@ -95,6 +96,8 @@ pub struct FeatureConfigPartial { #[serde(default)] pub manage_workdir: Option, #[serde(default)] + pub workdir_catalog: Option, + #[serde(default)] pub ticket: Option, #[serde(default)] pub merge_request: Option, @@ -136,6 +139,11 @@ impl FeatureConfigPartial { other.manage_workdir, FeatureFlagConfigPartial::merge, ), + workdir_catalog: merge_option( + self.workdir_catalog, + other.workdir_catalog, + FeatureFlagConfigPartial::merge, + ), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), merge_request: merge_option( self.merge_request, @@ -388,6 +396,12 @@ impl From for FeatureConfig { .manage_workdir .map(FeatureFlagConfig::from) .unwrap_or_default(), + workdir_catalog: FeatureFlagConfig { + enabled: value + .workdir_catalog + .and_then(|flag| flag.enabled) + .unwrap_or(defaults::WORKDIR_CATALOG_ENABLED), + }, ticket: value .ticket .map(TicketFeatureConfig::from) @@ -579,6 +593,7 @@ impl From for FeatureConfigPartial { workspace_worker_discovery: Some(value.workspace_worker_discovery.into()), objective: Some(value.objective.into()), manage_workdir: Some(value.manage_workdir.into()), + workdir_catalog: Some(value.workdir_catalog.into()), ticket: Some(value.ticket.into()), merge_request: Some(value.merge_request.into()), orchestration: Some(value.orchestration.into()), @@ -766,7 +781,8 @@ impl WorkerManifestConfig { /// Base config populated with the in-code per-field defaults listed in /// [`crate::defaults`]. This is not a selectable Profile and does not - /// enable a launch capability surface. Profile and one-file Manifest + /// enable launch authority or normal Tools. The read-only WIP workdir + /// catalog defaults enabled independently. Profile and one-file Manifest /// resolvers start from this layer so every per-field default lives at /// exactly one call site (the `defaults` module). /// @@ -2232,7 +2248,7 @@ worker_max_turns = 7 } #[test] - fn feature_flags_default_disabled_in_resolved_manifest() { + fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() { let manifest: WorkerManifest = minimal_valid().try_into().unwrap(); assert!(!manifest.feature.task.enabled); assert!(!manifest.feature.memory.profile.enabled); @@ -2240,10 +2256,106 @@ worker_max_turns = 7 assert!(!manifest.feature.sub_worker.enabled); assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.manage_workdir.enabled); + assert!(manifest.feature.workdir_catalog.enabled); assert!(!manifest.feature.ticket.enabled); assert!(!manifest.feature.merge_request.any()); } + #[test] + fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() { + assert!(FeatureConfig::default().workdir_catalog.enabled); + assert!(FeatureConfigPartial::default().workdir_catalog.is_none()); + for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] { + let partial = WorkerManifestConfig::from_toml(source).unwrap(); + let feature = FeatureConfig::from(partial.feature); + assert!(feature.workdir_catalog.enabled, "{source}"); + let value: toml::Value = toml::from_str(source).unwrap(); + let resolved: FeatureConfig = serde_json::from_value( + serde_json::to_value( + value + .get("feature") + .cloned() + .unwrap_or_else(|| toml::Value::Table(Default::default())), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(feature, resolved); + } + for source in ["{}", r#"{"workdir_catalog": {}}"#] { + let feature: FeatureConfig = serde_json::from_str(source).unwrap(); + assert!(feature.workdir_catalog.enabled); + assert!(!feature.manage_workdir.enabled); + } + } + + #[test] + fn workdir_catalog_is_independent_and_merges_partial_layers() { + for catalog in [false, true] { + for manage in [false, true] { + let source = format!( + "[feature.workdir_catalog]\nenabled = {catalog}\n\ + [feature.manage_workdir]\nenabled = {manage}\n" + ); + let lower = WorkerManifestConfig::from_toml(&source).unwrap(); + let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap(); + let cfg = minimal_valid().merge(lower.clone()).merge(empty); + let manifest: WorkerManifest = cfg.try_into().unwrap(); + assert_eq!(manifest.feature.workdir_catalog.enabled, catalog); + assert_eq!(manifest.feature.manage_workdir.enabled, manage); + + let override_source = + format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog); + let upper = WorkerManifestConfig::from_toml(&override_source).unwrap(); + let manifest: WorkerManifest = minimal_valid() + .merge(lower) + .merge(upper) + .try_into() + .unwrap(); + assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog); + assert_eq!(manifest.feature.manage_workdir.enabled, manage); + } + } + } + + #[test] + fn workdir_catalog_roundtrips_config_and_persisted_snapshots() { + for enabled in [false, true] { + let mut cfg = minimal_valid(); + cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial { + enabled: Some(enabled), + }); + let manifest: WorkerManifest = cfg.try_into().unwrap(); + let partial = FeatureConfigPartial::from(manifest.feature.clone()); + assert_eq!( + partial.workdir_catalog.as_ref().unwrap().enabled, + Some(enabled) + ); + let serialized = toml::to_string(&partial).unwrap(); + let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap(); + assert_eq!(FeatureConfig::from(partial), manifest.feature); + let serialized = serde_json::to_value(&manifest.feature).unwrap(); + assert_eq!(serialized["workdir_catalog"]["enabled"], enabled); + let feature: FeatureConfig = serde_json::from_value(serialized).unwrap(); + assert_eq!(feature, manifest.feature); + + let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap(); + let restored = + crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap(); + assert_eq!(restored.feature.workdir_catalog.enabled, enabled); + assert!(!restored.feature.manage_workdir.enabled); + + let mut legacy = snapshot; + legacy["manifest"]["feature"] + .as_object_mut() + .unwrap() + .remove("workdir_catalog"); + let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap(); + assert!(restored.feature.workdir_catalog.enabled); + assert!(!restored.feature.manage_workdir.enabled); + } + } + #[test] fn from_toml_parses_explicit_feature_flags() { let cfg = WorkerManifestConfig::from_toml( diff --git a/crates/manifest/src/defaults.rs b/crates/manifest/src/defaults.rs index bf4755b82..8b5b9619e 100644 --- a/crates/manifest/src/defaults.rs +++ b/crates/manifest/src/defaults.rs @@ -6,6 +6,10 @@ //! `pub const`. Both paths read from this module, so changing a //! default requires editing exactly one line. +/// Read-only Repository/Workdir/attachment reference discovery in WIP mode. +/// This enables a reference provider, not normal Tools or any authority grant. +pub const WORKDIR_CATALOG_ENABLED: bool = true; + /// Byte-size cap applied to any tool's `content` output when no /// per-tool override is set. See [`crate::ToolOutputLimits`]. pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024; diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 62634b34f..b37ce3b9a 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -65,8 +65,9 @@ pub struct WorkerManifest { /// permission layer is disabled and tool calls run as before. #[serde(default)] pub permissions: Option, - /// Explicit built-in feature/tool-surface enablement. Omitted feature flags - /// resolve disabled so Profile authors choose the exposed built-in surfaces. + /// Built-in feature/tool-surface enablement. Omitted tool features resolve + /// disabled; the read-only WIP workdir catalog defaults enabled without + /// granting authority or registering normal Tools. #[serde(default)] pub feature: FeatureConfig, /// Explicit external Model Context Protocol provider configuration. This @@ -131,6 +132,15 @@ pub struct FeatureConfig { pub objective: FeatureFlagConfig, #[serde(default)] pub manage_workdir: FeatureFlagConfig, + /// Read-only Repository/Workdir/attachment reference discovery. Defaults + /// enabled, is installed only in WIP mode, and exposes no normal Tools. + /// Independent of `manage_workdir`; permissions and host authority remain + /// separate, and discovering a reference grants no attachment or file access. + #[serde( + default = "default_workdir_catalog", + deserialize_with = "deserialize_workdir_catalog" + )] + pub workdir_catalog: FeatureFlagConfig, #[serde(default)] pub ticket: TicketFeatureConfig, #[serde(default)] @@ -139,6 +149,22 @@ pub struct FeatureConfig { pub orchestration: FeatureFlagConfig, } +fn default_workdir_catalog() -> FeatureFlagConfig { + FeatureFlagConfig { + enabled: defaults::WORKDIR_CATALOG_ENABLED, + } +} + +fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?; + Ok(FeatureFlagConfig { + enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED), + }) +} + impl Default for FeatureConfig { fn default() -> Self { Self { @@ -153,6 +179,7 @@ impl Default for FeatureConfig { workspace_worker_discovery: FeatureFlagConfig::disabled(), objective: FeatureFlagConfig::disabled(), manage_workdir: FeatureFlagConfig::disabled(), + workdir_catalog: default_workdir_catalog(), ticket: TicketFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(), orchestration: FeatureFlagConfig::disabled(), diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index c71fe2e7b..aba2a5a9f 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -181,6 +181,8 @@ pub fn validate_profile_execution_target( if feature.flow.enabled { requirements.insert(WorkspaceAuthorityRequirement::Flow); } + // workdir_catalog is a WIP-only reference provider, not Workspace authority + // or normal Tools. It must not prevent standalone Tools-mode launches. if feature.manage_workdir.enabled { requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir); } @@ -1586,6 +1588,59 @@ mod tests { assert!(!resolved.manifest.feature.manage_workdir.enabled); } + #[test] + fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() { + let tmp = TempDir::new().unwrap(); + for catalog in [None, Some(false), Some(true)] { + let mut artifact = serde_json::json!({ + "worker": { "mode": "tools" }, + "feature": { "manage_workdir": { "enabled": false } }, + "permissions": { "default_action": "deny" } + }); + if let Some(enabled) = catalog { + artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled }); + } + let resolved = resolve_profile_artifact_value( + artifact, + ProfileSource::Archive { + archive_id: "catalog-test".into(), + source: "test".into(), + }, + tmp.path(), + "catalog-worker", + ) + .unwrap(); + let manifest = resolved.manifest; + assert_eq!( + manifest.feature.workdir_catalog.enabled, + catalog.unwrap_or(true) + ); + assert!(!manifest.feature.manage_workdir.enabled); + assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools); + assert!(manifest.scope.allow.is_empty()); + assert!(manifest.delegation_scope.allow.is_empty()); + assert_eq!( + manifest.permissions.as_ref().unwrap().default_action, + crate::ToolPermissionAction::Deny + ); + validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone) + .unwrap(); + + let mut manage_manifest = manifest; + manage_manifest.feature.manage_workdir.enabled = true; + let error = validate_profile_execution_target( + &manage_manifest, + ProfileExecutionTarget::Standalone, + ) + .unwrap_err(); + assert!(matches!( + error, + ProfileError::UnsupportedExecutionTarget { requirements, .. } + if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir] + )); + } + } + #[test] fn standalone_rejects_profiles_that_require_workspace_authority() { let tmp = TempDir::new().unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 9b959f8b6..03d23245e 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1504,7 +1504,13 @@ where ), ); } - if feature_config.manage_workdir.enabled { + let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip; + let mut workdir_wip_feature = None; + if feature_config.manage_workdir.enabled + || (wip_mode + && feature_config.workdir_catalog.enabled + && worker.workspace_client_handle().is_available()) + { // Workdir lifecycle is Workspace control-plane authority. The Worker // receives only the injected WorkspaceClient and never Runtime URLs, // repository paths, materializer handles, or cleanup sessions. @@ -1520,7 +1526,7 @@ where } let shutdown_registry = spawned_registry.clone(); let reopen_registry = spawned_registry.clone(); - feature_registry.add_module( + let module = crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle( workspace_client, worker.workdir_sessions(), @@ -1529,8 +1535,11 @@ where Box::pin(async move { child_registry.shutdown_internal().await }) }), Arc::new(move || reopen_registry.reopen_internal()), - ), - ); + ); + if feature_config.manage_workdir.enabled { + feature_registry.add_module(module.clone()); + } + workdir_wip_feature = Some(module); } if feature_config.workspace_worker_discovery.enabled { let workspace_client = worker.workspace_client_handle(); @@ -1585,9 +1594,23 @@ where } let host_worker_observation_provider = worker.worker_observation_provider(); - let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip; let wip_permissions = worker.manifest().permissions.clone(); let mut wip_mount_registry = crate::wip::WipMountRegistry::new(); + if wip_mode && let Some(feature) = &workdir_wip_feature { + crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( + &mut wip_mount_registry, + feature, + feature_config.workdir_catalog.enabled, + feature_config.manage_workdir.enabled, + wip_permissions.clone(), + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Workdir WIP projection: {error}"), + ) + })?; + } { let workspace_client = worker.workspace_client_handle(); let engine = worker.engine_mut(); diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 54514dd75..537739187 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`]. +pub mod wip; + use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature { session_router: Arc, before_workdir_release: Option, after_workdir_attach: Option, + attachment_mutation_lock: Arc>, } impl std::fmt::Debug for ManageWorkdirFeature { @@ -86,6 +89,7 @@ impl ManageWorkdirFeature { session_router: Arc::new(workdir::WorkdirSessionRouter::new()), before_workdir_release: None, after_workdir_attach: None, + attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())), } } @@ -100,8 +104,19 @@ impl ManageWorkdirFeature { session_router, before_workdir_release: Some(before_workdir_release), after_workdir_attach: Some(after_workdir_attach), + attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())), } } + fn backend(&self) -> WorkspaceHttpWorkdirBackend { + let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()) + .with_session_router(self.session_router.clone()) + .with_child_lifecycle( + self.before_workdir_release.clone(), + self.after_workdir_attach.clone(), + ); + backend.attachment_mutation_lock = self.attachment_mutation_lock.clone(); + backend + } } pub fn manage_workdir_feature(client: Arc) -> ManageWorkdirFeature { @@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature { } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { - let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()) - .with_session_router(self.session_router.clone()) - .with_child_lifecycle( - self.before_workdir_release.clone(), - self.after_workdir_attach.clone(), - ); + let backend = self.backend(); for (name, definition) in [ ( LIST_TOOL, @@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend { } fn detach(&self, alias: &str) -> Result { + self.detach_expected(alias, None) + } + + fn detach_expected( + &self, + alias: &str, + expected_connection_id: Option<&str>, + ) -> Result { let workspace_id = encode_path_segment(self.workspace_id()?); let alias_path = encode_path_segment(alias); + let suffix = expected_connection_id + .map(|id| format!("?expected_connection_id={}", encode_path_segment(id))) + .unwrap_or_default(); let response = self.execute_json::(WorkspaceRequest { method: WorkspaceRequestMethod::Delete, - path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"), + path: format!( + "/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}" + ), body: None, })?; workdir_output( @@ -622,6 +645,72 @@ impl WorkspaceHttpWorkdirBackend { ) } + async fn detach_managed( + &self, + alias: &str, + expected_connection_id: Option<&str>, + ) -> Result { + let _mutation_guard = self.attachment_mutation_lock.lock().await; + let alias = validate_identity(alias, DETACH_TOOL, "alias")?; + if let Some(expected) = expected_connection_id { + // Under the shared local mutation lock, check lifetime before any + // local route exclusion or child cleanup. Backend DELETE still performs + // the authoritative conditional mutation under its lifecycle lock. + let workspace = encode_path_segment(self.workspace_id()?); + let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!( + "/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}", + encode_path_segment(expected) + )))?; + let current = page + .get("items") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("connection_id") + .and_then(serde_json::Value::as_str) + == Some(expected) + && item.get("alias").and_then(serde_json::Value::as_str) == Some(alias) + }) + }); + if !current { + return Err(ToolError::StructuredConflict { + code: "workspace_http_409".into(), + message: "attachment lifetime changed before local cleanup".into(), + }); + } + } + let alias_key = workdir::WorkdirAttachmentAlias::new(alias) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + self.session_router + .begin_detach(&alias_key) + .await + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if let Some(before_release) = &self.before_workdir_release + && let Err(error) = before_release().await + { + self.session_router.cancel_detach(&alias_key); + return Err(ToolError::ExecutionFailed(format!( + "stop Internal SubWorkers before Workdir detach: {error}" + ))); + } + let result = match self.detach_expected(alias, expected_connection_id) { + Ok(result) => result, + Err(error) => { + self.session_router.cancel_detach(&alias_key); + return Err(error); + } + }; + self.session_router + .finish_detach(&alias_key) + .await + .map_err(|error| { + ToolError::ExecutionFailed(format!( + "detach Workdir session route after Backend release: {error}" + )) + })?; + Ok(result) + } + fn execute_json Deserialize<'de>>( &self, request: WorkspaceRequest, @@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool { result } WorkdirOperation::Detach => { - let _mutation_guard = self.backend.attachment_mutation_lock.lock().await; let input = parse_input::(input_json)?; - let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?; - let alias_key = workdir::WorkdirAttachmentAlias::new(alias) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - self.backend - .session_router - .begin_detach(&alias_key) - .await - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if let Some(before_release) = &self.backend.before_workdir_release - && let Err(error) = before_release().await - { - self.backend.session_router.cancel_detach(&alias_key); - return Err(ToolError::ExecutionFailed(format!( - "stop Internal SubWorkers before Workdir detach: {error}" - ))); - } - let result = match self.backend.detach(alias) { - Ok(result) => result, - Err(error) => { - self.backend.session_router.cancel_detach(&alias_key); - return Err(error); - } - }; - self.backend - .session_router - .finish_detach(&alias_key) - .await - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "detach Workdir session route after Backend release: {error}" - )) - })?; - Ok(result) + self.backend.detach_managed(&input.alias, None).await } + WorkdirOperation::Delete => self .backend .delete(parse_input::(input_json)?), @@ -747,6 +804,12 @@ fn parse_input Deserialize<'de>>(input: &str) -> Result Deserialize<'de>>( response: WorkspaceResponse, ) -> Result { + if matches!(response.status, 401 | 403 | 404 | 409) { + return Err(ToolError::StructuredConflict { + code: format!("workspace_http_{}", response.status), + message: "Workspace lifecycle authority rejected the request before execution".into(), + }); + } if !response.is_success() { return Err(ToolError::ExecutionFailed(format!( "Workspace Workdir API returned HTTP {}: {}", diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs new file mode 100644 index 000000000..21ebef28a --- /dev/null +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -0,0 +1,1545 @@ +//! Host-owned Repository, Workdir and Worker attachment Objects. +//! Inventory is resolved from Workspace authority on demand, not a second catalog. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use manifest::{ToolPermissionAction, ToolPermissionConfig}; +use serde_json::{Value as Json, json}; +use sha2::{Digest, Sha256}; +use wip_protocol::{ + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, +}; + +use super::*; +use crate::permission::permission_action_for; +use crate::wip::{ + WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, + WipDynamicOperationContribution, WipDynamicOperationResolver, WipMountError, WipMountRegistry, + WipOperationContribution, WipOperationError, WipOperationHandler, WipOperationOutput, + WipProjection, WipProjectionKind, json_to_wip, wip_to_json, +}; + +const REPOSITORIES: &str = "/repositories"; +const WORKDIRS: &str = "/workdirs"; +const ATTACHMENTS: &str = "/workdir-attachments"; + +#[derive(Clone, Copy, Debug)] +enum Domain { + Repository, + Workdir, + Attachment, +} + +impl Domain { + fn route(self) -> &'static str { + match self { + Self::Repository => REPOSITORIES, + Self::Workdir => WORKDIRS, + Self::Attachment => ATTACHMENTS, + } + } + fn owner(self) -> &'static str { + match self { + Self::Repository => "repository", + Self::Workdir => "workdir", + Self::Attachment => "workdir-attachment", + } + } + fn list_permission(self) -> &'static str { + match self { + Self::Repository => "RepositoryList", + Self::Workdir => LIST_TOOL, + Self::Attachment => "WorkdirAttachmentList", + } + } + fn read_permission(self) -> &'static str { + match self { + Self::Repository => "RepositoryRead", + Self::Workdir => "WorkdirRead", + Self::Attachment => "WorkdirAttachmentRead", + } + } + fn identity_field(self) -> &'static str { + match self { + Self::Repository => "repository_key", + Self::Workdir => "working_directory_id", + Self::Attachment => "connection_id", + } + } +} + +#[derive(Clone)] +struct Provider { + backend: WorkspaceHttpWorkdirBackend, + permissions: Option, + catalog: bool, + manage: bool, +} + +/// Register Object ownership once, then contribute independent management operations. +/// The passed Feature shares its session router, mutation lock and child lifecycle with Tools. +pub fn mount_workspace_workdir_wip( + registry: &mut WipMountRegistry, + feature: &ManageWorkdirFeature, + catalog: bool, + manage: bool, + permissions: Option, +) -> Result<(), WipMountError> { + let provider = Provider { + backend: feature.backend(), + permissions, + catalog, + manage, + }; + for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] { + let namespace = + registry.allocate_namespace(domain.owner(), domain.route().trim_start_matches('/'))?; + let interface = format!("yoi.{}/collection/v1", domain.owner()); + let handler = Arc::new(CollectionHandler { + provider: provider.clone(), + domain, + }); + registry.mount(WipProjection { + route: namespace.root().into(), + capability: format!("{}:collection", domain.owner()), + kind: WipProjectionKind::Native, + object: object( + domain.route(), + domain.route(), + &interface, + &json!({"kind": "collection"}), + ), + interface: interface.clone(), + descriptor: collection_descriptor(domain, false), + interface_validator: None, + handler: handler.clone(), + })?; + if manage && matches!(domain, Domain::Workdir) { + registry.contribute_operations(WipOperationContribution { + route: domain.route().into(), + contributor: FEATURE_ID.into(), + interface, + descriptor: collection_descriptor(domain, true), + handler, + })?; + } + let item_interface = format!("yoi.{}/item/v1", domain.owner()); + let resolver = Arc::new(ItemResolver { + provider: provider.clone(), + domain, + }); + registry.mount_dynamic(WipDynamicMount { + collection_route: domain.route().into(), + capability: format!("{}:item", domain.owner()), + interface: item_interface.clone(), + descriptor: item_descriptor(domain, false), + interface_validator: None, + resolver: resolver.clone(), + })?; + if manage && !matches!(domain, Domain::Repository) { + registry.contribute_dynamic_operations(WipDynamicOperationContribution { + collection_route: domain.route().into(), + contributor: FEATURE_ID.into(), + interface: item_interface, + descriptor: item_descriptor(domain, true), + resolver, + })?; + } + } + registry.replace_compatibility_tools( + WORKDIRS, + [LIST_TOOL, CREATE_TOOL, ATTACH_TOOL, DELETE_TOOL], + )?; + registry.replace_compatibility_tools(ATTACHMENTS, [DETACH_TOOL])?; + Ok(()) +} + +impl Provider { + fn allowed(&self, name: &str, input: &Json) -> bool { + self.permissions + .as_ref() + .is_none_or(|p| permission_action_for(p, name, input) == ToolPermissionAction::Allow) + } + // An argument-dependent permission cannot be decided until call. Advertise only + // if the policy can allow this tool, then recheck the exact route-bound input. + fn may_allow(&self, name: &str) -> bool { + let Some(p) = &self.permissions else { + return true; + }; + for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(name)) { + if rule.action == ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + p.default_action == ToolPermissionAction::Allow + } + fn authorize(&self, name: &str, input: &Json) -> Result<(), WipOperationError> { + if self.allowed(name, input) { + Ok(()) + } else { + Err(failure( + ProtocolErrorCode::PermissionDenied, + format!("permission denied for `{name}`; approval-required policies fail closed"), + )) + } + } + fn request(&self, path: &str) -> Result { + let workspace = self.backend.workspace_id().map_err(map_tool_error)?; + let response = self + .backend + .client + .execute(WorkspaceRequest::get(format!( + "/api/w/{}/{path}", + encode_path_segment(workspace) + ))) + .map_err(|_| { + WipOperationError::OutcomeUnknown("Workspace inventory request unavailable".into()) + })?; + if !response.is_success() { + return Err(match response.status { + 401 | 403 => failure( + ProtocolErrorCode::PermissionDenied, + "Workspace authority denied this inventory request", + ), + 404 => failure( + ProtocolErrorCode::NotFound, + "inventory Object no longer exists", + ), + 409 => failure( + ProtocolErrorCode::ValidatorMismatch, + "inventory observation is stale; rediscover", + ), + _ => WipOperationError::OutcomeUnknown("Workspace inventory request failed".into()), + }); + } + serde_json::from_str(&response.body).map_err(|_| { + WipOperationError::OutcomeUnknown("invalid Workspace inventory response".into()) + }) + } + fn inventory(&self, domain: Domain) -> Result, WipOperationError> { + let response = self.request(match domain { + Domain::Repository => "repositories", + Domain::Workdir => "working-directories", + Domain::Attachment => "workers/self/workdir-attachments", + })?; + response + .get("items") + .and_then(Json::as_array) + .cloned() + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("invalid Workspace inventory collection".into()) + }) + } + fn raw_item(&self, domain: Domain, id: &str) -> Result { + match domain { + Domain::Attachment => self + .request(&format!( + "workers/self/workdir-attachments?limit=1&connection_id={}", + encode_path_segment(id) + ))? + .get("items") + .and_then(Json::as_array) + .cloned() + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("invalid attachment lookup response".into()) + })? + .into_iter() + .find(|v| v.get("connection_id").and_then(Json::as_str) == Some(id)) + .ok_or_else(|| { + failure( + ProtocolErrorCode::NotFound, + "attachment lifetime no longer exists; list current attachments", + ) + }), + Domain::Repository | Domain::Workdir => { + let prefix = if matches!(domain, Domain::Repository) { + "repositories" + } else { + "working-directories" + }; + self.request(&format!("{prefix}/{}", encode_path_segment(id)))? + .get("item") + .cloned() + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("invalid Workspace inventory item".into()) + }) + } + } + } + fn read_input(&self, domain: Domain, id: &str) -> Json { + json!({domain.identity_field(): id}) + } + fn projected(&self, domain: Domain, raw: &Json) -> Result { + let id = raw + .get(domain.identity_field()) + .and_then(Json::as_str) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("inventory identity missing".into()) + })?; + let mut result = serde_json::Map::new(); + let fields: &[&str] = match domain { + Domain::Repository => &[ + "repository_key", + "kind", + "provider", + "display_name", + "description", + "default_selector", + ], + Domain::Workdir => &[ + "working_directory_id", + "display_name", + "creation_selector", + "creation_ref", + "creation_tree", + "current_selector", + "current_ref", + "current_tree", + "status", + "cleanliness", + "occupied_by", + ], + Domain::Attachment => &[ + "connection_id", + "alias", + "working_directory_id", + "capabilities", + ], + }; + for field in fields { + if let Some(value) = raw.get(*field) { + result.insert((*field).into(), value.clone()); + } + } + if matches!(domain, Domain::Repository) { + // A missing default is explicit. No guessed main/ref, source URI or credentials. + result.entry("default_selector").or_insert(Json::Null); + } + if matches!(domain, Domain::Workdir) { + if let Some(source) = raw.get("source") { + let kind = source + .get("kind") + .and_then(Json::as_str) + .unwrap_or("unknown"); + let mut projected = json!({"kind": kind}); + if kind == "repository" + && let Some(key) = source.get("repository_key") + { + projected["repository_key"] = key.clone(); + projected["repository_path"] = json!(item_path( + Domain::Repository, + key.as_str().unwrap_or_default() + )); + } + result.insert("source".into(), projected); + } + } + if matches!(domain, Domain::Attachment) { + if let Some(id) = raw.get("working_directory_id").and_then(Json::as_str) { + result.insert("workdir_path".into(), json!(item_path(Domain::Workdir, id))); + } + // Content projection belongs to T-699/T-695. No unconditional FS entrance. + } + result.insert("path".into(), json!(item_path(domain, id))); + Ok(Json::Object(result)) + } +} + +struct CollectionHandler { + provider: Provider, + domain: Domain, +} +#[async_trait] +impl WipOperationHandler for CollectionHandler { + fn contextual_interface(&self) -> bool { + true + } + fn object_validator(&self) -> Option> { + let mut digest = Sha256::new(); + digest.update(self.domain.route()); + match self.provider.inventory(self.domain) { + Ok(items) => { + for raw in items { + if let Some(id) = raw.get(self.domain.identity_field()).and_then(Json::as_str) + && self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, id), + ) + && let Ok(value) = self.provider.projected(self.domain, &raw) + { + digest.update(value.to_string()); + digest.update([0]); + } + } + } + Err(_) => digest.update(b"inventory-unavailable"), + } + Some(digest.finalize().to_vec()) + } + fn is_visible(&self) -> bool { + (self.provider.catalog + && (self.provider.may_allow(self.domain.list_permission()) + || self.provider.may_allow(self.domain.read_permission()))) + || (self.provider.manage + && matches!(self.domain, Domain::Workdir) + && self.provider.may_allow(CREATE_TOOL)) + } + fn operation_available(&self, operation: &str) -> bool { + match operation { + "list" => { + self.provider.catalog && self.provider.may_allow(self.domain.list_permission()) + } + "create" => { + self.provider.manage + && matches!(self.domain, Domain::Workdir) + && self.provider.may_allow(CREATE_TOOL) + } + _ => false, + } + } + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let input = argument_json(arguments)?; + if !self.operation_available(operation) { + return Err(failure( + ProtocolErrorCode::PermissionDenied, + "operation is not available", + )); + } + let value = match operation { + "list" => { + self.provider + .authorize(self.domain.list_permission(), &input)?; + let limit = page_limit(&input)?; + let cursor = input.get("cursor").and_then(Json::as_str); + if matches!(self.domain, Domain::Attachment) { + let offset = cursor + .map(|c| { + c.strip_prefix("offset:") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| { + failure( + ProtocolErrorCode::InvalidArguments, + "invalid attachment page cursor", + ) + }) + }) + .transpose()? + .unwrap_or(0); + let page = self.provider.request(&format!( + "workers/self/workdir-attachments?limit={limit}&offset={offset}" + ))?; + let raws = page.get("items").and_then(Json::as_array).ok_or_else(|| { + WipOperationError::OutcomeUnknown("invalid attachment page".into()) + })?; + let mut items = Vec::new(); + for raw in raws { + let id = + raw.get("connection_id") + .and_then(Json::as_str) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown( + "attachment identity missing".into(), + ) + })?; + if self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, id), + ) { + items.push(self.provider.projected(self.domain, raw)?); + } + } + let next = page + .get("next_offset") + .and_then(Json::as_u64) + .map(|n| format!("offset:{n}")); + return native( + json!({"empty":items.is_empty(), "items":items, "has_more":next.is_some(), "next_cursor":next, "limit":limit}), + ); + } + let mut items = Vec::new(); + for raw in self.provider.inventory(self.domain)? { + let id = raw + .get(self.domain.identity_field()) + .and_then(Json::as_str) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown("inventory identity missing".into()) + })?; + if self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, id), + ) { + items.push(self.provider.projected(self.domain, &raw)?); + } + } + items.sort_by(|a, b| { + a[self.domain.identity_field()] + .as_str() + .cmp(&b[self.domain.identity_field()].as_str()) + }); + let total_visible = items.len(); + let remaining: Vec<_> = items + .into_iter() + .filter(|v| { + cursor.is_none_or(|c| { + v[self.domain.identity_field()] + .as_str() + .is_some_and(|id| id > c) + }) + }) + .collect(); + let has_more = remaining.len() > limit; + let items: Vec<_> = remaining.into_iter().take(limit).collect(); + let next_cursor = if has_more { + items + .last() + .map(|v| v[self.domain.identity_field()].clone()) + } else { + None + }; + json!({"items": items, "empty": total_visible == 0, "has_more": has_more, "next_cursor": next_cursor, "limit": limit}) + } + "create" => { + self.provider.authorize(CREATE_TOOL, &input)?; + let tool = WorkspaceHttpWorkdirTool { + backend: self.provider.backend.clone(), + operation: WorkdirOperation::Create, + }; + let output = tool + .execute(&input.to_string(), context.execution) + .await + .map_err(map_tool_error)?; + let raw = output_json(output)?; + json!({"item": self.provider.projected(Domain::Workdir, &raw["item"])?, "runtime_id":raw["runtime_id"]}) + } + _ => { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "operation not published", + )); + } + }; + native(value) + } +} + +struct ItemResolver { + provider: Provider, + domain: Domain, +} +impl WipDynamicItemResolver for ItemResolver { + fn resolve(&self, reference: &str) -> Option { + let id = decode_identity(reference)?; + if !self.provider.catalog + || !self.provider.allowed( + self.domain.read_permission(), + &self.provider.read_input(self.domain, &id), + ) + { + return None; + } + let raw = self.provider.raw_item(self.domain, &id).ok()?; + let projected = self.provider.projected(self.domain, &raw).ok()?; + let interface = format!("yoi.{}/item/v1", self.domain.owner()); + Some(WipDynamicItem { + object: object(&item_path(self.domain, &id), &id, &interface, &projected), + handler: Arc::new(ItemHandler { + provider: self.provider.clone(), + domain: self.domain, + id, + }), + }) + } +} +impl WipDynamicOperationResolver for ItemResolver { + fn handler(&self, reference: &str) -> Arc { + Arc::new(ItemHandler { + provider: self.provider.clone(), + domain: self.domain, + id: decode_identity(reference).unwrap_or_default(), + }) + } +} +struct ItemHandler { + provider: Provider, + domain: Domain, + id: String, +} +impl ItemHandler { + fn input(&self, arguments: &BTreeMap) -> Result { + let mut input = argument_json(arguments)?; + // Defense in depth in addition to descriptor validation, including direct handler calls. + if input.get(self.domain.identity_field()).is_some() + || input.get("alias").is_some() && matches!(self.domain, Domain::Attachment) + { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "route-bound identity cannot be overridden", + )); + } + input[self.domain.identity_field()] = json!(self.id); + Ok(input) + } + fn supports(&self, operation: &str) -> bool { + let Ok(raw) = self.provider.raw_item(self.domain, &self.id) else { + return false; + }; + match operation { + "read" => true, + "attach" => { + matches!(self.domain, Domain::Workdir) + && matches!( + raw.get("source") + .and_then(|v| v.get("kind")) + .and_then(Json::as_str), + Some("repository" | "external_grant") + ) + && raw.get("status").and_then(Json::as_str) == Some("active") + } + "delete" => { + matches!(self.domain, Domain::Workdir) + && raw.get("cleanup_target").is_some_and(|v| !v.is_null()) + } + "detach" => matches!(self.domain, Domain::Attachment), + _ => false, + } + } +} +#[async_trait] +impl WipOperationHandler for ItemHandler { + fn contextual_interface(&self) -> bool { + true + } + fn operation_available(&self, operation: &str) -> bool { + let (enabled, permission) = match operation { + "read" => (self.provider.catalog, self.domain.read_permission()), + "attach" => (self.provider.manage, ATTACH_TOOL), + "delete" => (self.provider.manage, DELETE_TOOL), + "detach" => (self.provider.manage, DETACH_TOOL), + _ => return false, + }; + enabled && self.provider.may_allow(permission) && self.supports(operation) + } + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let mut input = self.input(arguments)?; + // Recheck reference permission as well as mutation permission on every call. + self.provider.authorize( + self.domain.read_permission(), + &self.provider.read_input(self.domain, &self.id), + )?; + if !self.operation_available(operation) { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "target or subject does not support this operation", + )); + } + let value = match operation { + "read" => self + .provider + .projected(self.domain, &self.provider.raw_item(self.domain, &self.id)?)?, + "attach" | "delete" => { + let (permission, op) = if operation == "attach" { + (ATTACH_TOOL, WorkdirOperation::Attach) + } else { + (DELETE_TOOL, WorkdirOperation::Delete) + }; + self.provider.authorize(permission, &input)?; + let output = WorkspaceHttpWorkdirTool { + backend: self.provider.backend.clone(), + operation: op, + } + .execute(&input.to_string(), context.execution) + .await + .map_err(map_tool_error)?; + let raw = output_json(output)?; + if operation == "attach" { + let mut result = raw; + result["workdir_path"] = json!(item_path(Domain::Workdir, &self.id)); + result["attachments_path"] = json!(ATTACHMENTS); + // Backend attachment list supplies the durable lifetime identity. + result + } else { + raw + } + } + "detach" => { + let raw = self.provider.raw_item(Domain::Attachment, &self.id)?; + let alias = raw.get("alias").and_then(Json::as_str).ok_or_else(|| { + WipOperationError::OutcomeUnknown("attachment alias missing".into()) + })?; + input + .as_object_mut() + .expect("route input is an object") + .remove("connection_id"); + input["alias"] = json!(alias); + self.provider.authorize(DETACH_TOOL, &input)?; + output_json( + self.provider + .backend + .detach_managed(alias, Some(&self.id)) + .await + .map_err(map_tool_error)?, + )? + } + _ => { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "operation not published", + )); + } + }; + native(value) + } +} + +fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object { + let mut digest = Sha256::new(); + digest.update(path); + digest.update([0]); + digest.update(value.to_string()); + Object { + name: path.rsplit('/').next().unwrap_or_default().into(), + description: Some(format!("Workspace authority Object: {identity}")), + interfaces: vec![interface.into()], + r#ref: Some(identity.into()), + validator: Some(digest.finalize().to_vec()), + } +} +fn native(value: Json) -> Result { + Ok(WipOperationOutput::native( + json_to_wip(&value).map_err(WipOperationError::OutcomeUnknown)?, + )) +} +fn failure(code: ProtocolErrorCode, message: impl Into) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +fn map_tool_error(error: ToolError) -> WipOperationError { + match error { + ToolError::InvalidArgument(message) => { + failure(ProtocolErrorCode::InvalidArguments, message) + } + ToolError::Cancelled(output) => WipOperationError::Cancelled(output), + ToolError::Interrupted(output) => WipOperationError::Interrupted(output), + ToolError::StructuredConflict { code, .. } => { + let (code, message) = match code.as_str() { + "workspace_http_401" | "workspace_http_403" => ( + ProtocolErrorCode::PermissionDenied, + "Workspace authority denied the lifecycle request", + ), + "workspace_http_404" => (ProtocolErrorCode::NotFound, "Object no longer exists"), + "workspace_http_409" => ( + ProtocolErrorCode::ValidatorMismatch, + "connection or lifecycle observation is stale; rediscover", + ), + _ => { + return WipOperationError::OutcomeUnknown( + "Workspace lifecycle conflict with unknown execution outcome".into(), + ); + } + }; + failure(code, message) + } + // Never expose raw backend errors (which may include host/provider details). + _ => WipOperationError::OutcomeUnknown( + "Workspace lifecycle operation failed; outcome unknown; do not automatically retry" + .into(), + ), + } +} +fn output_json(output: ToolOutput) -> Result { + serde_json::from_str(output.content.as_deref().unwrap_or_default()) + .map_err(|_| WipOperationError::OutcomeUnknown("invalid lifecycle result".into())) +} +fn argument_json(arguments: &BTreeMap) -> Result { + let mut values = serde_json::Map::new(); + for (name, value) in arguments { + if !matches!(value, Value::Unit) { + values.insert( + name.clone(), + wip_to_json(value).map_err(|e| failure(ProtocolErrorCode::InvalidArguments, e))?, + ); + } + } + Ok(Json::Object(values)) +} +fn page_limit(input: &Json) -> Result { + let limit = input.get("limit").and_then(Json::as_u64).unwrap_or(50); + if !(1..=100).contains(&limit) { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "limit must be 1..=100", + )); + } + Ok(limit as usize) +} + +// Readable ASCII identities remain unchanged. Every other UTF-8 identity uses a +// canonical ~hex segment; the escape marker is itself always escaped. This is +// injective, does not exclude existing keys, and never decodes a second route. +fn encode_identity(id: &str) -> String { + if !id.is_empty() + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + id.into() + } else { + format!( + "~{}", + id.as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + } +} +fn decode_identity(segment: &str) -> Option { + let id = if let Some(hex) = segment.strip_prefix('~') { + if hex.len() % 2 != 0 || hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let bytes = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16)) + .collect::, _>>() + .ok()?; + String::from_utf8(bytes).ok()? + } else { + segment.into() + }; + (encode_identity(&id) == segment && !id.is_empty()).then_some(id) +} +fn item_path(domain: Domain, id: &str) -> String { + format!("{}/{}", domain.route(), encode_identity(id)) +} +fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } +} +fn operation( + name: &str, + summary: &str, + parameters: Vec, +) -> OperationDeclaration { + OperationDeclaration { + name: name.into(), + documentation: Some(Documentation { + summary: summary.into(), + details: None, + }), + parameters, + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Json, + }, + } +} +fn descriptor( + domain: Domain, + item: bool, + operations: Vec, +) -> InterfaceDescriptor { + InterfaceDescriptor { format: INTERFACE_FORMAT_V1.into(), documentation: Some(Documentation { + summary: format!("Workspace {} {}", domain.owner(), if item { "item" } else { "collection" }), + details: Some("Object identity is route-bound. Feature implementation, target capability and subject permission are independent. Inventory does not grant content or command access.".into()), + }), types: Vec::new(), operations } +} +fn collection_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor { + descriptor( + domain, + false, + if management { + vec![operation( + "create", + CREATE_DESCRIPTION, + vec![ + parameter("repository_key", true, TypeExpr::String), + parameter("selector", false, TypeExpr::String), + parameter("runtime_id", false, TypeExpr::String), + parameter("display_name", false, TypeExpr::String), + ], + )] + } else { + vec![operation( + "list", + "List a bounded page of visible Objects with item paths", + vec![ + parameter("limit", false, TypeExpr::Integer), + parameter("cursor", false, TypeExpr::String), + ], + )] + }, + ) +} +fn item_descriptor(domain: Domain, management: bool) -> InterfaceDescriptor { + descriptor( + domain, + true, + if management { + match domain { + Domain::Workdir => vec![ + operation( + "attach", + ATTACH_DESCRIPTION, + vec![parameter("alias", true, TypeExpr::String)], + ), + operation( + "delete", + DELETE_DESCRIPTION, + vec![parameter("reason", true, TypeExpr::String)], + ), + ], + Domain::Attachment => vec![operation("detach", DETACH_DESCRIPTION, vec![])], + Domain::Repository => vec![], + } + } else { + vec![operation( + "read", + "Read this Object through current Workspace authority", + vec![], + )] + }, + ) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use manifest::ToolPermissionRule; + use std::sync::Mutex; + + #[derive(Debug)] + pub(crate) struct CatalogClient { + pub(crate) state: Mutex, + } + #[derive(Debug)] + pub(crate) struct CatalogState { + pub(crate) repositories: Vec, + pub(crate) workdirs: Vec, + pub(crate) attachments: Vec, + pub(crate) requests: Vec, + pub(crate) denied: bool, + generation: usize, + } + impl Default for CatalogClient { + fn default() -> Self { + Self { + state: Mutex::new(CatalogState { + repositories: vec![ + json!({"repository_key":"registered-key", "kind":"git", "provider":"git", "source":{"path":"/secret/host", "credential":"SECRET"}, "git":{"remotes":["secret-uri"]}}), + ], + workdirs: vec![], + attachments: vec![], + requests: vec![], + denied: false, + generation: 0, + }), + } + } + } + fn directory(id: &str, repository: &str) -> Json { + json!({"working_directory_id":id, "source":{"kind":"repository", "repository_key":repository}, "display_name":"Review checkout", "status":"active", "cleanliness":"clean", "materializer_kind":"runtime_git_clone", "cleanup_target":{"kind":"runtime_git_clone", "repository_key":repository, "working_directory_id":id}}) + } + impl WorkspaceClient for CatalogClient { + fn workspace_id(&self) -> Option<&str> { + Some("test-workspace") + } + fn kind(&self) -> &str { + "catalog-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let mut state = self.state.lock().unwrap(); + state.requests.push(request.clone()); + if state.denied { + return Ok(WorkspaceResponse { + status: 403, + body: "SECRET /host/path".into(), + }); + } + let path = request.path.strip_prefix("/api/w/test-workspace/").unwrap(); + let body: Json = request + .body + .as_deref() + .map(|b| serde_json::from_str(b).unwrap()) + .unwrap_or(Json::Null); + let value = match (request.method, path.split('?').next().unwrap()) { + (WorkspaceRequestMethod::Get, "repositories") => { + json!({"items":state.repositories}) + } + (WorkspaceRequestMethod::Get, "working-directories") => { + json!({"items":state.workdirs}) + } + (WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => { + let query: BTreeMap<_, _> = path + .split('?') + .nth(1) + .unwrap_or_default() + .split('&') + .filter_map(|p| p.split_once('=')) + .collect(); + if let Some(expected) = query.get("connection_id") { + json!({"items":state.attachments.iter().filter(|a| a["connection_id"] == *expected).collect::>(), "next_offset":null}) + } else { + let limit = query + .get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(50); + let offset = query + .get("offset") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let items: Vec<_> = + state.attachments.iter().skip(offset).take(limit).collect(); + let next = + (offset + limit < state.attachments.len()).then_some(offset + limit); + json!({"items":items, "next_offset":next}) + } + } + (WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => { + let id = p.trim_start_matches("repositories/"); + match state + .repositories + .iter() + .find(|r| encode_path_segment(r["repository_key"].as_str().unwrap()) == id) + { + Some(r) => json!({"item":r}), + None => { + return Ok(WorkspaceResponse { + status: 404, + body: String::new(), + }); + } + } + } + (WorkspaceRequestMethod::Get, p) if p.starts_with("working-directories/") => { + let id = p.trim_start_matches("working-directories/"); + match state + .workdirs + .iter() + .find(|r| r["working_directory_id"] == id) + { + Some(r) => json!({"item":r}), + None => { + return Ok(WorkspaceResponse { + status: 404, + body: String::new(), + }); + } + } + } + (WorkspaceRequestMethod::Post, "working-directories") => { + let raw = directory("wd-created", body["repository_key"].as_str().unwrap()); + state.workdirs.push(raw.clone()); + json!({"workspace_id":"test-workspace", "runtime_id":"runtime", "item":raw, "diagnostics":[]}) + } + (WorkspaceRequestMethod::Post, "workers/self/workdir-attachments") => { + state.generation += 1; + let raw = json!({"alias":body["alias"], "working_directory_id":body["working_directory_id"], "connection_id":format!("connection-{}", state.generation), "capabilities":{"bits":63}}); + state.attachments.push(raw); + json!({"workspace_id":"test-workspace", "alias":body["alias"], "working_directory_id":body["working_directory_id"], "capabilities":{"bits":63}, "attached":true}) + } + (WorkspaceRequestMethod::Delete, p) + if p.starts_with("workers/self/workdir-attachments/") => + { + let alias = p.trim_start_matches("workers/self/workdir-attachments/"); + let expected = path + .split("expected_connection_id=") + .nth(1) + .unwrap_or_default(); + let Some(index) = state + .attachments + .iter() + .position(|r| r["alias"] == alias && r["connection_id"] == expected) + else { + return Ok(WorkspaceResponse { + status: 409, + body: "stale".into(), + }); + }; + let raw = state.attachments.remove(index); + json!({"workspace_id":"test-workspace", "alias":raw["alias"], "working_directory_id":raw["working_directory_id"], "capabilities":raw["capabilities"], "attached":false}) + } + (WorkspaceRequestMethod::Delete, p) if p.starts_with("working-directories/") => { + json!({"working_directory_id":p.trim_start_matches("working-directories/"), "disposition":"retained", "retryable":false}) + } + other => panic!("unexpected catalog request: {other:?}"), + }; + Ok(WorkspaceResponse { + status: 200, + body: value.to_string(), + }) + } + } + fn provider( + client: Arc, + catalog: bool, + manage: bool, + permissions: Option, + ) -> Provider { + Provider { + backend: ManageWorkdirFeature::new(client).backend(), + catalog, + manage, + permissions, + } + } + fn arguments(input: Json) -> BTreeMap { + input + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), json_to_wip(v).unwrap())) + .collect() + } + fn context() -> WipCallContext { + WipCallContext { + execution: ToolExecutionContext::default(), + security_context: "test".into(), + } + } + async fn call(handler: &dyn WipOperationHandler, operation: &str, input: Json) -> Json { + let output = handler + .call(operation, &arguments(input), context()) + .await + .unwrap_or_else(|_| panic!("{operation} failed")); + wip_to_json(&output.value).unwrap() + } + fn policy(allowed: &[&str]) -> ToolPermissionConfig { + ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: allowed + .iter() + .map(|name| ToolPermissionRule { + tool: (*name).into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }) + .collect(), + } + } + + #[test] + fn catalog_route_encoding_is_injective_and_canonical_for_existing_keys() { + let keys = [ + "main", + "Main", + "repo/name", + "../main", + ".", + "日本語", + "~2e", + "a.b", + "%2f", + "with space", + ]; + let mut paths = std::collections::HashSet::new(); + for key in keys { + let encoded = encode_identity(key); + assert_eq!(decode_identity(&encoded).as_deref(), Some(key)); + assert!(paths.insert(encoded.clone())); + wip_protocol::validate_path(&format!("/repositories/{encoded}")).unwrap(); + } + for invalid in ["~", "~xx", "~é", "~6162", "a.b", "..", "~C3A9"] { + assert!(decode_identity(invalid).is_none(), "{invalid}"); + } + } + + #[tokio::test] + async fn catalog_fresh_workspace_find_key_create_attach_read_detach_preserves_workdir() { + let client = Arc::new(CatalogClient::default()); + let p = provider(client.clone(), true, true, None); + let repositories = CollectionHandler { + provider: p.clone(), + domain: Domain::Repository, + }; + let listed = call(&repositories, "list", json!({})).await; + assert_eq!(listed["items"][0]["repository_key"], "registered-key"); + assert_eq!(listed["items"][0]["path"], "/repositories/registered-key"); + assert!(listed["items"][0]["default_selector"].is_null()); + assert!(!listed.to_string().contains("SECRET")); + assert!(!listed.to_string().contains("/secret/host")); + let repository = ItemHandler { + provider: p.clone(), + domain: Domain::Repository, + id: "registered-key".into(), + }; + assert!(!repository.operation_available("create")); + let read = call(&repository, "read", json!({})).await; + let workdirs = CollectionHandler { + provider: p.clone(), + domain: Domain::Workdir, + }; + let created = call( + &workdirs, + "create", + json!({"repository_key":read["repository_key"]}), + ) + .await; + assert_eq!(created["item"]["path"], "/workdirs/wd-created"); + assert!( + client.state.lock().unwrap().attachments.is_empty(), + "create is not attach" + ); + let workdir = ItemHandler { + provider: p.clone(), + domain: Domain::Workdir, + id: "wd-created".into(), + }; + let attached = call(&workdir, "attach", json!({"alias":"checkout"})).await; + assert_eq!(attached["attachments_path"], ATTACHMENTS); + let attachments = CollectionHandler { + provider: p.clone(), + domain: Domain::Attachment, + }; + let listed = call(&attachments, "list", json!({})).await; + assert_eq!(listed["items"][0]["alias"], "checkout"); + assert_eq!(listed["items"][0]["workdir_path"], "/workdirs/wd-created"); + let connection = ItemHandler { + provider: p.clone(), + domain: Domain::Attachment, + id: listed["items"][0]["connection_id"].as_str().unwrap().into(), + }; + call(&connection, "detach", json!({})).await; + assert!(client.state.lock().unwrap().attachments.is_empty()); + assert_eq!( + client.state.lock().unwrap().workdirs.len(), + 1, + "detach is not delete" + ); + assert!(p.backend.session_router.aliases().is_empty()); + let retained = call(&workdir, "delete", json!({"reason":"done"})).await; + assert_eq!(retained["disposition"], "retained"); + } + + #[tokio::test] + async fn catalog_feature_permission_and_target_capability_are_independent() { + let client = Arc::new(CatalogClient::default()); + client + .state + .lock() + .unwrap() + .workdirs + .push(directory("wd", "registered-key")); + for manage in [false, true] { + let p = provider( + client.clone(), + true, + manage, + Some(policy(&[ + "RepositoryList", + "RepositoryRead", + LIST_TOOL, + "WorkdirRead", + ])), + ); + let handler = ItemHandler { + provider: p.clone(), + domain: Domain::Workdir, + id: "wd".into(), + }; + assert!(handler.operation_available("read")); + assert!(!handler.operation_available("attach")); + assert!(matches!( + handler + .call("attach", &arguments(json!({"alias":"x"})), context()) + .await, + Err(WipOperationError::Protocol(_)) + )); + let resolver = ItemResolver { + provider: p, + domain: Domain::Workdir, + }; + assert!(resolver.resolve("wd").is_some()); + } + let denied = provider(client.clone(), true, true, Some(policy(&[]))); + assert!( + ItemResolver { + provider: denied.clone(), + domain: Domain::Workdir + } + .resolve("wd") + .is_none() + ); + assert!( + !CollectionHandler { + provider: denied, + domain: Domain::Repository + } + .is_visible() + ); + let p = provider(client.clone(), true, true, None); + client.state.lock().unwrap().workdirs.push(json!({"working_directory_id":"external", "status":"active", "source":{"kind":"external_grant", "grant_id":"opaque", "grant_permissions":{"read":true}}})); + let external = ItemHandler { + provider: p.clone(), + domain: Domain::Workdir, + id: "external".into(), + }; + assert!(external.operation_available("attach")); + assert!(!external.operation_available("delete")); + let raw = call(&external, "read", json!({})).await; + assert!(raw["source"].get("grant_id").is_none()); + client.state.lock().unwrap().denied = true; + assert!(!external.operation_available("read")); + assert!( + ItemResolver { + provider: p, + domain: Domain::Workdir + } + .resolve("external") + .is_none() + ); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|r| r.method == WorkspaceRequestMethod::Get) + ); + } + + #[tokio::test] + async fn catalog_empty_paging_route_bound_identity_and_failure_classification() { + let client = Arc::new(CatalogClient::default()); + let p = provider(client.clone(), true, true, None); + let handler = CollectionHandler { + provider: p.clone(), + domain: Domain::Repository, + }; + client.state.lock().unwrap().repositories.clear(); + let empty = call(&handler, "list", json!({})).await; + assert_eq!(empty["empty"], true); + client.state.lock().unwrap().repositories = (0..3) + .map(|i| json!({"repository_key":format!("key-{i}")})) + .collect(); + let first = call(&handler, "list", json!({"limit":2})).await; + assert_eq!(first["items"].as_array().unwrap().len(), 2); + let second = call( + &handler, + "list", + json!({"limit":2, "cursor":first["next_cursor"]}), + ) + .await; + assert_eq!(second["items"][0]["repository_key"], "key-2"); + assert_eq!(second["has_more"], false); + assert!( + handler + .call("list", &arguments(json!({"limit":101})), context()) + .await + .is_err() + ); + let item = ItemHandler { + provider: p, + domain: Domain::Workdir, + id: "wd-bound".into(), + }; + let requests_before = client.state.lock().unwrap().requests.len(); + assert!( + item.call( + "delete", + &arguments(json!({"working_directory_id":"different", "reason":"done"})), + context() + ) + .await + .is_err() + ); + assert_eq!(client.state.lock().unwrap().requests.len(), requests_before); + assert!(matches!( + map_tool_error(ToolError::StructuredConflict { + code: "workspace_http_409".into(), + message: "SECRET".into() + }), + WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + }) + )); + assert!(matches!( + map_tool_error(ToolError::ExecutionFailed( + "transport uncertain SECRET".into() + )), + WipOperationError::OutcomeUnknown(_) + )); + } + + #[test] + fn catalog_registration_uses_host_namespace_and_rejects_conflicts() { + let feature = ManageWorkdirFeature::new(Arc::new(CatalogClient::default())); + let mut registry = WipMountRegistry::new(); + mount_workspace_workdir_wip(&mut registry, &feature, true, true, None).unwrap(); + assert_eq!( + registry.routes().collect::>(), + [REPOSITORIES, ATTACHMENTS, WORKDIRS] + ); + assert!(matches!( + mount_workspace_workdir_wip(&mut registry, &feature, true, true, None), + Err(WipMountError::RouteCollision { .. }) + )); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: WORKDIRS.into(), + contributor: "another-feature".into(), + interface: "yoi.workdir/collection/v1".into(), + descriptor: collection_descriptor(Domain::Workdir, true), + handler: Arc::new(CollectionHandler { + provider: provider(Arc::new(CatalogClient::default()), true, true, None), + domain: Domain::Workdir + }) + }), + Err(WipMountError::OperationCollision { .. }) + )); + } +} + +#[cfg(test)] +mod lifetime_tests { + use super::tests::CatalogClient; + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test] + async fn attachment_pages_and_direct_lookup_are_not_limited_to_first_page() { + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().attachments = (0..65).map(|n| json!({"connection_id":format!("lifetime-{n:03}"), "alias":format!("alias-{n:03}"), "working_directory_id":"wd", "capabilities":{"bits":25}})).collect(); + let p = Provider { + backend: ManageWorkdirFeature::new(client).backend(), + permissions: None, + catalog: true, + manage: true, + }; + let handler = CollectionHandler { + provider: p.clone(), + domain: Domain::Attachment, + }; + let args = BTreeMap::from([("limit".into(), Value::Integer(50))]); + let context = || WipCallContext { + execution: ToolExecutionContext::default(), + security_context: "test".into(), + }; + let first = handler + .call("list", &args, context()) + .await + .unwrap_or_else(|_| panic!("list failed")); + let first = wip_to_json(&first.value).unwrap(); + assert_eq!(first["items"].as_array().unwrap().len(), 50); + assert_eq!(first["next_cursor"], "offset:50"); + let args = BTreeMap::from([("cursor".into(), Value::String("offset:50".into()))]); + let second = handler + .call("list", &args, context()) + .await + .unwrap_or_else(|_| panic!("list failed")); + let second = wip_to_json(&second.value).unwrap(); + assert_eq!(second["items"].as_array().unwrap().len(), 15); + assert_eq!(second["has_more"], false); + let resolver = ItemResolver { + provider: p, + domain: Domain::Attachment, + }; + assert!(resolver.resolve("lifetime-064").is_some()); + assert!(resolver.resolve("lifetime-unknown").is_none()); + } + + #[tokio::test] + async fn stale_lifetime_preflight_never_closes_new_alias_or_stops_children() { + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().attachments.push(json!({"connection_id":"replacement", "alias":"checkout", "working_directory_id":"same-workdir", "capabilities":{"bits":63}})); + let hooks = Arc::new(AtomicUsize::new(0)); + let hook_calls = hooks.clone(); + let backend = WorkspaceHttpWorkdirBackend::new(client.clone()).with_child_lifecycle( + Some(Arc::new(move || { + let hook_calls = hook_calls.clone(); + Box::pin(async move { + hook_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + })), + None, + ); + let alias = workdir::WorkdirAttachmentAlias::new("checkout").unwrap(); + backend + .session_router + .attach( + alias.clone(), + WorkspaceAttachedWorkdirSession::handle_for_workdir( + client.clone(), + "checkout", + "same-workdir", + ), + ) + .unwrap(); + let error = backend + .detach_managed("checkout", Some("old-lifetime")) + .await + .unwrap_err(); + assert!(matches!(error, ToolError::StructuredConflict { .. })); + assert_eq!(hooks.load(Ordering::SeqCst), 0); + assert!(backend.session_router.session(&alias).is_some()); + assert_eq!( + client.state.lock().unwrap().attachments[0]["connection_id"], + "replacement" + ); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|r| r.method == WorkspaceRequestMethod::Get) + ); + } +} + +#[cfg(test)] +mod logical_capability_tests { + use super::*; + #[test] + fn logical_reference_does_not_gain_physical_attach_delete_or_command() { + let client = Arc::new(super::tests::CatalogClient::default()); + client.state.lock().unwrap().workdirs.push(json!({"working_directory_id":"logical-settings", "source":{"kind":"workspace_config"}, "status":"active"})); + let provider = Provider { + backend: ManageWorkdirFeature::new(client).backend(), + catalog: true, + manage: true, + permissions: None, + }; + let item = ItemHandler { + provider, + domain: Domain::Workdir, + id: "logical-settings".into(), + }; + assert!(item.operation_available("read")); + for operation in ["attach", "delete", "command"] { + assert!(!item.operation_available(operation)); + } + } +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 703ac040e..4257f3214 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -121,6 +121,28 @@ pub enum WipOperationError { /// routing and validators are resolved before this trait is called. #[async_trait] pub trait WipOperationHandler: Send + Sync { + /// Whether the owning provider currently exposes this Object to its subject. + fn is_visible(&self) -> bool { + true + } + + /// Override the Object's registered validator with current provider state. + /// None preserves the registered validator and requires no inventory fetch. + fn object_validator(&self) -> Option> { + None + } + + /// Current target capability and subject permission intersection. Providers + /// must still enforce authority at execution; this hook controls publication. + fn operation_available(&self, _operation: &str) -> bool { + true + } + + /// Opt into path-qualified, freshly resolved interface descriptors. + fn contextual_interface(&self) -> bool { + false + } + async fn call( &self, operation: &str, @@ -179,6 +201,12 @@ pub struct WipDynamicItem { pub trait WipDynamicItemResolver: Send + Sync { fn resolve(&self, item_reference: &str) -> Option; + + /// Family-level opt-in, needed to deny unqualified descriptor fetches before + /// resolving any item. A contextual collection owner also opts in its family. + fn contextual_interface(&self) -> bool { + false + } } pub struct WipDynamicMount { @@ -228,6 +256,7 @@ impl MountedProjection { fn resolved(&self) -> WipProjection { let mut projection = self.projection.clone(); projection.handler = Arc::new(OperationDispatchHandler { + owner: Arc::clone(&self.projection.handler), handlers: self .operation_handlers .iter() @@ -239,11 +268,34 @@ impl MountedProjection { } struct OperationDispatchHandler { + owner: Arc, handlers: BTreeMap>, } #[async_trait] impl WipOperationHandler for OperationDispatchHandler { + fn is_visible(&self) -> bool { + self.owner.is_visible() + } + + fn object_validator(&self) -> Option> { + self.owner.object_validator() + } + + fn operation_available(&self, operation: &str) -> bool { + self.handlers + .get(operation) + .is_some_and(|handler| handler.operation_available(operation)) + } + + fn contextual_interface(&self) -> bool { + self.owner.contextual_interface() + || self + .handlers + .values() + .any(|handler| handler.contextual_interface()) + } + async fn call( &self, operation: &str, @@ -823,6 +875,36 @@ fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec { digest.finalize().to_vec() } +fn contextual_reference(base: &str, path: &str) -> String { + let encoded: String = path + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("{base}/@/{encoded}") +} + +fn decode_contextual_path(encoded: &str) -> Option { + if encoded.len() % 2 != 0 { + return None; + } + let bytes = encoded + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let digit = |byte| match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + }; + Some(digit(pair[0])? * 16 + digit(pair[1])?) + }) + .collect::>>()?; + let path = String::from_utf8(bytes).ok()?; + wip_protocol::validate_path(&path).ok()?; + Some(path) +} + fn is_tool_route(route: &str) -> bool { route .strip_prefix("/tools/") @@ -882,35 +964,95 @@ impl WipHost { } } - fn descriptor(&self, reference: &str) -> Option<(&InterfaceDescriptor, Option<&[u8]>)> { - self.registry - .mounts - .values() - .find(|mounted| mounted.projection.interface == reference) - .map(|mounted| { - ( - &mounted.projection.descriptor, - mounted.projection.interface_validator.as_deref(), - ) - }) - .or_else(|| { - self.registry - .dynamic_mounts - .iter() - .find(|mounted| mounted.interface == reference) - .map(|mounted| (&mounted.descriptor, mounted.interface_validator.as_deref())) - }) + fn descriptor(&self, reference: &str) -> Option<(InterfaceDescriptor, Option>)> { + for (path, mounted) in &self.registry.mounts { + if mounted.projection.interface == reference { + let Some(projection) = self.projection(path) else { + continue; + }; + if projection.interface == reference { + return Some((projection.descriptor, projection.interface_validator)); + } + } + } + if let Some(descriptor) = self.registry.dynamic_mounts.iter().find_map(|mounted| { + if mounted.interface != reference || self.dynamic_contextual(mounted) { + return None; + } + // Legacy families retain their shared descriptor. Contextual + // families must always resolve a qualified item reference below. + Some(( + mounted.descriptor.clone(), + mounted.interface_validator.clone(), + )) + }) { + return Some(descriptor); + } + let (_, encoded_path) = reference.rsplit_once("/@/")?; + let path = decode_contextual_path(encoded_path)?; + let projection = self.projection(&path)?; + // Re-resolve the exact current Object, not a cached descriptor or an + // arbitrary base interface with an appended path. Exact legacy refs + // above remain opaque even if they contain our contextual delimiter. + if projection.interface != reference { + return None; + } + Some((projection.descriptor, projection.interface_validator)) + } + + fn dynamic_contextual(&self, mounted: &WipDynamicMount) -> bool { + mounted.resolver.contextual_interface() + || self.registry.mounts[&mounted.collection_route] + .resolved() + .handler + .contextual_interface() + } + + fn project_current( + &self, + mut projection: WipProjection, + contextual_family: bool, + ) -> Option { + if !projection.handler.is_visible() { + return None; + } + if let Some(validator) = projection.handler.object_validator() { + projection.object.validator = Some(validator); + } + let contextual = contextual_family || projection.handler.contextual_interface(); + let original_len = projection.descriptor.operations.len(); + projection + .descriptor + .operations + .retain(|operation| projection.handler.operation_available(&operation.name)); + if contextual || projection.descriptor.operations.len() != original_len { + let mut digest = Sha256::new(); + digest.update(descriptor_validator(&projection.descriptor)); + // Keep an explicit provider version authoritative even when the + // filtered shape is unchanged. + if let Some(validator) = &projection.interface_validator { + digest.update(validator); + } + projection.interface_validator = Some(digest.finalize().to_vec()); + } + if contextual { + projection.interface = contextual_reference(&projection.interface, &projection.route); + projection.object.interfaces = vec![projection.interface.clone()]; + } + Some(projection) } fn projection(&self, path: &str) -> Option { if let Some(mounted) = self.registry.mounts.get(path) { - return Some(mounted.resolved()); + return self.project_current(mounted.resolved(), false); } self.registry.dynamic_mounts.iter().find_map(|mounted| { let item_reference = path.strip_prefix(&format!("{}/", mounted.collection_route))?; if item_reference.is_empty() || item_reference.contains('/') { return None; } + // Collection listing authority is independent of item read authority. + // The resolved item's owner gates its own visibility below. let item = mounted.resolver.resolve(item_reference)?; let contributions = self .registry @@ -942,10 +1084,13 @@ impl WipHost { interface: mounted.interface.clone(), descriptor: mounted.descriptor.clone(), interface_validator: mounted.interface_validator.clone(), - handler: Arc::new(OperationDispatchHandler { handlers }), + handler: Arc::new(OperationDispatchHandler { + owner: item.handler, + handlers, + }), }; validate_projection(&projection).ok()?; - Some(projection) + self.project_current(projection, self.dynamic_contextual(mounted)) }) } @@ -955,6 +1100,16 @@ impl WipHost { if let Some(projection) = self.projection(path) { return Some(projection.object); } + if self.registry.mounts.contains_key(path) + || self + .registry + .dynamic_mounts + .iter() + .any(|mount| path.starts_with(&format!("{}/", mount.collection_route))) + { + // A denied native Object must not reappear as a synthetic namespace. + return None; + } if path == WIP_ROOT || self.is_namespace(path) { let name = if path == WIP_ROOT { String::new() @@ -981,7 +1136,7 @@ impl WipHost { self.registry .mounts .keys() - .any(|route| route.starts_with(&prefix)) + .any(|route| route.starts_with(&prefix) && self.projection(route).is_some()) } fn children(&self, path: &str) -> Vec { @@ -1006,7 +1161,9 @@ impl WipHost { } else { format!("{path}/{segment}") }; - children.insert(child); + if self.object_at(&child).is_some() { + children.insert(child); + } } children.into_iter().collect() } @@ -1036,8 +1193,8 @@ impl WipHost { })?; Ok(FetchInterfaceResponse { interface: reference.to_string(), - descriptor: descriptor.clone(), - validator: validator.map(<[u8]>::to_vec), + descriptor, + validator, }) } @@ -3155,6 +3312,1084 @@ mod tests { assert!(error.to_string().contains("rediscover from `/`")); } + #[derive(Clone)] + struct ContextualHandler { + visible: Arc, + permitted: Arc, + capable: Arc, + calls: Arc, + } + + impl ContextualHandler { + fn new(permitted: bool, capable: bool) -> Self { + Self { + visible: Arc::new(AtomicBool::new(true)), + permitted: Arc::new(AtomicBool::new(permitted)), + capable: Arc::new(AtomicBool::new(capable)), + calls: Arc::new(AtomicUsize::new(0)), + } + } + } + + #[async_trait] + impl WipOperationHandler for ContextualHandler { + fn is_visible(&self) -> bool { + self.visible.load(Ordering::SeqCst) + } + + fn operation_available(&self, _operation: &str) -> bool { + self.permitted.load(Ordering::SeqCst) && self.capable.load(Ordering::SeqCst) + } + + fn contextual_interface(&self) -> bool { + true + } + + async fn call( + &self, + operation: &str, + _arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + if !self.is_visible() || !self.operation_available(operation) { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::PermissionDenied, + "current native authority denied", + ))); + } + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(WipOperationOutput::native(Value::Unit)) + } + } + + fn operation_names(descriptor: &InterfaceDescriptor) -> Vec<&str> { + descriptor + .operations + .iter() + .map(|operation| operation.name.as_str()) + .collect() + } + + fn contextual_static_registry( + owner: Arc, + contributor: Arc, + ) -> WipMountRegistry { + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + let projection = contribution_projection(owner); + let mut descriptor = projection.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry.mount(projection).unwrap(); + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor, + handler: contributor, + }) + .unwrap(); + registry + } + + #[tokio::test] + async fn contextual_static_filters_contributors_and_rechecks_old_references() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let contributor = Arc::new(ContextualHandler::new(false, true)); + let mut registry = contextual_static_registry(Arc::clone(&owner), Arc::clone(&contributor)); + // Same base contract, different Object/capability. It must not share the + // first Object's cached contextual descriptor. + let mut other = registry.mounts["/assets/A-1"].projection.clone(); + other.route = "/assets/A-2".into(); + other.object.name = "A-2".into(); + other.handler = Arc::new(ContextualHandler::new(true, false)); + registry.mount(other).unwrap(); + let host = WipHost::new(registry); + let initial = host.projection("/assets/A-1").unwrap(); + let other = host.projection("/assets/A-2").unwrap(); + assert_eq!( + initial.interface, + contextual_reference("test.asset/item/v1", "/assets/A-1") + ); + assert_ne!(initial.interface, other.interface); + assert_eq!(operation_names(&initial.descriptor), ["read"]); + assert!(other.descriptor.operations.is_empty()); + assert!(host.fetch_interface("test.asset/item/v1").is_err()); + assert_eq!( + host.fetch_interface(&initial.interface).unwrap().descriptor, + initial.descriptor + ); + host.call(asset_request(&initial, "read"), direct_wip_context()) + .await + .unwrap_or_else(|_| panic!("available owner operation must run")); + assert!(matches!( + host.call(asset_request(&initial, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::OperationNotFound, + .. + })) + )); + + contributor.permitted.store(true, Ordering::SeqCst); + let enabled = host.projection("/assets/A-1").unwrap(); + assert_eq!(enabled.interface, initial.interface); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["manage", "read"] + ); + assert_ne!(enabled.interface_validator, initial.interface_validator); + assert!(matches!( + host.call(asset_request(&initial, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceValidatorMismatch, + .. + })) + )); + host.call(asset_request(&enabled, "manage"), direct_wip_context()) + .await + .unwrap_or_else(|_| panic!("available contributor operation must run")); + contributor.capable.store(false, Ordering::SeqCst); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["read"] + ); + assert!(matches!( + host.call(asset_request(&enabled, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceValidatorMismatch, + .. + })) + )); + let current = host.projection("/assets/A-1").unwrap(); + assert!(matches!( + host.call(asset_request(¤t, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::OperationNotFound, + .. + })) + )); + let mut unqualified = asset_request(¤t, "read"); + unqualified.interface.reference = "test.asset/item/v1".into(); + assert!(matches!( + host.call(unqualified, direct_wip_context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceMismatch, + .. + })) + )); + owner.visible.store(false, Ordering::SeqCst); + contributor.permitted.store(true, Ordering::SeqCst); + contributor.capable.store(true, Ordering::SeqCst); + assert!(host.projection("/assets/A-1").is_none()); + assert!(host.fetch_interface(&initial.interface).is_err()); + assert!(host.observe("/assets/A-1", 0).is_err()); + let observed = host.observe("/assets", 1).unwrap(); + assert_eq!( + observed + .children + .unwrap() + .iter() + .map(|item| item.object.name.as_str()) + .collect::>(), + ["A-2"] + ); + assert!(matches!( + host.call(asset_request(&enabled, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::NotFound, + .. + })) + )); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + assert_eq!(contributor.calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn contextual_visibility_cannot_fall_back_to_namespace_and_provider_versions_stay_current() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let mut registry = contextual_static_registry( + Arc::clone(&owner), + Arc::new(ContextualHandler::new(true, true)), + ); + let mut child = contribution_projection(Arc::new(DynamicHandler { + calls: Arc::new(AtomicUsize::new(0)), + })); + child.route = "/assets/A-1/child".into(); + child.object.name = "child".into(); + // Legacy interface references stay opaque, including this delimiter. + child.interface = "legacy/@/opaque".into(); + child.object.interfaces = vec![child.interface.clone()]; + registry.mount(child.clone()).unwrap(); + let mut host = WipHost::new(registry); + assert_eq!( + host.fetch_interface(&child.interface).unwrap().descriptor, + child.descriptor + ); + let initial = host.projection("/assets/A-1").unwrap(); + host.registry + .mounts + .get_mut("/assets/A-1") + .unwrap() + .projection + .interface_validator = Some(vec![99]); + let current = host.fetch_interface(&initial.interface).unwrap(); + assert_eq!(current.descriptor, initial.descriptor); + assert_ne!(current.validator, initial.interface_validator); + owner.visible.store(false, Ordering::SeqCst); + assert!(host.object_at("/assets/A-1").is_none()); + assert!(host.observe("/assets/A-1", 1).is_err()); + assert!( + host.observe("/assets", 1) + .unwrap() + .children + .unwrap() + .is_empty() + ); + } + + struct ContextualItemResolver { + owner: Arc, + revision: Arc, + contextual_family: bool, + } + + impl WipDynamicItemResolver for ContextualItemResolver { + fn contextual_interface(&self) -> bool { + self.contextual_family + } + + fn resolve(&self, item_reference: &str) -> Option { + if !matches!(item_reference, "O-1" | "O-2") { + return None; + } + let mut handler = self.owner.as_ref().clone(); + if item_reference == "O-2" { + handler.capable = Arc::new(AtomicBool::new(false)); + } + Some(WipDynamicItem { + object: Object { + name: item_reference.into(), + description: None, + interfaces: vec!["test.dynamic/v1".into()], + r#ref: Some(format!("objective:{item_reference}")), + validator: Some(vec![self.revision.load(Ordering::SeqCst) as u8]), + }, + handler: Arc::new(handler), + }) + } + } + + struct ContextualContributionResolver(Arc); + + impl WipDynamicOperationResolver for ContextualContributionResolver { + fn handler(&self, _item_reference: &str) -> Arc { + self.0.clone() + } + } + + fn contextual_dynamic_registry( + owner: Arc, + contributor: Arc, + revision: Arc, + contextual_collection: bool, + ) -> WipMountRegistry { + let mut registry = dynamic_registry(Arc::clone(&revision), Arc::clone(&owner.calls)); + let mut mounted = registry.dynamic_mounts.pop().unwrap(); + mounted.descriptor.operations = vec![contributed_operation("read")]; + mounted.interface_validator = Some(descriptor_validator(&mounted.descriptor)); + mounted.resolver = Arc::new(ContextualItemResolver { + owner, + revision, + contextual_family: !contextual_collection, + }); + if contextual_collection { + let collection = registry.mounts.get_mut("/objectives").unwrap(); + collection.projection.handler = Arc::new(ContextualHandler::new(true, true)); + collection.operation_handlers = + operation_handlers(&collection.projection, "objective:collection"); + } + let mut descriptor = mounted.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry.mount_dynamic(mounted).unwrap(); + registry + .contribute_dynamic_operations(WipDynamicOperationContribution { + collection_route: "/objectives".into(), + contributor: "management".into(), + interface: "test.dynamic/v1".into(), + descriptor, + resolver: Arc::new(ContextualContributionResolver(contributor)), + }) + .unwrap(); + registry + } + + #[tokio::test] + async fn contextual_dynamic_filters_by_path_and_rechecks_permission_and_object_revision() { + // Both ways to opt in a family must block unqualified descriptor fetches. + for contextual_collection in [false, true] { + let owner = Arc::new(ContextualHandler::new(true, true)); + let contributor = Arc::new(ContextualHandler::new(true, true)); + let revision = Arc::new(AtomicUsize::new(1)); + let host = WipHost::new(contextual_dynamic_registry( + Arc::clone(&owner), + Arc::clone(&contributor), + Arc::clone(&revision), + contextual_collection, + )); + let initial = host.projection("/objectives/O-1").unwrap(); + let other = host.projection("/objectives/O-2").unwrap(); + assert_ne!(initial.interface, other.interface); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["manage", "read"] + ); + assert_eq!( + operation_names(&host.fetch_interface(&other.interface).unwrap().descriptor), + ["manage"] + ); + assert!(host.fetch_interface("test.dynamic/v1").is_err()); + assert!( + host.fetch_interface(&contextual_reference("wrong.base/v1", "/objectives/O-1")) + .is_err() + ); + assert!( + host.fetch_interface(&contextual_reference( + "test.dynamic/v1", + "/objectives/missing" + )) + .is_err() + ); + assert!(host.fetch_interface("test.dynamic/v1/@/ff").is_err()); + assert!(host.fetch_interface("test.dynamic/v1/@/2f0").is_err()); + let mut wrong_path = asset_request(&initial, "read"); + wrong_path.target.path = other.route.clone(); + assert!(matches!( + host.call(wrong_path, direct_wip_context()).await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceMismatch, + .. + })) + )); + contributor.permitted.store(false, Ordering::SeqCst); + assert_eq!( + operation_names(&host.fetch_interface(&initial.interface).unwrap().descriptor), + ["read"] + ); + assert!(matches!( + host.call(asset_request(&initial, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InterfaceValidatorMismatch, + .. + })) + )); + let current = host.projection("/objectives/O-1").unwrap(); + assert!(matches!( + host.call(asset_request(¤t, "manage"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::OperationNotFound, + .. + })) + )); + revision.store(2, Ordering::SeqCst); + assert!(matches!( + host.call(asset_request(¤t, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + })) + )); + let current = host.projection("/objectives/O-1").unwrap(); + host.call(asset_request(¤t, "read"), direct_wip_context()) + .await + .unwrap_or_else(|_| panic!("fresh available dynamic operation must run")); + owner.visible.store(false, Ordering::SeqCst); + assert!(host.observe("/objectives/O-1", 0).is_err()); + assert!(host.fetch_interface(&initial.interface).is_err()); + assert!(host.fetch_interface(&other.interface).is_err()); + assert!(matches!( + host.call(asset_request(¤t, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::NotFound, + .. + })) + )); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + assert_eq!(contributor.calls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn contextual_runtime_cached_descriptor_does_not_authorize_removed_operations() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let contributor = Arc::new(ContextualHandler::new(true, true)); + let runtime = WipRuntime::new( + WipHost::new(contextual_static_registry( + Arc::clone(&owner), + Arc::clone(&contributor), + )), + SecurityContext::new("worker-a"), + 0, + ) + .unwrap(); + runtime + .discover("/assets/A-1".into(), 0, false) + .await + .unwrap(); + let interface = runtime.host.projection("/assets/A-1").unwrap().interface; + runtime.inspect(interface.clone(), false).await.unwrap(); + contributor.permitted.store(false, Ordering::SeqCst); + // The Client still knows the old descriptor; the Host must independently + // check its current filtered descriptor rather than trust Known Space. + let error = runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("InterfaceValidatorMismatch")); + assert!( + runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "manage".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .is_err() + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 0); + assert_eq!(contributor.calls.load(Ordering::SeqCst), 0); + runtime.inspect(interface.clone(), true).await.unwrap(); + runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + owner.visible.store(false, Ordering::SeqCst); + assert!(runtime.host.fetch_interface(&interface).is_err()); + let inspected = runtime.inspect(interface.clone(), true).await.unwrap(); + let inspected: Json = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap(); + assert_eq!(inspected["state"], "error"); + assert!( + runtime + .call( + "/assets/A-1".into(), + interface, + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .is_err() + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + } + + struct CurrentObjectValidatorHandler { + inner: Arc, + revision: Arc, + } + + #[async_trait] + impl WipOperationHandler for CurrentObjectValidatorHandler { + fn contextual_interface(&self) -> bool { + true + } + + fn object_validator(&self) -> Option> { + Some(self.revision.load(Ordering::SeqCst).to_be_bytes().to_vec()) + } + + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + self.inner.call(operation, arguments, context).await + } + } + + #[tokio::test] + async fn static_object_validator_hook_rejects_current_owner_state_changes() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let revision = Arc::new(AtomicUsize::new(1)); + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + registry + .mount(contribution_projection(Arc::new( + CurrentObjectValidatorHandler { + inner: Arc::clone(&owner), + revision: Arc::clone(&revision), + }, + ))) + .unwrap(); + // Contributors cannot override Object ownership or its current validator. + let mut descriptor = registry.mounts["/assets/A-1"].projection.descriptor.clone(); + descriptor.operations = vec![contributed_operation("manage")]; + registry + .contribute_operations(WipOperationContribution { + route: "/assets/A-1".into(), + contributor: "management".into(), + interface: "test.asset/item/v1".into(), + descriptor, + handler: Arc::new(CurrentObjectValidatorHandler { + inner: Arc::new(ContextualHandler::new(true, true)), + revision: Arc::new(AtomicUsize::new(99)), + }), + }) + .unwrap(); + let runtime = + WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); + let interface = observed_contextual_interface(&runtime, "/assets/A-1").await; + let initial = runtime.host.projection("/assets/A-1").unwrap(); + assert_eq!( + initial.object.validator, + Some(1usize.to_be_bytes().to_vec()) + ); + revision.store(2, Ordering::SeqCst); + assert_eq!( + runtime.host.object_at("/assets/A-1").unwrap().validator, + Some(2usize.to_be_bytes().to_vec()) + ); + assert!(matches!( + runtime + .host + .call(asset_request(&initial, "read"), direct_wip_context()) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + })) + )); + let error = runtime + .call( + "/assets/A-1".into(), + interface.clone(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("ValidatorMismatch")); + assert_eq!(owner.calls.load(Ordering::SeqCst), 0); + assert_eq!( + observed_contextual_interface(&runtime, "/assets/A-1").await, + interface + ); + transport_call_json(&runtime, "/assets/A-1", &interface, "read", json!({})).await; + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dynamic_item_read_is_independent_of_collection_visibility_and_list_permission() { + let owner = Arc::new(ContextualHandler::new(true, true)); + let mut registry = contextual_dynamic_registry( + Arc::clone(&owner), + Arc::new(ContextualHandler::new(false, true)), + Arc::new(AtomicUsize::new(1)), + true, + ); + let denied_collection = Arc::new(ContextualHandler::new(false, true)); + denied_collection.visible.store(false, Ordering::SeqCst); + let collection = registry.mounts.get_mut("/objectives").unwrap(); + collection.projection.handler = denied_collection; + collection.operation_handlers = + operation_handlers(&collection.projection, "objective:collection"); + let runtime = + WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap(); + assert!(runtime.host.projection("/objectives").is_none()); + let interface = observed_contextual_interface(&runtime, "/objectives/O-1").await; + transport_call_json(&runtime, "/objectives/O-1", &interface, "read", json!({})).await; + owner.visible.store(false, Ordering::SeqCst); + assert!(runtime.host.fetch_interface(&interface).is_err()); + assert!( + runtime + .call( + "/objectives/O-1".into(), + interface, + "read".into(), + json!({}), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + } + + fn transport_output_json(output: ToolOutput) -> Json { + serde_json::from_str(output.content.as_deref().expect("transport JSON output")).unwrap() + } + + async fn observed_contextual_interface(runtime: &WipRuntime, path: &str) -> String { + let observed = transport_output_json(runtime.discover(path.into(), 0, true).await.unwrap()); + let object = observed["known_space"] + .as_array() + .unwrap() + .iter() + .find(|item| item["path"] == path) + .unwrap(); + assert_eq!(object["state"], "fresh"); + let interface = object["object"]["interfaces"][0] + .as_str() + .unwrap() + .to_string(); + assert!(interface.contains("/@/")); + let inspected = + transport_output_json(runtime.inspect(interface.clone(), true).await.unwrap()); + assert_eq!(inspected["state"], "fresh"); + interface + } + + async fn transport_call_json( + runtime: &WipRuntime, + path: &str, + interface: &str, + operation: &str, + arguments: Json, + ) -> Json { + transport_output_json( + runtime + .call( + path.into(), + interface.into(), + operation.into(), + arguments, + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ) + } + + fn catalog_transport_runtime( + client: Arc, + manage: bool, + permissions: Option, + ) -> Arc { + use crate::feature::FeatureRegistryBuilder; + use crate::feature::builtin::manage_workdir::{ + ManageWorkdirFeature, wip::mount_workspace_workdir_wip, + }; + let feature = ManageWorkdirFeature::new(client); + let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient); + let report = FeatureRegistryBuilder::new() + .with_module(feature.clone()) + .install_into_engine(&mut engine, &mut crate::hook::HookRegistryBuilder::new()); + assert!(!report.has_errors()); + assert_eq!(report.installed_tool_names().len(), 5); + let mut registry = WipMountRegistry::new(); + mount_workspace_workdir_wip(&mut registry, &feature, true, manage, permissions.clone()) + .unwrap(); + let runtime = + install_wip_mode_with_mounts(&mut engine, permissions, "worker-a".into(), registry) + .unwrap(); + for name in [ + "WorkdirList", + "WorkdirCreate", + "WorkdirAttach", + "WorkdirDetach", + "WorkdirDelete", + ] { + assert!(runtime.host.projection(&format!("/tools/{name}")).is_none()); + } + assert!(runtime.host.object_at("/features/manage-workdir").is_none()); + runtime + } + + #[tokio::test] + async fn catalog_native_transport_discovers_creates_attaches_reads_and_detaches() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + use crate::worker::WorkspaceRequestMethod; + let client = Arc::new(CatalogClient::default()); + let runtime = catalog_transport_runtime(client.clone(), true, None); + let root = transport_output_json(runtime.discover("/".into(), 1, false).await.unwrap()); + let paths = root["known_space"] + .as_array() + .unwrap() + .iter() + .map(|item| item["path"].as_str().unwrap()) + .collect::>(); + for path in ["/repositories", "/workdirs", "/workdir-attachments"] { + assert!(paths.contains(&path)); + } + assert!(!paths.iter().any(|path| path.starts_with("/tools/Workdir") + || path.starts_with("/features/manage-workdir"))); + let repositories_interface = observed_contextual_interface(&runtime, "/repositories").await; + let listed = transport_call_json( + &runtime, + "/repositories", + &repositories_interface, + "list", + json!({}), + ) + .await; + let repository = &listed["items"][0]; + assert_eq!(repository["repository_key"], "registered-key"); + assert!(repository["default_selector"].is_null()); + assert!(!listed.to_string().contains("SECRET")); + assert!(!listed.to_string().contains("/secret/host")); + let repository_path = repository["path"].as_str().unwrap(); + let repository_interface = observed_contextual_interface(&runtime, repository_path).await; + let read = transport_call_json( + &runtime, + repository_path, + &repository_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["repository_key"], repository["repository_key"]); + let workdirs_interface = observed_contextual_interface(&runtime, "/workdirs").await; + let created = transport_call_json( + &runtime, + "/workdirs", + &workdirs_interface, + "create", + json!({"repository_key":read["repository_key"]}), + ) + .await; + assert_eq!(created["item"]["path"], "/workdirs/wd-created"); + { + let state = client.state.lock().unwrap(); + assert_eq!(state.workdirs.len(), 1); + assert!(state.attachments.is_empty(), "creation must not attach"); + assert!( + !state + .requests + .iter() + .any(|request| request.method == WorkspaceRequestMethod::Post + && request.path.ends_with("workdir-attachments")) + ); + } + let workdir_path = created["item"]["path"].as_str().unwrap(); + let workdir_interface = observed_contextual_interface(&runtime, workdir_path).await; + let attached = transport_call_json( + &runtime, + workdir_path, + &workdir_interface, + "attach", + json!({"alias":"checkout"}), + ) + .await; + assert_eq!(attached["attachments_path"], "/workdir-attachments"); + let attachments_interface = + observed_contextual_interface(&runtime, "/workdir-attachments").await; + let listed = transport_call_json( + &runtime, + "/workdir-attachments", + &attachments_interface, + "list", + json!({}), + ) + .await; + let attachment = &listed["items"][0]; + assert_eq!(attachment["alias"], "checkout"); + assert_eq!(attachment["workdir_path"], workdir_path); + let attachment_path = attachment["path"].as_str().unwrap(); + let attachment_interface = observed_contextual_interface(&runtime, attachment_path).await; + let read = transport_call_json( + &runtime, + attachment_path, + &attachment_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["connection_id"], attachment["connection_id"]); + transport_call_json( + &runtime, + attachment_path, + &attachment_interface, + "detach", + json!({}), + ) + .await; + let attachments_interface = + observed_contextual_interface(&runtime, "/workdir-attachments").await; + let listed = transport_call_json( + &runtime, + "/workdir-attachments", + &attachments_interface, + "list", + json!({}), + ) + .await; + assert_eq!(listed["empty"], true); + assert!(client.state.lock().unwrap().attachments.is_empty()); + assert_eq!( + client.state.lock().unwrap().workdirs.len(), + 1, + "detach must preserve persistent Workdir" + ); + assert!(runtime.host.projection(attachment_path).is_none()); + assert!(runtime.host.fetch_interface(&attachment_interface).is_err()); + let state = client.state.lock().unwrap(); + let writes = state + .requests + .iter() + .filter(|request| request.method != WorkspaceRequestMethod::Get) + .collect::>(); + assert_eq!(writes.len(), 3); + assert!( + writes[2] + .path + .contains("expected_connection_id=connection-1") + ); + drop(state); + // A dirty provider result is a retained lifecycle outcome, not transport + // uncertainty or implicit destructive success. + client.state.lock().unwrap().workdirs[0]["cleanliness"] = json!("dirty"); + let workdir_interface = observed_contextual_interface(&runtime, workdir_path).await; + let read = transport_call_json( + &runtime, + workdir_path, + &workdir_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["cleanliness"], "dirty"); + let retained = transport_call_json( + &runtime, + workdir_path, + &workdir_interface, + "delete", + json!({"reason":"dirty checkout retained"}), + ) + .await; + assert_eq!(retained["disposition"], "retained"); + assert_eq!(client.state.lock().unwrap().workdirs.len(), 1); + assert_eq!( + runtime.audit().last().unwrap().outcome, + WipAuditOutcome::Success + ); + } + + #[tokio::test] + async fn catalog_native_transport_read_only_and_read_without_list_permissions() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + use crate::worker::WorkspaceRequestMethod; + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().workdirs.push(json!({ + "working_directory_id":"wd-read", "status":"active", "cleanliness":"clean", + "source":{"kind":"repository", "repository_key":"registered-key"}, + })); + let runtime = catalog_transport_runtime(client.clone(), false, None); + runtime.discover("/".into(), 1, false).await.unwrap(); + let collection_interface = observed_contextual_interface(&runtime, "/workdirs").await; + assert_eq!( + operation_names( + &runtime + .host + .fetch_interface(&collection_interface) + .unwrap() + .descriptor + ), + ["list"] + ); + let listed = transport_call_json( + &runtime, + "/workdirs", + &collection_interface, + "list", + json!({}), + ) + .await; + let path = listed["items"][0]["path"].as_str().unwrap(); + let interface = observed_contextual_interface(&runtime, path).await; + assert_eq!( + operation_names(&runtime.host.fetch_interface(&interface).unwrap().descriptor), + ["read"] + ); + transport_call_json(&runtime, path, &interface, "read", json!({})).await; + for (target, reference, operation, input) in [ + ( + "/workdirs", + &collection_interface, + "create", + json!({"repository_key":"registered-key"}), + ), + (path, &interface, "attach", json!({"alias":"checkout"})), + (path, &interface, "delete", json!({"reason":"done"})), + ] { + assert!( + runtime + .call( + target.into(), + reference.clone(), + operation.into(), + input, + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + } + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|request| request.method == WorkspaceRequestMethod::Get) + ); + let read_only = ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: ["RepositoryRead", "WorkdirRead"] + .iter() + .map(|name| manifest::ToolPermissionRule { + tool: (*name).into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }) + .collect(), + }; + let runtime = catalog_transport_runtime(client.clone(), false, Some(read_only)); + for path in ["/repositories/registered-key", "/workdirs/wd-read"] { + let interface = observed_contextual_interface(&runtime, path).await; + assert_eq!( + operation_names(&runtime.host.fetch_interface(&interface).unwrap().descriptor), + ["read"] + ); + transport_call_json(&runtime, path, &interface, "read", json!({})).await; + } + client.state.lock().unwrap().denied = true; + let interface = contextual_reference("yoi.workdir/item/v1", "/workdirs/wd-read"); + assert!(runtime.host.fetch_interface(&interface).is_err()); + assert!( + runtime + .call( + "/workdirs/wd-read".into(), + interface, + "read".into(), + json!({}), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|request| request.method == WorkspaceRequestMethod::Get) + ); + } + + #[derive(Debug)] + struct UncertainCatalogClient { + catalog: Arc, + } + + impl crate::worker::WorkspaceClient for UncertainCatalogClient { + fn workspace_id(&self) -> Option<&str> { + Some("test-workspace") + } + fn kind(&self) -> &str { + "uncertain-catalog-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: crate::worker::WorkspaceRequest, + ) -> Result { + use crate::worker::{WorkspaceClientError, WorkspaceRequestMethod}; + if request.method == WorkspaceRequestMethod::Post + && request.path.ends_with("/working-directories") + { + self.catalog.state.lock().unwrap().requests.push(request); + return Err(WorkspaceClientError::Unavailable( + "SECRET /host/path uncertain write".into(), + )); + } + self.catalog.execute(request) + } + } + + #[tokio::test] + async fn catalog_native_transport_unknown_mutation_is_terminal_and_not_retried() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + use crate::worker::WorkspaceRequestMethod; + let catalog = Arc::new(CatalogClient::default()); + let runtime = catalog_transport_runtime( + Arc::new(UncertainCatalogClient { + catalog: catalog.clone(), + }), + true, + None, + ); + let interface = observed_contextual_interface(&runtime, "/workdirs").await; + let error = runtime + .call( + "/workdirs".into(), + interface, + "create".into(), + json!({"repository_key":"registered-key"}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + let error = error.to_string(); + assert!(error.contains("outcome unknown")); + assert!(!error.contains("SECRET")); + assert!(!error.contains("/host/path")); + assert_eq!( + runtime.audit().last().unwrap().outcome, + WipAuditOutcome::OutcomeUnknown + ); + let state = runtime.state.lock().unwrap(); + assert!(matches!( + state + .client + .call_history(&state.session) + .unwrap() + .back() + .unwrap() + .outcome, + CallOutcome::Unknown { .. } + )); + let state = catalog.state.lock().unwrap(); + assert_eq!( + state + .requests + .iter() + .filter(|request| request.method == WorkspaceRequestMethod::Post) + .count(), + 1 + ); + assert!(state.workdirs.is_empty()); + assert!(state.attachments.is_empty()); + } + struct NativeTyped; #[async_trait] diff --git a/docs/design/workdir-wip-projection.md b/docs/design/workdir-wip-projection.md new file mode 100644 index 000000000..df07146b5 --- /dev/null +++ b/docs/design/workdir-wip-projection.md @@ -0,0 +1,175 @@ +# Repository, Workdir and attachment WIP projection + +## Objects and operations + +The Host owns these root namespaces, using the same `WipMountRegistry` +namespace/resolver/contribution contracts as Tickets, Objectives and Merge Requests: + +```text +/ +├── repositories list +│ └── read +├── workdirs list, create +│ └── read, attach, delete (when supported) +└── workdir-attachments list + └── read, detach +``` + +Operations are Interface declarations, **not child files**. There is no +`/features/manage-workdir`, no writable Repository catalog and no Repository +content tree here. `/checkouts` content projection is a separate provider (T-699), +as is Workspace configuration (T-695). Management Objects do not grant access to +unattached contents. A future content provider should return its entrance from +an authorized, live attachment; it must not infer content/command capability +from catalog or lifecycle access. + +`path` in inventory results is an absolute WIP Object path. It is a reference, +not an OS path, a capability grant, or an instruction to reinterpret another +string argument as a route. Source Repository references use `repository_path`; +attachment references use `workdir_path`. Attach results include +`attachments_path` to discover the current connection and its lifetime ID. + +### Identity and route encoding + +Repository identity is the formal Backend `repository_key`, never its display +name or a guessed `main`. Workdir identity is `working_directory_id`, never a +Worker-local alias. A segment containing only ASCII letters, digits, `-`, `_` +is unchanged. All other UTF-8 identities are encoded as `~` followed by lowercase +hexadecimal UTF-8 bytes. The `~` marker is itself always encoded. Only the +canonical encoding is accepted; no alternate spellings, recursive URL decoding, +case folding, slash traversal or namespace fallback are accepted. For example, +`repo/name` is `/repositories/~7265706f2f6e616d65`. This is injective and accepts +existing valid keys, including Unicode and slash-containing identities. + +Attachment identity is a fresh opaque Backend connection ID for each lifetime, +including detach/reattach to the **same** alias and Workdir. Read exposes alias, +Workdir ID and effective attachment capabilities as attributes. Workdir existence +and Worker connection are separate Objects. Detaching a connection does not +remove the Workdir or delete a logical settings space. + +## Feature and authority separation + +`feature.workdir_catalog.enabled` independently supplies read-only Repository, +Workdir and attachment reference operations. It defaults enabled so a WIP Worker +with Workspace authority and appropriate permissions can find registered keys +without Ticket or conversation hints. It does not add ordinary Tools or require +a filesystem/Bash location. Standalone Workers without Workspace authority have +no Workspace catalog mounted. + +`feature.manage_workdir.enabled` supplies management operation implementations +on the **same Objects** using `contribute_operations` and +`contribute_dynamic_operations`. It remains default disabled. It neither owns +another resolver nor confers permission. The object provider is unique; duplicate +namespace, route, operation name or inconsistent Interface contributions fail at +registration through the common registry. + +Native permission identities are: + +| Operation | Manifest permission identity | Bound identity in permission input | +| --- | --- | --- | +| repositories.list | RepositoryList | list arguments | +| repository.read | RepositoryRead | repository_key | +| workdirs.list | WorkdirList | list arguments | +| workdir.read | WorkdirRead | working_directory_id | +| workdirs.create | WorkdirCreate | original create arguments | +| workdir.attach | WorkdirAttach | working_directory_id + alias | +| workdir.delete | WorkdirDelete | working_directory_id + reason | +| attachments.list | WorkdirAttachmentList | list arguments | +| attachment.read | WorkdirAttachmentRead | connection_id | +| attachment.detach | WorkdirDetach | current bound alias | + +Read permissions gate direct item resolution as well as list item visibility. +The manifest's existing default policy is retained; `ask` is denied fail-closed. +An argument-dependent policy may advertise an operation that could be allowed, +but every call checks its exact reconstructed input before execution. Mutation +arguments cannot override path-bound Workdir or connection identity. Having +RepositoryRead is not WorkdirCreate; inventory/management permissions are not +file access, delegation or command execution grants. Backend caller/Workspace +identity, source proof, attachment capability ceilings and lifecycle policy are +checked by the existing paths on every request. + +## Contextual Interfaces and stale observations + +Providers opt into common Host contextual Interfaces. Operation publication is +the intersection of registered implementation, current target support and current +subject policy. The collection reference feature can remain available while the +management feature is disabled. External Workdirs without cleanup authority do +not expose delete. Only currently supported lifecycle operations are advertised; +no delete/command is added to logical attachments solely because they appear as +workspaces. + +A contextual Interface reference is opaque and path-qualified as +`/@/`. Clients must use the returned reference, not +construct it. Fetch and call freshly resolve the same Object and filtered +Interface; base references cannot fetch an unfiltered descriptor. Descriptor and +Object validators invalidate old observations after target/connection/permission +changes. Validators are managed by the WIP Client, not manually by the LLM. +Cached Interface possession and known direct paths never bypass current checks. +Historic session text remains append-only and is not rewritten or replayed. + +## Backend lifecycle and safe output + +Catalogs are request-time projections of existing Backend authority, not another +store or synchronization job. WIP list pages default to 50 and accept 1–100 +items. Repository/Workdir pages sort by stable identity and use the last identity +as `next_cursor`; they project the existing Backend inventory response rather +than maintaining a catalog copy. Attachment pages use the Backend's SQL-bounded, +alias-ordered limit/offset query and opaque `offset:` cursors. A cursor is not +a snapshot or authority grant: restart listing after concurrent collection +changes. Filtering by exact `connection_id` is applied in the caller-scoped +Backend ledger before paging, so direct item lookup also works beyond the first +page. Unknown/expired IDs yield no item, never a current alias substitute. +Repository projection uses an explicit field +allowlist: formal key, kind/provider, any existing display metadata and explicit +nullable default selector. Source URI/path, credentials, remotes, Runtime +connection information and internal fingerprints are not model-visible. +Workdir projection includes stable ID, optional display name, public source kind +and Repository key, existing selector/ref/tree, status, cleanliness and occupancy +when available. Cleanup handles and External grant internals are not forwarded. +No missing attributes or implicit selectors are fabricated. Backend errors are +sanitized rather than forwarding raw diagnostic bodies with possible host paths. + +Create and attach reuse the ordinary Workdir backend, request identity, +Worker-session router, mutation lock and Internal SubWorker lifecycle hooks. +Creation does **not** attach. Attach installs the Backend-attenuated session +capabilities, never a fabricated `ALL` capability for External Workdirs. Detach +uses the same begin/finish/cancel-detach path and command/operation exclusion as +Tools; the Backend checks expected connection identity under its existing +current-Worker lifecycle lock before closing sessions, then conditionally +updates that exact row in the existing connection ledger. Alias reuse cannot +retarget an old connection operation. Compensation retains the original +connection identity. Existing removal dispositions `removed`, `retained`, and +`attention_required`, plus retryability, remain intact. + +Stale identity/validator rejection is distinct from unknown operation outcome. +Transport failure, invalid post-dispatch response or uncertain lifecycle failure +is **not** proof that no effect occurred; it must not be automatically retried. +Inspect current state or request operator guidance. No new retry or attachment +ledger is introduced by WIP. + +When native management is mounted, `WorkdirList/Create/Attach/Detach/Delete` are +claimed as represented native capabilities, so `/tools/Workdir*` is not also +published. Ordinary Tools mode keeps its names and inputs. Existing native +Ticket/Objective/Merge Request routes retain their T-698 contracts. + +## First-Workspace example + +1. `WipDiscover(path="/", depth=1)` finds `repositories`, `workdirs` and + `workdir-attachments` when permitted. +2. Inspect the returned Repository collection Interface and call `list` with a + bounded limit. An empty registered/visible catalog explicitly returns an empty + list; do not invent a key. Follow `next_cursor` when supplied. +3. Discover the returned item `path`, inspect its Interface and call `read`. + `default_selector: null` means no configured default, not `main`. +4. Inspect the Workdir collection and `create` using the discovered formal key, + with optional selector, display name and Runtime under the existing contract. +5. Follow created `item.path`, discover/inspect and `attach` with a chosen + Worker-local alias. This is separate from creation. +6. Follow `attachments_path`, `list`, then discover/read the returned connection + path to confirm alias, Workdir and effective capabilities. +7. Call that connection's `detach` with no identity override. The observed + lifetime ID guards alias reuse; the Workdir remains in its inventory. + +Each step uses current WIP observations and no Ticket-derived key, host path, +Runtime endpoint or manual validator. Explicitly refresh/reset discovery after +external changes and before reconstructing a rejected stale call. diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index ed3019ba4..f77be0daf 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -75,7 +75,7 @@ The built-in native route migration is: | `/features/objective/objectives[/]` | `/objectives[/]` | | `/features/merge-request/merge-requests[/]` | `/merge-requests[/]` | -`/repositories` and `/workdirs` can be allocated by a later provider using the same registration contract; this layer does not implement or pre-allocate those domains. `/checkouts` content projection is likewise outside this contract. +The built-in Repository/Workdir reference provider allocates `/repositories`, `/workdirs`, and `/workdir-attachments` using this contract; `manage-workdir` contributes operations rather than owning another Object. See [Repository, Workdir and attachment WIP projection](workdir-wip-projection.md) for identity encoding, reference/management permissions, attachment lifetime safety, and first-Workspace discovery. `/checkouts` content projection remains a separate provider. ### Registering a subsequent provider