diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index f75441f36..4dbba20cb 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -98,6 +98,8 @@ pub struct FeatureConfigPartial { #[serde(default)] pub workdir_catalog: Option, #[serde(default)] + pub workspace_config: Option, + #[serde(default)] pub ticket: Option, #[serde(default)] pub merge_request: Option, @@ -144,6 +146,11 @@ impl FeatureConfigPartial { other.workdir_catalog, FeatureFlagConfigPartial::merge, ), + workspace_config: merge_option( + self.workspace_config, + other.workspace_config, + WorkspaceConfigFeatureConfigPartial::merge, + ), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), merge_request: merge_option( self.merge_request, @@ -173,6 +180,38 @@ impl FeatureFlagConfigPartial { } } +/// Activation only: no Workspace/grant/source/permission fields are accepted. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigFeatureConfigPartial { + #[serde(default)] + pub enabled: Option, +} + +impl WorkspaceConfigFeatureConfigPartial { + fn merge(self, other: Self) -> Self { + Self { + enabled: other.enabled.or(self.enabled), + } + } +} + +impl From for FeatureFlagConfig { + fn from(value: WorkspaceConfigFeatureConfigPartial) -> Self { + Self { + enabled: value.enabled.unwrap_or_default(), + } + } +} + +impl From for WorkspaceConfigFeatureConfigPartial { + fn from(value: FeatureFlagConfig) -> Self { + Self { + enabled: Some(value.enabled), + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct WorkerFeatureConfigPartial { #[serde(default)] @@ -402,6 +441,10 @@ impl From for FeatureConfig { .and_then(|flag| flag.enabled) .unwrap_or(defaults::WORKDIR_CATALOG_ENABLED), }, + workspace_config: value + .workspace_config + .map(FeatureFlagConfig::from) + .unwrap_or_default(), ticket: value .ticket .map(TicketFeatureConfig::from) @@ -594,6 +637,7 @@ impl From for FeatureConfigPartial { objective: Some(value.objective.into()), manage_workdir: Some(value.manage_workdir.into()), workdir_catalog: Some(value.workdir_catalog.into()), + workspace_config: Some(value.workspace_config.into()), ticket: Some(value.ticket.into()), merge_request: Some(value.merge_request.into()), orchestration: Some(value.orchestration.into()), @@ -2257,10 +2301,117 @@ worker_max_turns = 7 assert!(!manifest.feature.objective.enabled); assert!(!manifest.feature.manage_workdir.enabled); assert!(manifest.feature.workdir_catalog.enabled); + assert!(!manifest.feature.workspace_config.enabled); assert!(!manifest.feature.ticket.enabled); assert!(!manifest.feature.merge_request.any()); } + #[test] + fn workspace_config_defaults_disabled_for_partial_and_resolved_configs() { + assert!(!FeatureConfig::default().workspace_config.enabled); + assert!(FeatureConfigPartial::default().workspace_config.is_none()); + for source in ["", "[feature]\n", "[feature.workspace_config]\n"] { + let partial = WorkerManifestConfig::from_toml(source).unwrap(); + assert!( + !FeatureConfig::from(partial.feature) + .workspace_config + .enabled + ); + } + for source in ["{}", r#"{"workspace_config": {}}"#] { + let feature: FeatureConfig = serde_json::from_str(source).unwrap(); + assert!(!feature.workspace_config.enabled); + } + } + + #[test] + fn workspace_config_enabled_only_merges_and_roundtrips_snapshots() { + for enabled in [false, true] { + let lower = WorkerManifestConfig::from_toml(&format!( + "[feature.workspace_config]\nenabled = {enabled}\n" + )) + .unwrap(); + let empty = WorkerManifestConfig::from_toml("[feature.workspace_config]\n").unwrap(); + let manifest: WorkerManifest = minimal_valid() + .merge(lower.clone()) + .merge(empty) + .try_into() + .unwrap(); + assert_eq!(manifest.feature.workspace_config.enabled, enabled); + assert!(!manifest.feature.manage_workdir.enabled); + let partial = FeatureConfigPartial::from(manifest.feature.clone()); + assert_eq!( + partial.workspace_config.as_ref().unwrap().enabled, + Some(enabled) + ); + let serialized = toml::to_string(&partial).unwrap(); + let restored: FeatureConfigPartial = toml::from_str(&serialized).unwrap(); + assert_eq!(FeatureConfig::from(restored), manifest.feature); + let json = serde_json::to_value(&manifest.feature).unwrap(); + assert_eq!( + json["workspace_config"], + serde_json::json!({"enabled": enabled}) + ); + assert_eq!( + serde_json::from_value::(json).unwrap(), + manifest.feature + ); + let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap(); + assert_eq!( + crate::read_persisted_worker_manifest_snapshot(snapshot.clone()) + .unwrap() + .feature + .workspace_config + .enabled, + enabled + ); + let mut old_snapshot = snapshot; + old_snapshot["manifest"]["feature"] + .as_object_mut() + .unwrap() + .remove("workspace_config"); + assert!( + !crate::read_persisted_worker_manifest_snapshot(old_snapshot) + .unwrap() + .feature + .workspace_config + .enabled + ); + let upper = WorkerManifestConfig::from_toml(&format!( + "[feature.workspace_config]\nenabled = {}\n", + !enabled + )) + .unwrap(); + let manifest: WorkerManifest = minimal_valid() + .merge(lower) + .merge(upper) + .try_into() + .unwrap(); + assert_eq!(manifest.feature.workspace_config.enabled, !enabled); + } + } + + #[test] + fn workspace_config_rejects_manifest_authority_fields() { + for (field, value) in [ + ("grant_id", "\"grant-1\""), + ("workspace_id", "\"workspace-1\""), + ("access", "\"read_write\""), + ("config_root", "\"/host/config\""), + ("source", "\"config\""), + ] { + let source = format!("[feature.workspace_config]\nenabled = true\n{field} = {value}\n"); + assert!(WorkerManifestConfig::from_toml(&source).is_err(), "{field}"); + assert!( + toml::from_str::(&format!( + "[workspace_config]\nenabled = true\n{field} = {value}\n" + )) + .is_err(), + "{field}" + ); + } + } + #[test] fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() { assert!(FeatureConfig::default().workdir_catalog.enabled); diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index b37ce3b9a..016b88577 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -141,6 +141,10 @@ pub struct FeatureConfig { deserialize_with = "deserialize_workdir_catalog" )] pub workdir_catalog: FeatureFlagConfig, + /// Opt-in WIP-only logical Workspace configuration feature. This flag is + /// not an access grant; Backend-owned Worker grants remain authoritative. + #[serde(default, deserialize_with = "deserialize_workspace_config")] + pub workspace_config: FeatureFlagConfig, #[serde(default)] pub ticket: TicketFeatureConfig, #[serde(default)] @@ -165,6 +169,13 @@ where }) } +fn deserialize_workspace_config<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(config::WorkspaceConfigFeatureConfigPartial::deserialize(deserializer)?.into()) +} + impl Default for FeatureConfig { fn default() -> Self { Self { @@ -180,6 +191,7 @@ impl Default for FeatureConfig { objective: FeatureFlagConfig::disabled(), manage_workdir: FeatureFlagConfig::disabled(), workdir_catalog: default_workdir_catalog(), + workspace_config: FeatureFlagConfig::disabled(), ticket: TicketFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(), orchestration: FeatureFlagConfig::disabled(), diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 4cf41c3ec..c1c6e9e1b 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -11,6 +11,7 @@ pub use api_macros::axum as server_support; pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub mod repository_openapi_typescript; +mod workspace_config; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -18,6 +19,7 @@ use webauthn_rs_proto::{ CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, }; +pub use workspace_config::*; #[allow(dead_code)] #[derive(JsonSchema)] @@ -200,6 +202,22 @@ impl_openapi_schema!( CurrentWorkerWorkdirDetachQuery, CurrentWorkerWorkdirOperationRequest, CurrentWorkerWorkdirOperationResponse, + WorkspaceConfigAccess, + WorkspaceConfigNodeKind, + WorkspaceConfigFailureClassification, + WorkspaceConfigAttachRequest, + WorkspaceConfigAttachment, + WorkspaceConfigCurrentResponse, + WorkspaceConfigObserveRequest, + WorkspaceConfigNode, + WorkspaceConfigObserveResponse, + WorkspaceConfigReadRequest, + WorkspaceConfigReadResponse, + WorkspaceConfigCommitRequest, + WorkspaceConfigCommitResponse, + WorkspaceConfigApiError, + WorkspaceConfigGrantCreateRequest, + WorkspaceConfigGrantResponse, ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse, WorkspaceResponse, @@ -2570,6 +2588,55 @@ pub trait ServerApi { #[path] working_directory_id: String, #[body] request: WorkingDirectoryRemovalRequest, ) -> Result; + #[get("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)] + async fn current_worker_workspace_config_get( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + #[post("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)] + async fn current_worker_workspace_config_attach( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceConfigAttachRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/workers/self/workspace-config/observe", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)] + async fn current_worker_workspace_config_observe( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceConfigObserveRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/workers/self/workspace-config/read", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)] + async fn current_worker_workspace_config_read( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceConfigReadRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/workers/self/workspace-config/commit", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)] + async fn current_worker_workspace_config_commit( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceConfigCommitRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/workspace-config-grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)] + async fn workspace_config_grant_create( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: WorkspaceConfigGrantCreateRequest, + ) -> Result; + #[delete("/api/w/{workspace_id}/workspace-config-grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)] + async fn workspace_config_grant_revoke( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[path] grant_id: String, + ) -> Result; + #[get( "/api/w/{workspace_id}/workers/self/workdir-catalog", status = 200, @@ -6323,6 +6390,7 @@ pub struct Diagnostic { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum WorkingDirectoryMaterializerKind { + LogicalWorkspaceConfig, #[default] RuntimeGitClone, ClientHostedExternal, @@ -6462,6 +6530,11 @@ pub struct RuntimeWorkingDirectorySummary { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum WorkingDirectorySource { + WorkspaceConfig { + access: WorkspaceConfigAccess, + content_path: String, + purpose: String, + }, Repository { repository_key: String, }, @@ -10723,6 +10796,22 @@ pub fn legacy_catalog_typescript() -> String { ToolchainContract::decl(&config), WorkspaceConfigTreeResponse::decl(&config), ConfigCommitRequest::decl(&config), + WorkspaceConfigAccess::decl(&config), + WorkspaceConfigNodeKind::decl(&config), + WorkspaceConfigFailureClassification::decl(&config), + WorkspaceConfigAttachRequest::decl(&config), + WorkspaceConfigAttachment::decl(&config), + WorkspaceConfigCurrentResponse::decl(&config), + WorkspaceConfigObserveRequest::decl(&config), + WorkspaceConfigNode::decl(&config), + WorkspaceConfigObserveResponse::decl(&config), + WorkspaceConfigReadRequest::decl(&config), + WorkspaceConfigReadResponse::decl(&config), + WorkspaceConfigCommitRequest::decl(&config), + WorkspaceConfigCommitResponse::decl(&config), + WorkspaceConfigApiError::decl(&config), + WorkspaceConfigGrantCreateRequest::decl(&config), + WorkspaceConfigGrantResponse::decl(&config), ProfileSettingsResponse::decl(&config), WorkspaceProfileSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config), @@ -13214,6 +13303,11 @@ mod openapi_artifact_tests { // identity, or a one-use Reviewer capability. They remain generated Rust client/Axum // operations, but must not appear as unauthenticated operations in the public OpenAPI. const SIGNED_INTERNAL: &[&str] = &[ + "current_worker_workspace_config_get", + "current_worker_workspace_config_attach", + "current_worker_workspace_config_observe", + "current_worker_workspace_config_read", + "current_worker_workspace_config_commit", "current_worker_workdir_catalog", "current_worker_workdir_attachment_list", "current_worker_workdir_attach", diff --git a/crates/server-api/src/workspace_config.rs b/crates/server-api/src/workspace_config.rs new file mode 100644 index 000000000..905c9248c --- /dev/null +++ b/crates/server-api/src/workspace_config.rs @@ -0,0 +1,284 @@ +//! Grant-bound logical Workspace configuration transport. No host filesystem contract. +use super::{ConfigCommitRequest, ConfigContentType}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceConfigAccess { + ReadOnly, + ReadWrite, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceConfigNodeKind { + Directory, + File, + Missing, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceConfigFailureClassification { + NotCommitted, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigAttachRequest { + #[serde(default)] + pub alias: Option, + #[serde(default)] + pub access: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigAttachment { + pub workspace_id: String, + pub connection_id: String, + pub alias: String, + pub working_directory_id: String, + pub access: WorkspaceConfigAccess, + pub name: String, + pub purpose: String, + pub content_path: String, + pub already_attached: bool, +} +/// Named transport wrapper required by the generated API; JSON remains attachment-or-null. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(transparent)] +pub struct WorkspaceConfigCurrentResponse(pub Option); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigObserveRequest { + pub connection_id: String, + pub paths: Vec, + pub depth: u32, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigNode { + pub path: String, + pub kind: WorkspaceConfigNodeKind, + pub validator: String, + pub digest: Option, + pub content_type: Option, + pub operations: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigObserveResponse { + pub connection_id: String, + pub validator: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub digest: String, + /// Canonical commit entrypoints from this exact metadata snapshot. + pub entrypoints: Vec, + pub nodes: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigReadRequest { + pub connection_id: String, + pub path: String, + pub validator: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigReadResponse { + pub path: String, + pub content: String, + pub content_type: ConfigContentType, + pub digest: String, + pub validator: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigCommitRequest { + pub connection_id: String, + pub validator: String, + pub request: ConfigCommitRequest, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigCommitResponse { + pub validator: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub revision: u64, + pub digest: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigApiError { + pub status: u16, + pub code: String, + pub message: String, + pub classification: WorkspaceConfigFailureClassification, +} +impl api_macros::HttpError for WorkspaceConfigApiError { + fn status_code(&self) -> u16 { + self.status + } +} +impl api_macros::HttpRequestError for WorkspaceConfigApiError { + fn from_request_rejection(status: u16, _message: String) -> Self { + Self { + status, + code: "invalid_request".into(), + message: "Invalid configuration request".into(), + classification: WorkspaceConfigFailureClassification::NotCommitted, + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigGrantCreateRequest { + pub runtime_id: String, + pub worker_id: String, + pub access: WorkspaceConfigAccess, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceConfigGrantResponse { + pub grant_id: String, + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: String, + pub working_directory_id: String, + pub access: WorkspaceConfigAccess, + pub revoked: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ApiContract, HttpMethod, ServerApiMetadata}; + + #[test] + fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() { + let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]}); + let response: WorkspaceConfigObserveResponse = + serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), value); + for field in ["revision", "digest", "entrypoints"] { + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err()); + } + let mut with_body = value; + with_body["nodes"][0]["content"] = serde_json::json!("must never appear here"); + assert!(serde_json::from_value::(with_body).is_err()); + assert!( + serde_json::from_value::( + serde_json::json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + serde_json::json!({"grant_id":"invented"}) + ) + .is_err() + ); + assert_eq!( + serde_json::to_value(WorkspaceConfigCurrentResponse(None)).unwrap(), + serde_json::Value::Null + ); + } + + #[test] + fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() { + for classification in [ + WorkspaceConfigFailureClassification::NotCommitted, + WorkspaceConfigFailureClassification::Unknown, + ] { + let error = WorkspaceConfigApiError { + status: 500, + code: "unavailable".into(), + message: "safe".into(), + classification, + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&error).unwrap() + ) + .unwrap(), + error + ); + assert_eq!(api_macros::HttpError::status_code(&error), 500); + } + let error = + ::from_request_rejection( + 413, + "/private/secret".into(), + ); + assert_eq!( + error.classification, + WorkspaceConfigFailureClassification::NotCommitted + ); + assert!(!error.message.contains("private")); + } + + #[test] + fn workspace_config_generated_routes_are_exact_and_grants_are_separate() { + for (name, method, suffix) in [ + ("get", HttpMethod::Get, ""), + ("attach", HttpMethod::Post, ""), + ("observe", HttpMethod::Post, "/observe"), + ("read", HttpMethod::Post, "/read"), + ("commit", HttpMethod::Post, "/commit"), + ] { + let name = format!("current_worker_workspace_config_{name}"); + let operation = ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == name) + .unwrap(); + assert_eq!(operation.method, method); + assert_eq!( + operation.path, + format!("/api/w/{{workspace_id}}/workers/self/workspace-config{suffix}") + ); + } + for (name, method, path) in [ + ( + "workspace_config_grant_create", + HttpMethod::Post, + "/api/w/{workspace_id}/workspace-config-grants", + ), + ( + "workspace_config_grant_revoke", + HttpMethod::Delete, + "/api/w/{workspace_id}/workspace-config-grants/{grant_id}", + ), + ] { + let operation = ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == name) + .unwrap(); + assert_eq!(operation.method, method); + assert_eq!(operation.path, path); + } + } +} diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 962912b63..30e168e14 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -526,6 +526,18 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String { BackendWorkingDirectorySource::Repository { repository_key } => { (repository_key.as_str(), String::new()) } + BackendWorkingDirectorySource::WorkspaceConfig { .. } => { + // Logical config access is not host filesystem or COMMAND authority. + let permissions = &attachment.effective_permissions; + let mut categories = Vec::new(); + if permissions.read { + categories.push("READ"); + } + if permissions.write { + categories.push("EDIT"); + } + ("workspace-config", format!("・{}", categories.join("・"))) + } BackendWorkingDirectorySource::ExternalGrant { .. } => { let permissions = &attachment.effective_permissions; let mut categories = Vec::new(); @@ -860,6 +872,37 @@ mod tests { assert!(!text.contains("active clean")); } + #[test] + fn logical_config_attachment_labels_editability_without_command_or_host_path() { + for (access, write, expected) in [ + ("read_only", false, "READ"), + ("read_write", true, "READ・EDIT"), + ] { + let mut worker = worker("runtime-a", "worker-b", None); + worker.workdir_attachments = vec![serde_json::from_value(serde_json::json!({ + "alias": "workspace-config", + "effective_permissions": {"read": true, "write": write, "command": false}, + "working_directory": { + "working_directory_id": "logical-config-1", + "display_name": "Workspace configuration", + "source": {"kind": "workspace_config", "access": access, + "content_path": "/workspace-config", "purpose": "Workspace configuration"}, + "materializer_kind": "logical_workspace_config", + "status": "active" + } + })).unwrap()]; + let text = working_directory_text(&worker); + assert_eq!( + text, + format!( + "wd:workspace-config:Workspace configuration・{expected}・logical-config-1" + ) + ); + assert!(!text.contains("COMMAND")); + assert!(!text.contains("/workspace-config")); + } + } + #[test] fn worker_rows_align_identity_name_state_and_workdir_columns() { let mut short = worker("runtime-a", "worker-a", None); diff --git a/crates/worker/README.md b/crates/worker/README.md index 0c5015fdf..695b7460a 100644 --- a/crates/worker/README.md +++ b/crates/worker/README.md @@ -25,6 +25,80 @@ Does not own: A Worker is runtime authority, not UI state. It should commit model-visible events through history/session paths and keep current Worker-name state in Worker metadata rather than in transient runtime files. +## Workspace config WIP Feature + +Enable the opt-in surface in a Worker profile/manifest: + +```toml +[feature.workspace_config] +enabled = true +``` + +This setting accepts **only** `enabled` and defaults to false. Controller +registration additionally requires Workspace identity, an available injected +Workspace Client and WIP mode. It creates no grant. An owner must explicitly +create a Backend grant bound to Workspace/Runtime/Worker and read-only or +read-write access; standalone/Tools-mode config compatibility tools do not exist. + +`feature::builtin::workspace_config` registers `/workspace-config` through the +common structured invocation registry (T696). Its optional `access` argument is +`effective` (default), `read_only`, or `read_write`; explicit write never silently +downgrades. Feature preparation attaches before the request reaches the assistant +and reports the logical target and effective access. Native self-attach uses the +same `WorkspaceConfigFeature::attach` path. Backend alias attachment is idempotent +under the session lock; neither entry point blindly retries uncertain outcomes. + +The canonical alias is `workspace-config`; the content entrance is WIP +`/workspace-config`. Attachment identity and conditional detach use the existing +T697 Backend ledger. No OS mount, Workdir session, Bash capability, local config +copy or second attachment inventory is created. The registry source is logical, +not a Runtime filesystem target. + +The request-time provider maps `/workspace-config` to the Backend's empty root +path and descendants to canonical relative source paths, for example +`/workspace-config/main.dcdl` -> `main.dcdl`. Observations return structure, +Backend validators and current operations, **not bodies**. Files publish `read`; +read-write grants additionally publish `write`, unique-substring `edit`, and +`delete`. Directories and explicit absence observations publish `create` where +Backend metadata permits it. The root publishes `apply_changes` for atomic +create/update/delete/rename sets, allowing new imported +sources and their references to be saved together. Empty directory creation, +commands and generic POSIX operations are not exposed. The adapter uses the typed +`workers/self/workspace-config` GET/attach/observe/read/commit API; it never calls +an owner config API as a bypass. + +Client-managed PATH validators are compared against freshly resolved Host +metadata. Each mutation captures the ROOT validator, whole-tree revision/digest, +canonical entrypoints and node state from that same observation. Backend resolves +the live grant/link and validates canonical changes/CAS under its execution lock. +No operation accepts LLM-supplied validators or `expected_digest` fields. Returned +post-call path validators are refreshed from the exact committed revision, not +substituted with root validators or later unrelated state. UI/Worker edits and +same-bytes replacement revisions reject stale calls. Old descriptors cannot +bypass read-only restrictions, detach, revocation or Workspace boundaries. + +Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB +aggregate changed text/read content, and 30-second retrieval/operation deadlines. +Path traversal and malformed parameters fail before content calls. Typed +`NotCommitted` failures remain distinct from `Unknown`; timeouts/lost mutation +responses are unknown and never automatically retried. Raw transport/provider +errors are not copied into model/UI output. Retrieval protocol failures are +reported as failures after recording their Client cache state, not as fresh +successful observations. Saving commits the canonical Workspace configuration +and existing projection/activation pipeline; it does **not** rewrite the resolved +manifests of already-running Workers. + +Public integration seams: `WorkspaceConfigFeature::for_workspace`, +`workspace_config::wip::mount_workspace_config_wip`, and +`WipRuntime::from_mounts` plus `discover`/`inspect`/`call`. Worker tests exercise the +real Feature registry, native mount and WIP Client/Host through a typed in-process +contract router: deep discovery, slash/native attach, read/edit/write, +create/delete, atomic multi-source updates, grant restrictions/revocation, +detach, cross-Workspace isolation, concurrent/same-bytes stale observations, +invalid/oversize/save/unknown failures and no retries. Those contract-router tests +are not a substitute for Backend canonical persistence, semantic validation, +projection activation or full Web-to-Backend acceptance tests. + ## See also - [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index e92b8e4d9..cbd28db20 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1583,6 +1583,15 @@ where ); } let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip; + let workspace_config_feature = + crate::feature::builtin::workspace_config::WorkspaceConfigFeature::configured( + worker.workspace_client_handle(), + feature_config.workspace_config.enabled, + wip_mode, + ); + if let Some(module) = &workspace_config_feature { + feature_registry.add_module(module.clone()); + } let mut workdir_wip_feature = None; if feature_config.manage_workdir.enabled || (wip_mode @@ -1674,6 +1683,18 @@ where let host_worker_observation_provider = worker.worker_observation_provider(); let wip_permissions = worker.manifest().permissions.clone(); let mut wip_mount_registry = crate::wip::WipMountRegistry::new(); + if let Some(feature) = &workspace_config_feature { + crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip( + &mut wip_mount_registry, + feature, + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Workspace config WIP projection: {error}"), + ) + })?; + } if wip_mode && !backend_job_profile { crate::checkout::mount_checkouts( &mut wip_mount_registry, diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index b4b305d35..e4532e63c 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -24,6 +24,7 @@ pub(crate) mod subjektiv_session; pub mod task; pub mod ticket; pub mod worker_observation; +pub mod workspace_config; pub mod workspace_worker_discovery; pub use task::{TaskFeature, task_tools_feature}; diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs index c1a745f79..149a4b684 100644 --- a/crates/worker/src/feature/builtin/manage_workdir/wip.rs +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -77,6 +77,7 @@ struct Provider { permissions: Option, catalog: bool, manage: bool, + config_content: bool, } /// Register Object ownership once, then contribute independent management operations. @@ -93,6 +94,9 @@ pub fn mount_workspace_workdir_wip( permissions, catalog, manage, + config_content: registry + .routes() + .any(|route| route == crate::feature::builtin::workspace_config::CONTENT_ROOT), }; for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] { let namespace = @@ -337,6 +341,20 @@ impl Provider { key.as_str().unwrap_or_default() )); } + if kind == "workspace_config" + && source.get("content_path").and_then(Json::as_str) + == Some(crate::feature::builtin::workspace_config::CONTENT_ROOT) + && matches!( + source.get("access").and_then(Json::as_str), + Some("read_only" | "read_write") + ) + { + for field in ["access", "content_path", "purpose"] { + if let Some(value) = source.get(field) { + projected[field] = value.clone(); + } + } + } result.insert("source".into(), projected); } } @@ -344,7 +362,29 @@ impl Provider { 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))); } - if let Some(alias) = raw.get("alias").and_then(Json::as_str) + // Content references are resolved from current Backend authority, + // never inferred from the alias or an OS session. An old lifetime + // cannot refer to a replacement connection at the same root. + if self.config_content + && raw.get("alias").and_then(Json::as_str) + == Some(crate::feature::builtin::workspace_config::ATTACHMENT_ALIAS) + && let Ok(Some(current)) = + crate::feature::builtin::workspace_config::backend::WorkspaceConfigBackend::new( + self.backend.client.clone(), + ) + .current() + && current.connection_id == id + && raw.get("working_directory_id").and_then(Json::as_str) + == Some(current.working_directory_id.as_str()) + && raw.get("alias").and_then(Json::as_str) == Some(current.alias.as_str()) + { + result.insert("content_path".into(), json!(current.content_path)); + result.insert("name".into(), json!(current.name)); + result.insert("purpose".into(), json!(current.purpose)); + result.insert("access".into(), json!(current.access)); + } + if !result.contains_key("content_path") + && let Some(alias) = raw.get("alias").and_then(Json::as_str) && let Ok(selected) = self.backend.session_router.resolve(Some(alias)) && raw.get("working_directory_id").and_then(Json::as_str) == Some(selected.session.workdir().id().as_str()) @@ -1038,6 +1078,7 @@ pub(crate) mod tests { pub(crate) attachments: Vec, pub(crate) requests: Vec, pub(crate) denied: bool, + config_current: Option, generation: usize, } impl Default for CatalogClient { @@ -1051,6 +1092,7 @@ pub(crate) mod tests { attachments: vec![], requests: vec![], denied: false, + config_current: None, generation: 0, }), } @@ -1088,6 +1130,9 @@ pub(crate) mod tests { .map(|b| serde_json::from_str(b).unwrap()) .unwrap_or(Json::Null); let value = match (request.method, path.split('?').next().unwrap()) { + (WorkspaceRequestMethod::Get, "workers/self/workspace-config") => { + state.config_current.clone().unwrap_or(Json::Null) + } (WorkspaceRequestMethod::Get, "repositories") => { json!({"items":state.repositories}) } @@ -1236,6 +1281,72 @@ pub(crate) mod tests { }) } } + #[test] + fn config_content_reference_requires_enabled_mount_and_exact_current_lifetime() { + let client = Arc::new(CatalogClient::default()); + let current = json!({"workspace_id":"test-workspace", "alias":"workspace-config", "connection_id":"config-lifetime", "working_directory_id":"config-workdir", "access":"read_only", "name":"Workspace configuration", "purpose":"Configuration editing", "content_path":"/workspace-config", "already_attached":true}); + client.state.lock().unwrap().config_current = Some(current); + let raw = json!({"alias":"workspace-config", "connection_id":"config-lifetime", "working_directory_id":"config-workdir", "capabilities":{"bits":25}, "host_path":"/SECRET"}); + let mut p = provider(client.clone(), true, true, None); + assert!( + p.projected(Domain::Attachment, &raw) + .ok() + .unwrap() + .get("content_path") + .is_none() + ); + p.config_content = true; + let mut physical = raw.clone(); + physical["alias"] = json!("checkout"); + assert!( + p.projected(Domain::Attachment, &physical) + .ok() + .unwrap() + .get("content_path") + .is_none() + ); + assert!( + client.state.lock().unwrap().requests.is_empty(), + "physical inventories must not perform a config request per attachment" + ); + let projected = p.projected(Domain::Attachment, &raw).ok().unwrap(); + assert_eq!(projected["content_path"], "/workspace-config"); + assert_eq!(projected["access"], "read_only"); + assert_eq!(projected["purpose"], "Configuration editing"); + assert!(projected.get("checkout_path").is_none()); + assert!(!projected.to_string().contains("SECRET")); + let mut old = raw.clone(); + old["connection_id"] = json!("previous-lifetime"); + assert!( + p.projected(Domain::Attachment, &old) + .ok() + .unwrap() + .get("content_path") + .is_none() + ); + client.state.lock().unwrap().denied = true; + assert!( + p.projected(Domain::Attachment, &raw) + .ok() + .unwrap() + .get("content_path") + .is_none() + ); + } + + #[test] + fn config_workdir_source_preserves_safe_mapping_not_private_management_data() { + let p = provider(Arc::new(CatalogClient::default()), true, false, None); + let raw = json!({"working_directory_id":"config-workdir", "source":{"kind":"workspace_config", "access":"read_write", "content_path":"/workspace-config", "purpose":"Configuration editing", "grant_id":"PRIVATE", "host_path":"/SECRET"}}); + let projected = p.projected(Domain::Workdir, &raw).ok().unwrap(); + assert_eq!( + projected["source"], + json!({"kind":"workspace_config", "access":"read_write", "content_path":"/workspace-config", "purpose":"Configuration editing"}) + ); + assert!(!projected.to_string().contains("PRIVATE")); + assert!(!projected.to_string().contains("SECRET")); + } + fn provider( client: Arc, catalog: bool, @@ -1246,6 +1357,7 @@ pub(crate) mod tests { backend: ManageWorkdirFeature::new(client).backend(), catalog, manage, + config_content: false, permissions, } } @@ -1573,6 +1685,7 @@ mod lifetime_tests { permissions: None, catalog: true, manage: true, + config_content: false, }; let handler = CollectionHandler { provider: p.clone(), @@ -1668,6 +1781,7 @@ mod logical_capability_tests { backend: ManageWorkdirFeature::new(client).backend(), catalog: true, manage: true, + config_content: false, permissions: None, }; let item = ItemHandler { diff --git a/crates/worker/src/feature/builtin/workspace_config.rs b/crates/worker/src/feature/builtin/workspace_config.rs new file mode 100644 index 000000000..e847266b5 --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config.rs @@ -0,0 +1,333 @@ +//! WIP-only, grant-bound Workspace configuration Feature. No local config store +//! or OS Workdir/session is created by this logical attachment. + +pub mod backend; +mod content; +pub mod wip; + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use protocol::{ + FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity, + FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax, + InvocationArgumentDescriptor, InvocationArgumentType, InvocationCompletion, InvocationValue, +}; + +use crate::feature::{ + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext, + FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule, +}; +use crate::worker::WorkspaceClient; + +pub const FEATURE_ID: &str = "workspace-config"; +pub const INVOCATION_ID: &str = "builtin:workspace-config/attach"; +pub const ATTACHMENT_ALIAS: &str = "workspace-config"; +pub const CONTENT_ROOT: &str = "/workspace-config"; +const DEADLINE: Duration = Duration::from_secs(30); + +/// Requested restriction, not an authorization grant. `None` selects the +/// effective Backend grant. Explicit read_write must never silently downgrade. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfigAccess { + ReadOnly, + ReadWrite, +} + +impl ConfigAccess { + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "read_only", + Self::ReadWrite => "read_write", + } + } +} + +fn requested_access(value: Option<&str>) -> Result, ConfigAttachError> { + match value { + None | Some("effective") => Ok(None), + Some("read_only") => Ok(Some(ConfigAccess::ReadOnly)), + Some("read_write") => Ok(Some(ConfigAccess::ReadWrite)), + _ => Err(ConfigAttachError::InvalidRequest), + } +} + +/// Adapter result from the *existing Backend attachment ledger*. No Worker-local +/// attachment registry or authorization state is created from this result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfigAttachment { + pub connection_id: String, + pub access: ConfigAccess, + pub already_attached: bool, +} + +/// Safe diagnostics only. Raw Backend/transport errors may contain host paths or +/// secrets and must be classified by the adapter, never copied into these results. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfigAttachError { + InvalidRequest, + Denied, + Unavailable, + OutcomeUnknown, +} + +impl ConfigAttachError { + fn message(self) -> &'static str { + match self { + Self::InvalidRequest => "Invalid Workspace config attachment request", + Self::Denied => "Workspace config access denied; explicit access is not downgraded", + Self::Unavailable => "Workspace config attachment unavailable before effects", + Self::OutcomeUnknown => { + "Workspace config attachment outcome unknown; inspect current attachments before retrying" + } + } + } + + fn invocation_error(self) -> FeatureInvocationHandlerError { + if self == Self::OutcomeUnknown { + FeatureInvocationHandlerError::outcome_unknown(self.message()) + } else { + FeatureInvocationHandlerError::failed(self.message()) + } + } +} + +/// Narrow application adapter seam, not a config compatibility Tool/API. +/// +/// The production implementation captures an identity-bound WorkspaceClient and +/// uses the shared typed Backend attach operation. Both validation (including +/// T696 replay/resume) and attach must consult current authority. Attach rechecks +/// authorization atomically with ledger mutation, enforces explicit access and +/// alias identity. The execution key identifies the host receipt, not a grant; +/// Backend alias attach is idempotent under its session lock. Never blind retry. +#[async_trait] +pub trait WorkspaceConfigAttachmentBackend: Send + Sync { + fn is_available(&self) -> bool; + fn validate_access(&self, access: Option) -> Result<(), ConfigAttachError>; + async fn attach( + &self, + idempotency_key: &str, + access: Option, + ) -> Result; +} + +/// Shared slash/self-attach handler. Possession of Workdir tools is not a grant. +#[derive(Clone)] +pub struct WorkspaceConfigFeature { + client: Arc, + backend: Arc, + content_backend: Option>, + wip_mode: bool, + permits: Arc, +} + +impl WorkspaceConfigFeature { + /// Host supplies the actual mode; a user invocation cannot enable WIP or + /// change the Workspace/security context captured by the adapter. + pub fn new( + client: Arc, + backend: Arc, + wip_mode: bool, + ) -> Self { + Self { + client, + backend, + content_backend: None, + wip_mode, + permits: Arc::new(tokio::sync::Semaphore::new(16)), + } + } + + /// Controller registration gate: a flag cannot create Backend authority or + /// turn a Tools/standalone Worker into a Workspace WIP Worker. + pub fn configured( + client: Arc, + enabled: bool, + wip_mode: bool, + ) -> Option { + (enabled + && wip_mode + && client.is_available() + && client + .workspace_id() + .is_some_and(|id| !id.is_empty() && !id.chars().any(char::is_control))) + .then(|| Self::for_workspace(client, true)) + } + + /// Production adapter captures the injected identity-bound Workspace client. + pub fn for_workspace(client: Arc, wip_mode: bool) -> Self { + let backend = Arc::new(backend::WorkspaceConfigBackend::new(client.clone())); + let mut feature = Self::new(client, backend.clone(), wip_mode); + feature.content_backend = Some(backend); + feature + } + + pub fn is_available(&self) -> bool { + self.wip_mode + && self.client.is_available() + && self.client.workspace_id().is_some_and(|id| !id.is_empty()) + && self.backend.is_available() + } + + fn validate_access(&self, access: Option) -> Result<(), ConfigAttachError> { + if !self.is_available() { + return Err(ConfigAttachError::Denied); + } + self.backend.validate_access(access) + } + + /// The only attach execution path used by T696 and native WIP. No router + /// session, command capability, or attachment lifetime is synthesized here. + pub async fn attach( + &self, + idempotency_key: &str, + access: Option, + ) -> Result { + if idempotency_key.is_empty() || idempotency_key.len() > 256 { + return Err(ConfigAttachError::InvalidRequest); + } + self.validate_access(access)?; + let _permit = tokio::time::timeout(DEADLINE, self.permits.acquire()) + .await + .map_err(|_| ConfigAttachError::Unavailable)? + .map_err(|_| ConfigAttachError::Unavailable)?; + // Authority can change while queued. Backend still rechecks on execution. + self.validate_access(access)?; + let result = tokio::time::timeout(DEADLINE, self.backend.attach(idempotency_key, access)) + .await + .map_err(|_| ConfigAttachError::OutcomeUnknown)??; + // The adapter must map only a safe lifetime identity from a validated + // Backend result. Invalid completion metadata cannot imply no effects. + if result.connection_id.is_empty() + || result.connection_id.len() > 256 + || !result + .connection_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + || access.is_some_and(|requested| requested != result.access) + { + return Err(ConfigAttachError::OutcomeUnknown); + } + Ok(result) + } +} + +pub fn workspace_config_invocation_descriptor() -> FeatureInvocationDescriptor { + FeatureInvocationDescriptor { + identity: FeatureInvocationIdentity(INVOCATION_ID.into()), + name: FEATURE_ID.into(), + aliases: Vec::new(), + display_name: "Workspace config".into(), + description: "Select this Workspace's logical config attachment before the request reaches the assistant. Backend grants determine access; no host filesystem or command access is added.".into(), + syntax: FeatureInvocationSyntax::Parenthesized, + arguments: vec![InvocationArgumentDescriptor { + name: "access".into(), + position: Some(0), + required: false, + value_type: InvocationArgumentType::Enum { + values: vec!["effective".into(), "read_only".into(), "read_write".into()], + }, + completion: InvocationCompletion::None, + description: Some("Default: effective Backend grant. read_only restricts the connection; explicit read_write fails if unavailable.".into()), + }], + client_adapter: None, + } +} + +impl FeatureModule for WorkspaceConfigFeature { + fn descriptor(&self) -> FeatureDescriptor { + let descriptor = FeatureDescriptor::builtin(FEATURE_ID, "Workspace config") + .with_description("WIP-only logical Workspace config attachment preparation"); + if self.wip_mode && self.client.workspace_id().is_some_and(|id| !id.is_empty()) { + descriptor.with_chat_invocation(workspace_config_invocation_descriptor()) + } else { + descriptor + } + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + if self.wip_mode && self.client.workspace_id().is_some_and(|id| !id.is_empty()) { + context + .chat_invocations() + .register(workspace_config_invocation_descriptor(), self.clone())?; + } + // No tools are declared/installed, including in non-WIP mode. + Ok(()) + } +} + +fn invocation_access( + invocation: &FeatureInvocation, +) -> Result, ConfigAttachError> { + if invocation.identity.0 != INVOCATION_ID + || invocation.name != FEATURE_ID + || invocation.arguments.len() > 1 + { + return Err(ConfigAttachError::InvalidRequest); + } + match invocation.arguments.first() { + None => requested_access(None), + Some(argument) if argument.name == "access" => match &argument.value { + InvocationValue::String(value) => requested_access(Some(value)), + _ => Err(ConfigAttachError::InvalidRequest), + }, + _ => Err(ConfigAttachError::InvalidRequest), + } +} + +#[async_trait] +impl FeatureInvocationHandler for WorkspaceConfigFeature { + fn is_available(&self) -> bool { + WorkspaceConfigFeature::is_available(self) + } + + fn validate( + &self, + invocation: &FeatureInvocation, + ) -> Result<(), FeatureInvocationHandlerError> { + let access = invocation_access(invocation).map_err(ConfigAttachError::invocation_error)?; + self.validate_access(access) + .map_err(ConfigAttachError::invocation_error) + } + + async fn invoke( + &self, + context: FeatureInvocationContext, + invocation: &FeatureInvocation, + ) -> Result { + if context.invocation_id != invocation.invocation_id { + return Err(ConfigAttachError::InvalidRequest.invocation_error()); + } + let access = invocation_access(invocation).map_err(ConfigAttachError::invocation_error)?; + let result = self + .attach(&context.invocation_id, access) + .await + .map_err(ConfigAttachError::invocation_error)?; + let action = if result.already_attached { + "Already attached" + } else { + "Attached" + }; + Ok(FeatureInvocationResult { + invocation_id: context.invocation_id, + identity: invocation.identity.clone(), + status: FeatureInvocationStatus::Succeeded, + message: format!( + "{action}: {ATTACHMENT_ALIAS} (Workspace configuration; {}).", + result.access.as_str() + ), + context: Some(format!( + "Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Discover structure through WIP; read and change bodies through published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.", + result.access.as_str() + )), + }) + } +} + +#[cfg(test)] +#[path = "workspace_config_tests.rs"] +mod tests; + +#[cfg(test)] +mod production_tests; diff --git a/crates/worker/src/feature/builtin/workspace_config/backend.rs b/crates/worker/src/feature/builtin/workspace_config/backend.rs new file mode 100644 index 000000000..97326eb72 --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config/backend.rs @@ -0,0 +1,302 @@ +//! Identity-bound typed Workspace transport. Never retries a dispatched request. +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Serialize, de::DeserializeOwned}; +use server_api::{ + WorkspaceConfigAccess, WorkspaceConfigApiError, WorkspaceConfigAttachRequest, + WorkspaceConfigAttachment, WorkspaceConfigFailureClassification, +}; +use wip_protocol::{ProtocolError, ProtocolErrorCode}; + +use super::{ + ATTACHMENT_ALIAS, CONTENT_ROOT, ConfigAccess, ConfigAttachError, ConfigAttachment, DEADLINE, + WorkspaceConfigAttachmentBackend, +}; +use crate::wip::WipOperationError; +use crate::worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, +}; + +pub const MAX_TEXT_BYTES: usize = 256 * 1024; +pub const MAX_PATH_BYTES: usize = 512; +pub const MAX_NODES: usize = 256; +pub const MAX_DEPTH: u32 = 8; +const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Clone)] +pub struct WorkspaceConfigBackend { + client: Arc, +} + +impl WorkspaceConfigBackend { + pub fn new(client: Arc) -> Self { + Self { client } + } + + fn base(&self) -> Result { + let workspace = self + .client + .workspace_id() + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + failure( + ProtocolErrorCode::PermissionDenied, + "Workspace config authority unavailable", + ) + })?; + let encoded: String = workspace + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') { + (byte as char).to_string() + } else { + format!("%{byte:02X}") + } + }) + .collect(); + Ok(format!("/api/w/{encoded}/workers/self/workspace-config")) + } + + fn request( + &self, + request: WorkspaceRequest, + mutation: bool, + ) -> Result { + let response = self + .client + .execute_with_timeout(request, DEADLINE) + .map_err(|error| { + if mutation && !matches!(error, WorkspaceClientError::InvalidPath(_)) { + unknown() + } else { + failure( + ProtocolErrorCode::Internal, + "Workspace config transport unavailable", + ) + } + })?; + if response.body.len() > MAX_RESPONSE_BYTES { + return Err(if mutation { + unknown() + } else { + failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config response exceeds limit", + ) + }); + } + if !response.is_success() { + if let Ok(error) = serde_json::from_str::(&response.body) { + if error.classification == WorkspaceConfigFailureClassification::Unknown { + return Err(unknown()); + } + // Do not reflect arbitrary transport/provider text into model output. + let code = match error.status { + 401 | 403 | 404 => ProtocolErrorCode::PermissionDenied, + 409 => ProtocolErrorCode::ValidatorMismatch, + 413 | 429 => ProtocolErrorCode::ResourceLimitExceeded, + 400 | 422 => ProtocolErrorCode::InvalidArguments, + _ => ProtocolErrorCode::Internal, + }; + return Err(failure( + code, + "Workspace config request rejected without commit", + )); + } + return Err(if mutation { + unknown() + } else { + failure( + if matches!(response.status, 401 | 403 | 404) { + ProtocolErrorCode::PermissionDenied + } else { + ProtocolErrorCode::Internal + }, + "Workspace config request denied or unavailable", + ) + }); + } + serde_json::from_str(&response.body).map_err(|_| { + if mutation { + unknown() + } else { + failure( + ProtocolErrorCode::Internal, + "Invalid Workspace config response", + ) + } + }) + } + + /// GET checks the current grant even when the Worker has not attached yet. + pub fn current(&self) -> Result, WipOperationError> { + let result: Option = + self.request(WorkspaceRequest::get(self.base()?), false)?; + if let Some(attachment) = &result { + self.validate_attachment(attachment)?; + } + Ok(result) + } + + fn validate_attachment( + &self, + attachment: &WorkspaceConfigAttachment, + ) -> Result<(), WipOperationError> { + if Some(attachment.workspace_id.as_str()) != self.client.workspace_id() + || attachment.alias != ATTACHMENT_ALIAS + || attachment.content_path != CONTENT_ROOT + || attachment.connection_id.is_empty() + || attachment.connection_id.len() > 256 + || !attachment + .connection_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return Err(failure( + ProtocolErrorCode::PermissionDenied, + "Invalid Workspace config attachment identity", + )); + } + Ok(()) + } + + pub async fn post( + &self, + suffix: &str, + request: &R, + mutation: bool, + ) -> Result { + let body = serde_json::to_string(request).map_err(|_| { + failure( + ProtocolErrorCode::InvalidArguments, + "Invalid Workspace config request", + ) + })?; + if body.len() > MAX_RESPONSE_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config request exceeds limit", + )); + } + let request = WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("{}{suffix}", self.base()?), + body, + ); + let this = self.clone(); + tokio::time::timeout( + DEADLINE, + tokio::task::spawn_blocking(move || this.request(request, mutation)), + ) + .await + .map_err(|_| { + if mutation { + unknown() + } else { + failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config request deadline exceeded", + ) + } + })? + .map_err(|_| { + if mutation { + unknown() + } else { + failure( + ProtocolErrorCode::Internal, + "Workspace config transport unavailable", + ) + } + })? + } + + pub async fn current_async( + &self, + ) -> Result, WipOperationError> { + let this = self.clone(); + tokio::time::timeout( + DEADLINE, + tokio::task::spawn_blocking(move || this.current()), + ) + .await + .map_err(|_| { + failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config request deadline exceeded", + ) + })? + .map_err(|_| { + failure( + ProtocolErrorCode::Internal, + "Workspace config transport unavailable", + ) + })? + } +} + +#[async_trait] +impl WorkspaceConfigAttachmentBackend for WorkspaceConfigBackend { + fn is_available(&self) -> bool { + self.client.is_available() + } + fn validate_access(&self, access: Option) -> Result<(), ConfigAttachError> { + let current = self.current().map_err(attach_error)?; + if access == Some(ConfigAccess::ReadWrite) + && current.is_some_and(|a| a.access == WorkspaceConfigAccess::ReadOnly) + { + return Err(ConfigAttachError::Denied); + } + Ok(()) + } + async fn attach( + &self, + _execution_key: &str, + access: Option, + ) -> Result { + // Alias attach is idempotent under the Backend session lock. The T696 + // fenced receipt owns replay; no new idempotency protocol or blind retry. + let request = WorkspaceConfigAttachRequest { + alias: Some(ATTACHMENT_ALIAS.into()), + access: access.map(|a| match a { + ConfigAccess::ReadOnly => WorkspaceConfigAccess::ReadOnly, + ConfigAccess::ReadWrite => WorkspaceConfigAccess::ReadWrite, + }), + }; + let attachment: WorkspaceConfigAttachment = + self.post("", &request, true).await.map_err(attach_error)?; + self.validate_attachment(&attachment) + .map_err(|_| ConfigAttachError::OutcomeUnknown)?; + Ok(ConfigAttachment { + connection_id: attachment.connection_id, + access: match attachment.access { + WorkspaceConfigAccess::ReadOnly => ConfigAccess::ReadOnly, + WorkspaceConfigAccess::ReadWrite => ConfigAccess::ReadWrite, + }, + already_attached: attachment.already_attached, + }) + } +} + +fn attach_error(error: WipOperationError) -> ConfigAttachError { + match error { + WipOperationError::OutcomeUnknown(_) => ConfigAttachError::OutcomeUnknown, + WipOperationError::Protocol(error) => match error.code { + ProtocolErrorCode::PermissionDenied => ConfigAttachError::Denied, + ProtocolErrorCode::InvalidArguments => ConfigAttachError::InvalidRequest, + _ => ConfigAttachError::Unavailable, + }, + _ => ConfigAttachError::Unavailable, + } +} + +pub(super) fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +pub(super) fn unknown() -> WipOperationError { + WipOperationError::OutcomeUnknown("Workspace config outcome unknown; refresh before any subsequent operation; no automatic retry".into()) +} diff --git a/crates/worker/src/feature/builtin/workspace_config/content.rs b/crates/worker/src/feature/builtin/workspace_config/content.rs new file mode 100644 index 000000000..59929eea8 --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config/content.rs @@ -0,0 +1,726 @@ +//! Request-time logical subtree. Metadata is captured per resolution, never +//! cached as authority; authorization and canonical tree CAS stay in Backend. +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::json; +use server_api::{ + ConfigCommitRequest, ConfigContentType, ConfigTreeChange, WorkspaceConfigAccess, + WorkspaceConfigAttachment, WorkspaceConfigCommitRequest, WorkspaceConfigCommitResponse, + WorkspaceConfigNode, WorkspaceConfigNodeKind, WorkspaceConfigObserveRequest, + WorkspaceConfigObserveResponse, WorkspaceConfigReadRequest, WorkspaceConfigReadResponse, +}; +use sha2::{Digest, Sha256}; +use wip_protocol::{ + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, +}; + +use super::backend::{ + MAX_DEPTH, MAX_NODES, MAX_PATH_BYTES, MAX_TEXT_BYTES, WorkspaceConfigBackend, failure, unknown, +}; +use super::{CONTENT_ROOT, WorkspaceConfigFeature}; +use crate::wip::{ + WipCallContext, WipOperationError, WipOperationHandler, WipOperationOutput, WipProjection, + WipProjectionKind, WipSubtreeProvider, contextual_reference, json_to_wip, +}; + +pub(super) struct ConfigProvider { + pub feature: WorkspaceConfigFeature, + pub backend: Arc, +} + +/// Backend tree root is empty; source names are canonical relative config paths. +fn source_path(route: &str) -> Result { + if route == CONTENT_ROOT { + return Ok(String::new()); + } + let path = route + .strip_prefix(&format!("{CONTENT_ROOT}/")) + .ok_or_else(invalid)?; + validate_source(path)?; + Ok(path.into()) +} +fn validate_source(path: &str) -> Result<(), WipOperationError> { + if path.is_empty() + || path.len() > MAX_PATH_BYTES + || path.starts_with('/') + || path.contains('\\') + || path.chars().any(char::is_control) + || path + .split('/') + .any(|s| s.is_empty() || s == "." || s == "..") + { + return Err(invalid()); + } + Ok(()) +} +fn route(path: &str) -> String { + if path.is_empty() { + CONTENT_ROOT.into() + } else { + format!("{CONTENT_ROOT}/{path}") + } +} +fn invalid() -> WipOperationError { + failure( + ProtocolErrorCode::InvalidArguments, + "Invalid Workspace config operation arguments", + ) +} +fn protocol(error: WipOperationError) -> ProtocolError { + match error { + WipOperationError::Protocol(error) => error, + _ => ProtocolError { + code: ProtocolErrorCode::Internal, + message: "Workspace config metadata unavailable".into(), + }, + } +} +/// A denied namespace is absent from observation, not a failure of the whole +/// Worldspace. Do not mask transport, validation or Backend failures as absence. +fn visible_metadata(result: Result) -> Result, ProtocolError> { + match result { + Ok(value) => Ok(Some(value)), + Err(WipOperationError::Protocol(error)) + if error.code == ProtocolErrorCode::PermissionDenied => + { + Ok(None) + } + Err(error) => Err(protocol(error)), + } +} + +fn same_snapshot( + snapshot: &WorkspaceConfigObserveResponse, + other: &WorkspaceConfigObserveResponse, +) -> Result<(), WipOperationError> { + if snapshot.connection_id != other.connection_id + || snapshot.validator != other.validator + || snapshot.revision != other.revision + || snapshot.digest != other.digest + || snapshot.entrypoints != other.entrypoints + { + return Err(failure( + ProtocolErrorCode::ValidatorMismatch, + "Workspace config observation is stale", + )); + } + Ok(()) +} + +async fn observe( + backend: &WorkspaceConfigBackend, + connection: &str, + paths: Vec, + depth: u32, +) -> Result { + if paths.len() > MAX_NODES || depth > MAX_DEPTH { + return Err(invalid()); + } + for path in &paths { + if !path.is_empty() { + validate_source(path)?; + } + } + let result: WorkspaceConfigObserveResponse = backend + .post( + "/observe", + &WorkspaceConfigObserveRequest { + connection_id: connection.into(), + paths: paths.clone(), + depth, + }, + false, + ) + .await?; + if result.connection_id != connection + || result.nodes.len() > MAX_NODES + || result.validator.is_empty() + || result.validator.len() > 4096 + || result.digest.is_empty() + { + return Err(failure( + ProtocolErrorCode::Internal, + "Invalid Workspace config metadata", + )); + } + if result.entrypoints.is_empty() || result.entrypoints.len() > MAX_NODES { + return Err(failure( + ProtocolErrorCode::Internal, + "Invalid Workspace config entrypoints", + )); + } + for path in &result.entrypoints { + validate_source(path)?; + } + let mut seen = BTreeSet::new(); + for node in &result.nodes { + if !node.path.is_empty() { + validate_source(&node.path)?; + } + if !seen.insert(&node.path) + || node.validator.is_empty() + || node.validator.len() > 4096 + || !paths.iter().any(|parent| { + if node.path == *parent { + return true; + } + let suffix = if parent.is_empty() { + Some(node.path.as_str()) + } else { + node.path.strip_prefix(&format!("{parent}/")) + }; + suffix.is_some_and(|s| s.split('/').count() <= depth as usize) + }) + { + return Err(failure( + ProtocolErrorCode::Internal, + "Invalid Workspace config metadata scope", + )); + } + } + Ok(result) +} + +#[async_trait] +impl WipSubtreeProvider for ConfigProvider { + fn max_depth(&self) -> u32 { + MAX_DEPTH + } + fn max_nodes(&self) -> usize { + MAX_NODES + } + + async fn projection(&self, path: &str) -> Result, ProtocolError> { + let source = source_path(path).map_err(protocol)?; + let Some(attachment) = visible_metadata(self.backend.current_async().await)? else { + return Ok(None); + }; + let Some(attachment) = attachment else { + // An authorized but unattached Worker sees only the self-attach entrance. + if path != CONTENT_ROOT { + return Ok(None); + } + let mut projection = super::wip::attach_projection(&self.feature); + projection.interface = contextual_reference(&projection.interface, path); + projection.object.interfaces = vec![projection.interface.clone()]; + return Ok(Some(projection)); + }; + let Some(snapshot) = visible_metadata( + observe( + &self.backend, + &attachment.connection_id, + vec![source.clone()], + 0, + ) + .await, + )? + else { + return Ok(None); + }; + let Some(node) = snapshot.nodes.iter().find(|n| n.path == source).cloned() else { + return Ok(None); + }; + let operations = operations(&node, attachment.access, path == CONTENT_ROOT); + // Missing nodes are explicit absence observations for safe create, not files. + if operations.is_empty() && node.kind == WorkspaceConfigNodeKind::Missing { + return Ok(None); + } + let descriptor = descriptor(&operations); + let interface = contextual_reference("yoi.workspace-config/node/v1", path); + let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec()); + Ok(Some(WipProjection { + route: path.into(), + capability: "workspace-config:content".into(), + kind: WipProjectionKind::Native, + object: Object { + name: path.rsplit('/').next().unwrap_or("workspace-config").into(), + description: Some(format!( + "Logical Workspace config {:?}; bodies are available only through Operations", + node.kind + )), + interfaces: vec![interface.clone()], + r#ref: None, + validator: Some(node.validator.as_bytes().to_vec()), + }, + interface, + descriptor, + interface_validator, + handler: Arc::new(ConfigHandler { + feature: self.feature.clone(), + backend: self.backend.clone(), + attachment, + snapshot, + node, + operations, + }), + })) + } + + async fn children(&self, path: &str) -> Result, ProtocolError> { + let source = source_path(path).map_err(protocol)?; + let Some(Some(attachment)) = visible_metadata(self.backend.current_async().await)? else { + return Ok(Vec::new()); + }; + let Some(snapshot) = visible_metadata( + observe( + &self.backend, + &attachment.connection_id, + vec![source.clone()], + 1, + ) + .await, + )? + else { + return Ok(Vec::new()); + }; + Ok(snapshot + .nodes + .iter() + .filter(|n| n.path != source && n.kind != WorkspaceConfigNodeKind::Missing) + .map(|n| route(&n.path)) + .collect()) + } +} + +fn operations( + node: &WorkspaceConfigNode, + access: WorkspaceConfigAccess, + root: bool, +) -> Vec { + let mut names = Vec::new(); + // Backend operations are a publication ceiling; never publish a write on RO. + for name in ["read", "write", "edit", "create", "delete", "apply_changes"] { + let kind_ok = match name { + "read" | "write" | "edit" | "delete" => node.kind == WorkspaceConfigNodeKind::File, + "apply_changes" => root, + "create" => node.kind != WorkspaceConfigNodeKind::File, + _ => false, + }; + if kind_ok + && (name == "read" || access == WorkspaceConfigAccess::ReadWrite) + && node.operations.iter().any(|n| n == name) + { + names.push(name.into()); + } + } + if root { + names.push("attach".into()); + } + names +} + +fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } +} +fn descriptor(names: &[String]) -> InterfaceDescriptor { + InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), types: Vec::new(), + documentation: Some(Documentation { + summary: "Logical Workspace configuration file operations".into(), + details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()), + }), + operations: names.iter().map(|name| OperationDeclaration { + name: name.clone(), documentation: None, + parameters: match name.as_str() { + "attach" => vec![parameter("access", false, TypeExpr::String)], + "read" | "delete" => vec![], + "write" => vec![parameter("content", true, TypeExpr::String)], + "edit" => vec![parameter("old_string", true, TypeExpr::String), parameter("new_string", true, TypeExpr::String), parameter("replace_all", false, TypeExpr::Boolean)], + "create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)], + "apply_changes" => vec![parameter("changes", true, TypeExpr::Json)], + _ => vec![], + }, + returns: ReturnDeclaration { documentation: None, r#type: TypeExpr::Json }, + }).collect(), + } +} + +struct ConfigHandler { + feature: WorkspaceConfigFeature, + backend: Arc, + attachment: WorkspaceConfigAttachment, + snapshot: WorkspaceConfigObserveResponse, + node: WorkspaceConfigNode, + operations: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum Change { + Create { + path: String, + content: String, + #[serde(default)] + content_type: Option, + }, + Update { + path: String, + content: String, + }, + Delete { + path: String, + }, + Rename { + from: String, + to: String, + }, +} +impl Change { + fn target(&self) -> &str { + match self { + Self::Create { path, .. } | Self::Update { path, .. } | Self::Delete { path } => path, + Self::Rename { from, .. } => from, + } + } +} +fn text(arguments: &BTreeMap, key: &str) -> Result { + match arguments.get(key) { + Some(Value::String(s)) if s.len() <= MAX_TEXT_BYTES => Ok(s.clone()), + _ => Err(invalid()), + } +} +fn content_type(value: Option<&Value>) -> Result { + match value { + None => Ok(ConfigContentType::Decodal), + Some(Value::String(value)) if value == "decodal" => Ok(ConfigContentType::Decodal), + Some(Value::String(value)) if value == "text" => Ok(ConfigContentType::Text), + _ => Err(invalid()), + } +} + +impl ConfigHandler { + async fn read(&self) -> Result { + let response: WorkspaceConfigReadResponse = self + .backend + .post( + "/read", + &WorkspaceConfigReadRequest { + connection_id: self.attachment.connection_id.clone(), + path: self.node.path.clone(), + validator: self.node.validator.clone(), + }, + false, + ) + .await?; + if response.path != self.node.path + || response.validator != self.node.validator + || Some(&response.digest) != self.node.digest.as_ref() + || Some(&response.content_type) != self.node.content_type.as_ref() + || response.content.len() > MAX_TEXT_BYTES + { + return Err(failure( + ProtocolErrorCode::Internal, + "Invalid Workspace config read response", + )); + } + Ok(response) + } + + async fn canonical_changes( + &self, + changes: Vec, + ) -> Result, WipOperationError> { + if changes.is_empty() || changes.len() > MAX_NODES { + return Err(invalid()); + } + let mut paths = BTreeSet::new(); + let mut text_bytes = 0usize; + for change in &changes { + validate_source(change.target())?; + if !paths.insert(change.target().to_owned()) { + return Err(invalid()); + } + match change { + Change::Create { content, .. } | Change::Update { content, .. } => { + text_bytes = text_bytes.saturating_add(content.len()); + } + Change::Rename { to, .. } => { + validate_source(to)?; + } + _ => {} + } + } + if text_bytes > MAX_TEXT_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config text exceeds limit", + )); + } + // Resolve canonical per-file preconditions at the SAME captured tree revision. + let metadata = observe( + &self.backend, + &self.attachment.connection_id, + paths.into_iter().collect(), + 0, + ) + .await?; + same_snapshot(&self.snapshot, &metadata)?; + changes + .into_iter() + .map(|change| { + let node = metadata + .nodes + .iter() + .find(|n| n.path == change.target()) + .ok_or_else(invalid)?; + let digest = || { + node.digest + .clone() + .filter(|_| node.kind == WorkspaceConfigNodeKind::File) + .ok_or_else(|| { + failure( + ProtocolErrorCode::ValidatorMismatch, + "Workspace config file no longer exists", + ) + }) + }; + Ok(match change { + Change::Create { + path, + content, + content_type, + } => { + if node.kind != WorkspaceConfigNodeKind::Missing { + return Err(failure( + ProtocolErrorCode::ValidatorMismatch, + "Workspace config create target already exists", + )); + } + ConfigTreeChange::Create { + path, + content, + content_type: content_type.unwrap_or(ConfigContentType::Decodal), + } + } + Change::Update { path, content } => ConfigTreeChange::Update { + path, + content, + expected_digest: digest()?, + }, + Change::Delete { path } => ConfigTreeChange::Delete { + path, + expected_digest: digest()?, + }, + Change::Rename { from, to } => ConfigTreeChange::Rename { + from, + to, + expected_digest: digest()?, + }, + }) + }) + .collect() + } +} + +#[async_trait] +impl WipOperationHandler for ConfigHandler { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let dispatched = std::sync::atomic::AtomicBool::new(false); + tokio::time::timeout( + super::DEADLINE, + self.execute(operation, arguments, context, &dispatched), + ) + .await + .map_err(|_| { + if dispatched.load(std::sync::atomic::Ordering::Acquire) { + unknown() + } else { + failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config operation deadline exceeded before effects", + ) + } + })? + } +} + +impl ConfigHandler { + async fn execute( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + dispatched: &std::sync::atomic::AtomicBool, + ) -> Result { + if !self.operations.iter().any(|name| name == operation) { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "Workspace config operation is not published", + )); + } + // Direct handler calls also enforce parameter shape; no model validators accepted. + let declaration = descriptor(&self.operations) + .operations + .into_iter() + .find(|op| op.name == operation) + .ok_or_else(invalid)?; + if arguments + .keys() + .any(|name| !declaration.parameters.iter().any(|p| p.name == *name)) + { + return Err(invalid()); + } + if operation == "attach" { + dispatched.store(true, std::sync::atomic::Ordering::Release); + return super::wip::attach_projection(&self.feature) + .handler + .call(operation, arguments, context) + .await; + } + if operation == "read" { + let result = self.read().await?; + // Validator goes to Client state, not the model operation result. + let value = json_to_wip(&json!({ + "path": result.path, "content": result.content, + "content_type": result.content_type, "digest": result.digest, + })) + .map_err(|_| invalid())?; + return Ok(WipOperationOutput::native_with_validator( + value, + self.node.validator.as_bytes().to_vec(), + )); + } + if self.attachment.access != WorkspaceConfigAccess::ReadWrite { + return Err(failure( + ProtocolErrorCode::PermissionDenied, + "Workspace config write denied", + )); + } + let changes = match operation { + "write" => vec![Change::Update { + path: self.node.path.clone(), + content: text(arguments, "content")?, + }], + "edit" => { + let original = self.read().await?.content; + let old = text(arguments, "old_string")?; + let new = text(arguments, "new_string")?; + let all = match arguments.get("replace_all") { + None => false, + Some(Value::Boolean(all)) => *all, + _ => return Err(invalid()), + }; + if old.is_empty() { + return Err(invalid()); + } + let count = original.matches(&old).count(); + if count == 0 || (!all && count != 1) { + return Err(invalid()); + } + let count = if all { count } else { 1 }; + let size = original + .len() + .checked_sub(count.saturating_mul(old.len())) + .and_then(|n| n.checked_add(count.checked_mul(new.len())?)) + .filter(|n| *n <= MAX_TEXT_BYTES); + if size.is_none() { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config edit exceeds text limit", + )); + } + let content = if all { + original.replace(&old, &new) + } else { + original.replacen(&old, &new, 1) + }; + vec![Change::Update { + path: self.node.path.clone(), + content, + }] + } + "delete" => vec![Change::Delete { + path: self.node.path.clone(), + }], + "create" => { + let path = match (self.node.kind, arguments.get("path")) { + (WorkspaceConfigNodeKind::Missing, None) => self.node.path.clone(), + (WorkspaceConfigNodeKind::Directory, Some(Value::String(path))) => { + validate_source(path)?; + if self.node.path.is_empty() { + path.clone() + } else { + format!("{}/{path}", self.node.path) + } + } + _ => return Err(invalid()), + }; + vec![Change::Create { + path, + content: text(arguments, "content")?, + content_type: Some(content_type(arguments.get("content_type"))?), + }] + } + "apply_changes" => { + let Some(value) = arguments.get("changes") else { + return Err(invalid()); + }; + serde_json::from_value::>( + crate::wip::wip_to_json(value).map_err(|_| invalid())?, + ) + .map_err(|_| invalid())? + } + _ => return Err(invalid()), + }; + let changes = self.canonical_changes(changes).await?; + dispatched.store(true, std::sync::atomic::Ordering::Release); + let result: WorkspaceConfigCommitResponse = self + .backend + .post( + "/commit", + &WorkspaceConfigCommitRequest { + connection_id: self.attachment.connection_id.clone(), + validator: self.snapshot.validator.clone(), + request: ConfigCommitRequest { + base_revision: self.snapshot.revision, + base_digest: self.snapshot.digest.clone(), + changes, + entrypoints: self.snapshot.entrypoints.clone(), + }, + }, + true, + ) + .await?; + // Commit response supplies ROOT state. Obtain the exact post-commit PATH + // validator, never substitute the root validator for a file validator. + let updated = observe( + &self.backend, + &self.attachment.connection_id, + vec![self.node.path.clone()], + 0, + ) + .await + .map_err(|_| unknown())?; + if updated.revision != result.revision + || updated.digest != result.digest + || updated.validator != result.validator + { + return Err(unknown()); + } + let node = updated + .nodes + .iter() + .find(|n| n.path == self.node.path) + .ok_or_else(unknown)?; + let value = json_to_wip(&json!({"revision": result.revision, "digest": result.digest, + "activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?; + Ok(WipOperationOutput::native_with_validator( + value, + node.validator.as_bytes().to_vec(), + )) + } +} diff --git a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs new file mode 100644 index 000000000..29e61dcc5 --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs @@ -0,0 +1,884 @@ +//! Worker-side E2E across real Feature invocation, WIP Client/Host and typed +//! Workspace transport. Canonical persistence/projection tests live in Backend; +//! this deterministic router models its published auth/CAS/response contract. +use super::*; +use crate::feature::FeatureRegistryBuilder; +use crate::hook::HookRegistryBuilder; +use crate::wip::{WipMountRegistry, WipRuntime}; +use crate::worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + WorkspaceResponse, +}; +use agen::tool::ToolExecutionContext; +use protocol::{FeatureInvocation, FeatureInvocationIdentity}; +use serde::Serialize; +use serde_json::{Value as Json, json}; +use server_api::*; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug)] +struct State { + granted: bool, + read_only: bool, + active: Option, + next_connection: u64, + revision: u64, + entries: BTreeMap, + commits: Vec, + requests: Vec, + fail_commit: bool, + fail_current: bool, + lose_commit_reply: bool, + race_commit: bool, +} +#[derive(Debug)] +struct Router { + workspace: &'static str, + state: Arc>, +} +impl Router { + fn new() -> Arc { + Arc::new(Self { + workspace: "workspace-1", + state: Arc::new(Mutex::new(State { + granted: true, + read_only: false, + active: None, + next_connection: 0, + revision: 1, + entries: BTreeMap::from([ + ( + "main.dcdl".into(), + (ConfigContentType::Decodal, "model = old".into()), + ), + ( + "profiles/team/deep/model.dcdl".into(), + (ConfigContentType::Decodal, "model = deep".into()), + ), + ]), + commits: Vec::new(), + requests: Vec::new(), + fail_commit: false, + fail_current: false, + lose_commit_reply: false, + race_commit: false, + })), + }) + } +} +fn response(value: &T) -> WorkspaceResponse { + WorkspaceResponse { + status: 200, + body: serde_json::to_string(value).unwrap(), + } +} +fn denied(status: u16, classification: WorkspaceConfigFailureClassification) -> WorkspaceResponse { + // Deliberately unsafe provider text must never be propagated by the adapter. + WorkspaceResponse { + status, + body: serde_json::to_string(&WorkspaceConfigApiError { + status, + code: "test".into(), + message: "secret-token /private/host/config".into(), + classification, + }) + .unwrap(), + } +} +fn digest(content: &str) -> String { + Sha256::digest(content.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} +impl State { + fn tree_digest(&self) -> String { + digest(&serde_json::to_string(&self.entries).unwrap()) + } + fn validator(&self, path: &str) -> String { + format!( + "wc:{}:{}:{}:{}", + self.active.as_ref().unwrap().connection_id, + self.revision, + self.tree_digest(), + path + ) + } + fn node(&self, path: &str, kind: WorkspaceConfigNodeKind) -> WorkspaceConfigNode { + let mut operations = match kind { + WorkspaceConfigNodeKind::File => vec!["read".into()], + _ => vec![], + }; + if !self.read_only { + match kind { + WorkspaceConfigNodeKind::File => { + operations.extend(["write", "edit", "delete"].map(str::to_string)) + } + WorkspaceConfigNodeKind::Missing => operations.push("create".into()), + WorkspaceConfigNodeKind::Directory => { + operations.push("create".into()); + if path.is_empty() { + operations.push("apply_changes".into()); + } + } + } + } + WorkspaceConfigNode { + path: path.into(), + kind, + validator: self.validator(path), + digest: self.entries.get(path).map(|(_, s)| digest(s)), + content_type: self.entries.get(path).map(|(t, _)| *t), + operations, + } + } +} +impl WorkspaceClient for Router { + fn workspace_id(&self) -> Option<&str> { + Some(self.workspace) + } + fn kind(&self) -> &str { + "typed-config-router" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let mut state = self.state.lock().unwrap(); + state.requests.push(request.clone()); + let prefix = "/api/w/workspace-1/workers/self/workspace-config"; + if self.workspace != "workspace-1" || !request.path.starts_with(prefix) || !state.granted { + return Ok(denied( + 403, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + let suffix = request.path.strip_prefix(prefix).unwrap(); + if suffix.is_empty() { + if request.method == WorkspaceRequestMethod::Get { + if state.fail_current { + return Ok(WorkspaceResponse { + status: 503, + body: "unsafe provider diagnostic /private/host/config".into(), + }); + } + return Ok(response(&state.active)); + } + let attach: WorkspaceConfigAttachRequest = + serde_json::from_str(request.body.as_ref().unwrap()).unwrap(); + assert_eq!(attach.alias.as_deref(), Some(ATTACHMENT_ALIAS)); + if attach.access == Some(WorkspaceConfigAccess::ReadWrite) && state.read_only { + return Ok(denied( + 403, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + let already_attached = state.active.is_some(); + if !already_attached { + state.next_connection += 1; + } + let access = attach.access.unwrap_or(if state.read_only { + WorkspaceConfigAccess::ReadOnly + } else { + WorkspaceConfigAccess::ReadWrite + }); + state.read_only |= access == WorkspaceConfigAccess::ReadOnly; + let attachment = WorkspaceConfigAttachment { + workspace_id: "workspace-1".into(), + connection_id: format!("connection-{}", state.next_connection), + alias: ATTACHMENT_ALIAS.into(), + working_directory_id: "logical-grant-1".into(), + access, + name: "Workspace configuration".into(), + purpose: "Canonical config".into(), + content_path: CONTENT_ROOT.into(), + already_attached, + }; + state.active = Some(attachment.clone()); + return Ok(response(&attachment)); + } + let input: Json = serde_json::from_str(request.body.as_ref().unwrap()).unwrap(); + if state.active.as_ref().map(|a| a.connection_id.as_str()) + != input["connection_id"].as_str() + { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + match suffix { + "/observe" => { + let request: WorkspaceConfigObserveRequest = serde_json::from_value(input).unwrap(); + assert!(request.depth <= 8 && request.paths.len() <= 256); + let mut kinds = + BTreeMap::from([(String::new(), WorkspaceConfigNodeKind::Directory)]); + for path in state.entries.keys() { + kinds.insert(path.clone(), WorkspaceConfigNodeKind::File); + let mut path = path.as_str(); + while let Some((parent, _)) = path.rsplit_once('/') { + kinds + .entry(parent.into()) + .or_insert(WorkspaceConfigNodeKind::Directory); + path = parent; + } + } + let mut selected = BTreeSet::new(); + for path in &request.paths { + selected.insert(path.clone()); + let prefix = if path.is_empty() { + String::new() + } else { + format!("{path}/") + }; + for child in kinds.keys() { + if let Some(suffix) = child.strip_prefix(&prefix) + && child != path + && suffix.split('/').count() <= request.depth as usize + { + selected.insert(child.clone()); + } + } + } + if selected.len() > 256 { + return Ok(denied( + 413, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + let nodes = selected + .into_iter() + .map(|p| { + state.node( + &p, + *kinds.get(&p).unwrap_or(&WorkspaceConfigNodeKind::Missing), + ) + }) + .collect(); + Ok(response(&WorkspaceConfigObserveResponse { + connection_id: state.active.as_ref().unwrap().connection_id.clone(), + validator: state.validator(""), + revision: state.revision, + digest: state.tree_digest(), + entrypoints: vec!["main.dcdl".into()], + nodes, + })) + } + "/read" => { + let request: WorkspaceConfigReadRequest = serde_json::from_value(input).unwrap(); + if request.validator != state.validator(&request.path) { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + let Some((content_type, content)) = state.entries.get(&request.path) else { + return Ok(denied( + 404, + WorkspaceConfigFailureClassification::NotCommitted, + )); + }; + Ok(response(&WorkspaceConfigReadResponse { + path: request.path.clone(), + content: content.clone(), + content_type: *content_type, + digest: digest(content), + validator: state.validator(&request.path), + })) + } + "/commit" => { + let request: WorkspaceConfigCommitRequest = serde_json::from_value(input).unwrap(); + state.commits.push(request.clone()); + if state.race_commit { + state.revision += 1; + state.race_commit = false; + } + if state.read_only { + return Ok(denied( + 403, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + assert_eq!(request.request.entrypoints, ["main.dcdl"]); + if request.validator != state.validator("") + || request.request.base_revision != state.revision + || request.request.base_digest != state.tree_digest() + { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + if state.fail_commit { + return Ok(denied( + 500, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + let mut entries = state.entries.clone(); + for change in request.request.changes { + match change { + ConfigTreeChange::Create { + path, + content_type, + content, + } => { + if entries.contains_key(&path) { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + entries.insert(path, (content_type, content)); + } + ConfigTreeChange::Update { + path, + expected_digest, + content, + } => { + let Some((kind, old)) = entries.get(&path) else { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + }; + if digest(old) != expected_digest { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + entries.insert(path, (*kind, content)); + } + ConfigTreeChange::Delete { + path, + expected_digest, + } => { + if entries + .get(&path) + .is_none_or(|(_, s)| digest(s) != expected_digest) + { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + entries.remove(&path); + } + ConfigTreeChange::Rename { + from, + to, + expected_digest, + } => { + if entries + .get(&from) + .is_none_or(|(_, s)| digest(s) != expected_digest) + || entries.contains_key(&to) + { + return Ok(denied( + 409, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + let entry = entries.remove(&from).unwrap(); + entries.insert(to, entry); + } + } + } + if entries.values().any(|(_, s)| s == "INVALID") { + return Ok(denied( + 400, + WorkspaceConfigFailureClassification::NotCommitted, + )); + } + state.entries = entries; + state.revision += 1; + if state.lose_commit_reply { + state.lose_commit_reply = false; + return Err(WorkspaceClientError::Request( + "secret-token /private/host/config".into(), + )); + } + Ok(response(&WorkspaceConfigCommitResponse { + validator: state.validator(""), + revision: state.revision, + digest: state.tree_digest(), + })) + } + _ => panic!("unregistered endpoint: {suffix}"), + } + } +} +fn invocation(key: &str) -> FeatureInvocation { + FeatureInvocation { + invocation_id: key.into(), + identity: FeatureInvocationIdentity(INVOCATION_ID.into()), + name: FEATURE_ID.into(), + arguments: vec![], + } +} +fn runtime(client: Arc) -> (WorkspaceConfigFeature, WipRuntime) { + let feature = WorkspaceConfigFeature::for_workspace(client, true); + let mut mounts = WipMountRegistry::new(); + wip::mount_workspace_config_wip(&mut mounts, &feature).unwrap(); + let runtime = WipRuntime::from_mounts(mounts, "worker-1@workspace-1".into()).unwrap(); + (feature, runtime) +} +fn interface(path: &str, attached: bool) -> String { + crate::wip::contextual_reference( + if attached { + "yoi.workspace-config/node/v1" + } else { + "yoi.workspace-config/attachment/v1" + }, + path, + ) +} +async fn observe_interface(runtime: &WipRuntime, path: &str, attached: bool) { + runtime.discover(path.into(), 0, true).await.unwrap(); + runtime + .inspect(interface(path, attached), true) + .await + .unwrap(); +} +async fn call( + runtime: &WipRuntime, + path: &str, + op: &str, + args: Json, +) -> Result { + runtime + .call( + path.into(), + interface(path, true), + op.into(), + args, + ToolExecutionContext::direct(), + ) + .await +} + +#[tokio::test] +async fn workspace_config_production_slash_deep_discover_read_edit_and_atomic_import_changes() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + let mut tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(feature) + .install_into_pending(&mut tools, &mut HookRegistryBuilder::default()); + assert!(tools.is_empty() && !report.has_errors()); + assert_eq!( + report + .chat_invocations + .feature_completions("workspace-c") + .len(), + 1 + ); + assert!( + report + .chat_invocations + .invoke(&invocation("slash-1")) + .await + .unwrap() + .message + .starts_with("Attached") + ); + assert!( + report + .chat_invocations + .invoke(&invocation("slash-2")) + .await + .unwrap() + .message + .starts_with("Already attached") + ); + let discovered = runtime + .discover(CONTENT_ROOT.into(), 5, true) + .await + .unwrap() + .content + .unwrap(); + assert!(discovered.contains("profiles/team/deep/model.dcdl")); + assert!(!discovered.contains("model = deep")); + let path = "/workspace-config/profiles/team/deep/model.dcdl"; + observe_interface(&runtime, path, true).await; + assert!( + call(&runtime, path, "read", json!({})) + .await + .unwrap() + .content + .unwrap() + .contains("model = deep") + ); + call( + &runtime, + path, + "edit", + json!({"old_string":"deep","new_string":"changed"}), + ) + .await + .unwrap(); + assert_eq!( + client.state.lock().unwrap().entries["profiles/team/deep/model.dcdl"].1, + "model = changed" + ); + // Client receives the new path validator automatically, no model tracking. + call(&runtime, path, "write", json!({"content":"model = second"})) + .await + .unwrap(); + observe_interface(&runtime, CONTENT_ROOT, true).await; + call( + &runtime, + CONTENT_ROOT, + "apply_changes", + json!({"changes":[ + {"kind":"create","path":"profiles/new.dcdl","content":"model = imported"}, + {"kind":"update","path":"main.dcdl","content":"import profiles/new.dcdl"} + ]}), + ) + .await + .unwrap(); + let state = client.state.lock().unwrap(); + assert_eq!(state.entries["main.dcdl"].1, "import profiles/new.dcdl"); + assert_eq!(state.entries["profiles/new.dcdl"].1, "model = imported"); + assert_eq!(state.commits.last().unwrap().request.changes.len(), 2); + assert!( + state + .requests + .iter() + .all(|r| !r.path.contains("session") && !r.path.contains("bash")) + ); +} + +#[tokio::test] +async fn workspace_config_production_native_attach_create_delete_and_detach_end_use() { + let client = Router::new(); + let (_, runtime) = runtime(client.clone()); + observe_interface(&runtime, CONTENT_ROOT, false).await; + runtime + .call( + CONTENT_ROOT.into(), + interface(CONTENT_ROOT, false), + "attach".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let directory = "/workspace-config/profiles"; + observe_interface(&runtime, directory, true).await; + call( + &runtime, + directory, + "create", + json!({"path":"notes.txt", "content_type":"text", "content":"configuration notes"}), + ) + .await + .unwrap(); + assert_eq!( + client.state.lock().unwrap().entries["profiles/notes.txt"].0, + ConfigContentType::Text + ); + let path = "/workspace-config/new/nested/file.dcdl"; + observe_interface(&runtime, path, true).await; + call(&runtime, path, "create", json!({"content":"new = true"})) + .await + .unwrap(); + observe_interface(&runtime, path, true).await; + call(&runtime, path, "delete", json!({})).await.unwrap(); + assert!( + !client + .state + .lock() + .unwrap() + .entries + .contains_key("new/nested/file.dcdl") + ); + client.state.lock().unwrap().active = None; + assert!( + call(&runtime, "/workspace-config/main.dcdl", "read", json!({})) + .await + .is_err() + ); + assert!( + runtime + .discover("/workspace-config/main.dcdl".into(), 0, true) + .await + .is_err() + ); +} + +#[tokio::test] +async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_isolation() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("grant", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + observe_interface(&runtime, path, true).await; + client.state.lock().unwrap().granted = false; + assert!(call(&runtime, path, "read", json!({})).await.is_err()); + assert!(feature.attach("revoked", None).await.is_err()); + client.state.lock().unwrap().granted = true; + client.state.lock().unwrap().read_only = true; + client.state.lock().unwrap().active.as_mut().unwrap().access = WorkspaceConfigAccess::ReadOnly; + assert!( + feature + .attach("explicit-write", Some(ConfigAccess::ReadWrite)) + .await + .is_err() + ); + observe_interface(&runtime, path, true).await; + let descriptor = runtime + .inspect(interface(path, true), true) + .await + .unwrap() + .content + .unwrap(); + let descriptor: Json = serde_json::from_str(&descriptor).unwrap(); + let names = descriptor["descriptor"]["operations"] + .as_array() + .unwrap() + .iter() + .map(|op| op["name"].as_str().unwrap()) + .collect::>(); + assert_eq!(names, ["read"]); + assert!( + call(&runtime, path, "write", json!({"content":"DENIED"})) + .await + .is_err() + ); + let cross = Arc::new(Router { + workspace: "other-workspace", + state: client.state.clone(), + }); + let (feature, other) = runtime_for_cross(cross); + assert!(feature.attach("cross", None).await.is_err()); + assert!(other.discover(path.into(), 0, false).await.is_err()); + assert!(client.state.lock().unwrap().commits.is_empty()); +} +fn runtime_for_cross(client: Arc) -> (WorkspaceConfigFeature, WipRuntime) { + runtime(client) +} + +#[tokio::test] +async fn workspace_config_production_stale_same_bytes_and_commit_race_never_overwrite() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("initial", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + observe_interface(&runtime, path, true).await; + client.state.lock().unwrap().revision += 1; // Same bytes, different identity revision. + assert!( + call(&runtime, path, "write", json!({"content":"overwritten"})) + .await + .is_err() + ); + assert!(client.state.lock().unwrap().commits.is_empty()); + observe_interface(&runtime, path, true).await; + client.state.lock().unwrap().race_commit = true; + assert!( + call(&runtime, path, "write", json!({"content":"raced"})) + .await + .is_err() + ); + assert_eq!( + client.state.lock().unwrap().entries["main.dcdl"].1, + "model = old" + ); + assert_eq!(client.state.lock().unwrap().commits.len(), 1); +} + +#[tokio::test] +async fn workspace_config_production_invalid_size_save_failure_unknown_are_safe_and_not_retried() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("initial", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + for content in ["INVALID".into(), "x".repeat(256 * 1024 + 1)] { + observe_interface(&runtime, path, true).await; + let error = call(&runtime, path, "write", json!({"content":content})) + .await + .unwrap_err() + .to_string(); + assert!(!error.contains("secret-token") && !error.contains("/private/host")); + assert_eq!( + client.state.lock().unwrap().entries["main.dcdl"].1, + "model = old" + ); + } + observe_interface(&runtime, path, true).await; + let before = client.state.lock().unwrap().commits.len(); + assert!( + call( + &runtime, + path, + "write", + json!({"content":"valid","validator":"model-supplied"}) + ) + .await + .is_err() + ); + assert_eq!(before, client.state.lock().unwrap().commits.len()); + client.state.lock().unwrap().fail_commit = true; + assert!( + call(&runtime, path, "write", json!({"content":"valid"})) + .await + .is_err() + ); + assert_eq!( + client.state.lock().unwrap().entries["main.dcdl"].1, + "model = old" + ); + client.state.lock().unwrap().fail_commit = false; + observe_interface(&runtime, path, true).await; + client.state.lock().unwrap().lose_commit_reply = true; + let count = client.state.lock().unwrap().commits.len(); + let error = call( + &runtime, + path, + "write", + json!({"content":"saved-but-reply-lost"}), + ) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("unknown")); + assert!(!error.contains("secret-token") && !error.contains("/private/host")); + assert_eq!(client.state.lock().unwrap().commits.len(), count + 1); + assert_eq!( + client.state.lock().unwrap().entries["main.dcdl"].1, + "saved-but-reply-lost" + ); +} + +#[tokio::test] +async fn workspace_config_production_bounds_and_traversal_reject_before_content_calls() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("initial", None).await.unwrap(); + assert!( + runtime + .discover(CONTENT_ROOT.into(), 9, true) + .await + .is_err() + ); + for path in [ + "/workspace-config/../secret", + "/workspace-config//main.dcdl", + "/workspace-config/./main.dcdl", + ] { + assert!(runtime.discover(path.into(), 0, true).await.is_err()); + } + let path = format!("/workspace-config/{}", "a".repeat(513)); + assert!(runtime.discover(path, 0, true).await.is_err()); + assert!(client.state.lock().unwrap().commits.is_empty()); +} + +#[tokio::test] +async fn workspace_config_production_reattach_new_lifetime_rejects_old_same_bytes_cache() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + let first = feature.attach("first", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + observe_interface(&runtime, path, true).await; + client.state.lock().unwrap().active = None; + let second = feature.attach("second", None).await.unwrap(); + assert_ne!(first.connection_id, second.connection_id); + assert!( + call(&runtime, path, "write", json!({"content":"bad"})) + .await + .is_err() + ); + assert!(client.state.lock().unwrap().commits.is_empty()); + observe_interface(&runtime, path, true).await; + call(&runtime, path, "write", json!({"content":"new-lifetime"})) + .await + .unwrap(); + assert_eq!( + client.state.lock().unwrap().entries["main.dcdl"].1, + "new-lifetime" + ); +} + +#[tokio::test] +async fn workspace_config_production_cumulative_node_limit_and_edit_growth_are_bounded() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("initial", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + observe_interface(&runtime, path, true).await; + let before = client.state.lock().unwrap().commits.len(); + assert!( + call( + &runtime, + path, + "edit", + json!({ + "old_string":"o", "new_string":"x".repeat(256*1024), "replace_all":true + }) + ) + .await + .is_err() + ); + assert_eq!(client.state.lock().unwrap().commits.len(), before); + { + let mut state = client.state.lock().unwrap(); + for i in 0..90 { + state.entries.insert( + format!("groups/{i}/nested/f.dcdl"), + (ConfigContentType::Decodal, "x = true".into()), + ); + } + } + // No individual depth-1 response exceeds the cap; the whole Host traversal + // must nevertheless stop before exceeding 256 Objects. + let error = runtime + .discover(CONTENT_ROOT.into(), 4, true) + .await + .unwrap_err(); + assert!(error.to_string().contains("ResourceLimitExceeded")); +} + +#[tokio::test] +async fn workspace_config_production_backend_failure_is_not_hidden_as_unauthorized_absence() { + let client = Router::new(); + client.state.lock().unwrap().fail_current = true; + let (_, runtime) = runtime(client); + let error = runtime + .discover(CONTENT_ROOT.into(), 0, true) + .await + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("Internal"), "{message}"); + assert!(!message.contains("/private/host/config")); +} + +#[tokio::test] +async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_discovery() { + let client = Router::new(); + client.state.lock().unwrap().granted = false; + let (_, runtime) = runtime(client.clone()); + let discovery = runtime.discover("/".into(), 2, true).await.unwrap(); + let discovery: Json = serde_json::from_str(discovery.content.as_deref().unwrap()).unwrap(); + assert_eq!(discovery["known_space"].as_array().unwrap().len(), 1); + assert_eq!(discovery["known_space"][0]["path"], "/"); + assert!( + runtime + .discover(CONTENT_ROOT.into(), 0, true) + .await + .is_err() + ); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|r| r.method == WorkspaceRequestMethod::Get) + ); +} diff --git a/crates/worker/src/feature/builtin/workspace_config/wip.rs b/crates/worker/src/feature/builtin/workspace_config/wip.rs new file mode 100644 index 000000000..946c89eee --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config/wip.rs @@ -0,0 +1,182 @@ +//! Shared native self-attach entrance and production request-time config subtree. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; +use sha2::{Digest, Sha256}; +use wip_protocol::{ + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, +}; + +use super::{ + ATTACHMENT_ALIAS, CONTENT_ROOT, ConfigAttachError, WorkspaceConfigFeature, requested_access, +}; +use crate::feature::builtin::manage_workdir::wip::encode_identity; +use crate::wip::{ + WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, +}; + +/// Mount the Feature's native attachment entrance and, for the production +/// adapter, the grant-bound config subtree. No static file inventory is created. +pub fn mount_workspace_config_wip( + registry: &mut WipMountRegistry, + feature: &WorkspaceConfigFeature, +) -> Result<(), WipMountError> { + mount_workspace_config_attach_wip(registry, feature)?; + if feature.wip_mode + && feature + .client + .workspace_id() + .is_some_and(|id| !id.is_empty()) + && let Some(backend) = &feature.content_backend + { + registry.mount_subtree(crate::wip::WipSubtreeMount { + root: CONTENT_ROOT.into(), + provider: Arc::new(super::content::ConfigProvider { + feature: feature.clone(), + backend: backend.clone(), + }), + })?; + } + Ok(()) +} + +/// Shared root registration; no OS session or duplicate attachment inventory. +pub fn mount_workspace_config_attach_wip( + registry: &mut WipMountRegistry, + feature: &WorkspaceConfigFeature, +) -> Result<(), WipMountError> { + if !feature.wip_mode || feature.client.workspace_id().is_none_or(str::is_empty) { + return Ok(()); + } + registry.allocate_namespace("workspace-config", "workspace-config")?; + registry.mount(attach_projection(feature))?; + Ok(()) +} + +pub(super) fn attach_projection(feature: &WorkspaceConfigFeature) -> WipProjection { + let interface = "yoi.workspace-config/attachment/v1"; + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: Some(Documentation { + summary: "Attach this Workspace's logical configuration".into(), + details: Some("Uses the same Backend ledger operation as /workspace-config. Default access is the effective grant; explicit read_write is never downgraded. No host path, Workdir session or Bash capability is created.".into()), + }), + types: Vec::new(), + operations: vec![OperationDeclaration { + name: "attach".into(), + documentation: Some(Documentation { + summary: "Attach or resolve the existing workspace-config connection".into(), + details: None, + }), + parameters: vec![ParameterDeclaration { + name: "access".into(), + required: false, + documentation: None, + r#type: TypeExpr::String, + }], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Json, + }, + }], + }; + let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec()); + WipProjection { + route: CONTENT_ROOT.into(), + capability: "workspace-config:attachment".into(), + kind: WipProjectionKind::Native, + object: Object { + name: ATTACHMENT_ALIAS.into(), + description: Some("Logical Workspace configuration attachment entrance".into()), + interfaces: vec![interface.into()], + r#ref: None, + // Attaching does not modify config content. The content subtree will + // supply its own Backend validators for file/directory Operations. + validator: None, + }, + interface: interface.into(), + descriptor, + interface_validator, + handler: Arc::new(AttachHandler(feature.clone())), + } +} + +struct AttachHandler(WorkspaceConfigFeature); + +#[async_trait] +impl WipOperationHandler for AttachHandler { + fn is_visible(&self) -> bool { + self.0.is_available() && self.0.validate_access(None).is_ok() + } + + fn operation_available(&self, operation: &str) -> bool { + operation == "attach" && self.0.validate_access(None).is_ok() + } + + fn contextual_interface(&self) -> bool { + true + } + + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + if operation != "attach" { + return Err(failure( + ProtocolErrorCode::OperationNotFound, + "Unknown config attachment operation", + )); + } + if arguments.keys().any(|key| key != "access") { + return Err(map_error(ConfigAttachError::InvalidRequest)); + } + let access = match arguments.get("access") { + None => requested_access(None), + Some(Value::String(value)) => requested_access(Some(value)), + _ => Err(ConfigAttachError::InvalidRequest), + } + .map_err(map_error)?; + // Host execution identity, never a model-supplied source or grant. + let key = format!("wip:{}", context.execution.execution_id()); + let result = self.0.attach(&key, access).await.map_err(map_error)?; + let value = json_to_wip(&json!({ + "alias": ATTACHMENT_ALIAS, + "purpose": "Workspace configuration", + "access": result.access.as_str(), + "connection_id": result.connection_id, + "attachment_path": format!("/workdir-attachments/{}", encode_identity(&result.connection_id)), + "content_path": CONTENT_ROOT, + "already_attached": result.already_attached, + })).map_err(|_| WipOperationError::OutcomeUnknown("Completed config attachment response unavailable".into()))?; + Ok(WipOperationOutput::native(value)) + } +} + +fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} + +fn map_error(error: ConfigAttachError) -> WipOperationError { + match error { + ConfigAttachError::InvalidRequest => { + failure(ProtocolErrorCode::InvalidArguments, error.message()) + } + ConfigAttachError::Denied => failure(ProtocolErrorCode::PermissionDenied, error.message()), + ConfigAttachError::Unavailable => { + failure(ProtocolErrorCode::ResourceLimitExceeded, error.message()) + } + ConfigAttachError::OutcomeUnknown => { + WipOperationError::OutcomeUnknown(error.message().into()) + } + } +} diff --git a/crates/worker/src/feature/builtin/workspace_config_tests.rs b/crates/worker/src/feature/builtin/workspace_config_tests.rs new file mode 100644 index 000000000..1f5a1763f --- /dev/null +++ b/crates/worker/src/feature/builtin/workspace_config_tests.rs @@ -0,0 +1,334 @@ +//! Backend-independent preparation tests; not config persistence/E2E evidence. +use super::*; +use std::collections::BTreeMap; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use agen::tool::ToolExecutionContext; +use protocol::InvocationArgumentValue; +use wip_protocol::{ProtocolErrorCode, Value}; + +use crate::feature::{FeatureInvocationRegistry, FeatureRegistryBuilder}; +use crate::hook::HookRegistryBuilder; +use crate::wip::{WipCallContext, WipMountRegistry, WipOperationError}; +use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + +#[derive(Debug)] +struct Client { + workspace: Option<&'static str>, + available: bool, +} +impl WorkspaceClient for Client { + fn workspace_id(&self) -> Option<&str> { + self.workspace + } + fn kind(&self) -> &str { + "test" + } + fn is_available(&self) -> bool { + self.available + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("scaffolding must not invent Backend endpoints"); + } +} + +struct Backend { + granted: AtomicBool, + read_only: AtomicBool, + unknown: AtomicBool, + calls: AtomicUsize, + keys: Mutex>, + active: Mutex>, +} +impl Backend { + fn new() -> Self { + Self { + granted: AtomicBool::new(true), + read_only: AtomicBool::new(false), + unknown: AtomicBool::new(false), + calls: AtomicUsize::new(0), + keys: Mutex::new(Vec::new()), + active: Mutex::new(None), + } + } +} +#[async_trait] +impl WorkspaceConfigAttachmentBackend for Backend { + fn is_available(&self) -> bool { + self.granted.load(Ordering::SeqCst) + } + fn validate_access(&self, access: Option) -> Result<(), ConfigAttachError> { + if !self.granted.load(Ordering::SeqCst) + || (self.read_only.load(Ordering::SeqCst) && access == Some(ConfigAccess::ReadWrite)) + { + return Err(ConfigAttachError::Denied); + } + Ok(()) + } + async fn attach( + &self, + key: &str, + access: Option, + ) -> Result { + self.validate_access(access)?; + self.calls.fetch_add(1, Ordering::SeqCst); + self.keys.lock().unwrap().push(key.into()); + if self.unknown.load(Ordering::SeqCst) { + return Err(ConfigAttachError::OutcomeUnknown); + } + let mut active = self.active.lock().unwrap(); + let already_attached = active.is_some(); + let access = access.unwrap_or(if self.read_only.load(Ordering::SeqCst) { + ConfigAccess::ReadOnly + } else { + ConfigAccess::ReadWrite + }); + let result = ConfigAttachment { + connection_id: "connection-1".into(), + access, + already_attached, + }; + *active = Some(result.clone()); + Ok(result) + } +} + +fn feature( + backend: Arc, + wip_mode: bool, + workspace: Option<&'static str>, +) -> WorkspaceConfigFeature { + WorkspaceConfigFeature::new( + Arc::new(Client { + workspace, + available: true, + }), + backend, + wip_mode, + ) +} +fn registry(feature: WorkspaceConfigFeature) -> FeatureInvocationRegistry { + let mut tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(feature) + .install_into_pending(&mut tools, &mut HookRegistryBuilder::default()); + assert!(!report.has_errors()); + assert!(tools.is_empty(), "no config compatibility tools"); + report.chat_invocations +} +fn invocation(key: &str, access: Option<&str>) -> FeatureInvocation { + FeatureInvocation { + invocation_id: key.into(), + identity: FeatureInvocationIdentity(INVOCATION_ID.into()), + name: FEATURE_ID.into(), + arguments: access + .into_iter() + .map(|value| InvocationArgumentValue { + name: "access".into(), + value: InvocationValue::String(value.into()), + }) + .collect(), + } +} +fn context() -> WipCallContext { + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "test-worker".into(), + } +} + +#[tokio::test] +async fn workspace_config_common_completion_selection_and_preparation_use_backend_ledger() { + let backend = Arc::new(Backend::new()); + let registry = registry(feature(backend.clone(), true, Some("workspace-1"))); + let completion = registry.feature_completions("workspace-c"); + assert_eq!(completion.len(), 1); + let descriptor = completion[0].invocation.as_ref().unwrap(); + assert_eq!(descriptor.identity.0, INVOCATION_ID); + assert!(descriptor.client_adapter.is_none()); + assert_eq!( + registry + .argument_completions(&descriptor.identity, "access", "read_") + .await + .into_iter() + .map(|entry| entry.value) + .collect::>(), + ["read_only", "read_write"] + ); + + let first = registry + .invoke(&invocation("selected-1", None)) + .await + .unwrap(); + assert_eq!(first.status, FeatureInvocationStatus::Succeeded); + assert!(first.message.contains("read_write")); + assert!(first.context.unwrap().contains("WIP `/workspace-config`")); + let second = registry + .invoke(&invocation("selected-2", None)) + .await + .unwrap(); + assert!(second.message.starts_with("Already attached")); + assert_eq!(*backend.keys.lock().unwrap(), ["selected-1", "selected-2"]); +} + +#[tokio::test] +async fn workspace_config_slash_and_wip_self_attach_share_handler_and_identity() { + let backend = Arc::new(Backend::new()); + let feature = feature(backend.clone(), true, Some("workspace-1")); + let slash = registry(feature.clone()); + slash.invoke(&invocation("slash-1", None)).await.unwrap(); + let projection = wip::attach_projection(&feature); + assert_eq!(projection.route, CONTENT_ROOT); + assert_eq!(projection.descriptor.operations.len(), 1); + let result = projection + .handler + .call("attach", &BTreeMap::new(), context()) + .await; + assert!(result.is_ok()); + assert_eq!(backend.calls.load(Ordering::SeqCst), 2); + assert_eq!( + backend + .active + .lock() + .unwrap() + .as_ref() + .unwrap() + .connection_id, + "connection-1" + ); + assert!(backend.keys.lock().unwrap()[1].starts_with("wip:")); +} + +#[tokio::test] +async fn workspace_config_effective_access_and_explicit_read_write_do_not_silently_downgrade() { + let backend = Arc::new(Backend::new()); + backend.read_only.store(true, Ordering::SeqCst); + let feature = feature(backend.clone(), true, Some("workspace-1")); + let registry = registry(feature.clone()); + let result = registry + .invoke(&invocation("effective-1", None)) + .await + .unwrap(); + assert!(result.message.contains("read_only")); + assert!( + registry + .invoke(&invocation("write-1", Some("read_write"))) + .await + .is_err() + ); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + let args = BTreeMap::from([("access".into(), Value::String("read_write".into()))]); + assert!( + matches!(wip::attach_projection(&feature).handler.call("attach", &args, context()).await, + Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::PermissionDenied) + ); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn workspace_config_old_handler_and_replay_validation_recheck_revocation() { + let backend = Arc::new(Backend::new()); + let feature = feature(backend.clone(), true, Some("workspace-1")); + let old = wip::attach_projection(&feature); + let registry = registry(feature); + let request = invocation("replay-1", None); + registry.invoke(&request).await.unwrap(); + backend.granted.store(false, Ordering::SeqCst); + assert!(registry.feature_completions("").is_empty()); + assert!(registry.validate(&request).is_err()); + assert!(!old.handler.is_visible()); + assert!( + matches!(old.handler.call("attach", &BTreeMap::new(), context()).await, + Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::PermissionDenied) + ); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn workspace_config_unknown_attach_is_not_retried_and_never_reports_success() { + let backend = Arc::new(Backend::new()); + backend.unknown.store(true, Ordering::SeqCst); + let feature = feature(backend.clone(), true, Some("workspace-1")); + let registry = registry(feature.clone()); + let error = registry + .invoke(&invocation("unknown-1", None)) + .await + .unwrap_err(); + assert!(error.outcome_unknown); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert!(matches!( + wip::attach_projection(&feature) + .handler + .call("attach", &BTreeMap::new(), context()) + .await, + Err(WipOperationError::OutcomeUnknown(_)) + )); + assert_eq!(backend.calls.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn workspace_config_non_wip_and_missing_workspace_have_no_fallback() { + for (wip_mode, workspace) in [(false, Some("workspace-1")), (true, None), (true, Some(""))] { + let backend = Arc::new(Backend::new()); + let feature = feature(backend.clone(), wip_mode, workspace); + assert!(registry(feature.clone()).descriptors().is_empty()); + let mut mounts = WipMountRegistry::new(); + wip::mount_workspace_config_attach_wip(&mut mounts, &feature).unwrap(); + assert_eq!(mounts.routes().count(), 0); + assert_eq!( + feature.attach("not-enabled", None).await, + Err(ConfigAttachError::Denied) + ); + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn workspace_config_rejects_unstructured_authority_and_invalid_access_before_attach() { + let backend = Arc::new(Backend::new()); + let feature = feature(backend.clone(), true, Some("workspace-1")); + let registry = registry(feature.clone()); + assert!( + registry + .invoke(&invocation("bad-1", Some("admin"))) + .await + .is_err() + ); + let projection = wip::attach_projection(&feature); + let args = BTreeMap::from([("workspace_id".into(), Value::String("another".into()))]); + assert!( + matches!(projection.handler.call("attach", &args, context()).await, + Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::InvalidArguments) + ); + let mut bad = invocation("bad-2", None); + bad.arguments.push(InvocationArgumentValue { + name: "access".into(), + value: InvocationValue::String("true".into()), + }); + assert!(registry.invoke(&bad).await.is_err()); + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn workspace_config_controller_gate_requires_configured_workspace_wip_and_not_a_grant() { + for enabled in [false, true] { + for wip in [false, true] { + for workspace in [None, Some(""), Some("workspace-1"), Some("bad\nworkspace")] { + for available in [false, true] { + let client = Arc::new(Client { + workspace, + available, + }); + let expected = enabled && wip && available && workspace == Some("workspace-1"); + // Registration needs identity/mode, but performs no grant or + // filesystem mutation. Runtime authorization is still Backend's. + assert_eq!( + WorkspaceConfigFeature::configured(client, enabled, wip).is_some(), + expected + ); + } + } + } + } +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 31b2b8c38..bbce6e9cd 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -241,6 +241,13 @@ pub struct WipDynamicMount { /// this resolves both files and directories and enumerates authorized children. #[async_trait] pub trait WipSubtreeProvider: Send + Sync { + /// Provider-specific observation bounds, capped by the Host global bounds. + fn max_depth(&self) -> u32 { + 32 + } + fn max_nodes(&self) -> usize { + 1024 + } async fn projection(&self, path: &str) -> Result, ProtocolError>; async fn children(&self, path: &str) -> Result, ProtocolError>; } @@ -972,7 +979,7 @@ fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec { digest.finalize().to_vec() } -fn contextual_reference(base: &str, path: &str) -> String { +pub(crate) fn contextual_reference(base: &str, path: &str) -> String { let encoded: String = path .as_bytes() .iter() @@ -1172,6 +1179,15 @@ impl WipHost { Box> + Send + 'a>, > { Box::pin(async move { + if let Some(mount) = self.subtree(path) { + if depth > mount.provider.max_depth() { + return Err(protocol_error( + ProtocolErrorCode::ResourceLimitExceeded, + "observation exceeds provider depth limit", + )); + } + *remaining = (*remaining).min(mount.provider.max_nodes()); + } if *remaining == 0 { return Err(protocol_error( ProtocolErrorCode::ResourceLimitExceeded, @@ -1820,6 +1836,19 @@ pub struct WipRuntime { } impl WipRuntime { + /// Build an isolated Client/Host endpoint from native application mounts. + /// Useful to embedders and in-process integration tests without an LLM Engine. + pub fn from_mounts( + registry: WipMountRegistry, + security_context: String, + ) -> Result { + Self::new( + WipHost::new(registry), + SecurityContext::new(security_context), + 0, + ) + } + fn new( host: WipHost, security_context: SecurityContext, @@ -1874,7 +1903,7 @@ impl WipRuntime { Ok(()) } - async fn discover( + pub async fn discover( &self, path: String, depth: u32, @@ -1899,10 +1928,11 @@ impl WipRuntime { .fetch_add(1, Ordering::Relaxed); let response = self.dispatch_retrieval(&prepared.request).await?; let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - state + let completion = state .client .complete(prepared.id, response) .map_err(client_tool_error)?; + check_retrieval_completion(completion)?; } let state = self.state.lock().unwrap_or_else(|error| error.into_inner()); let values = state @@ -1926,7 +1956,7 @@ impl WipRuntime { )) } - async fn inspect(&self, interface: String, refresh: bool) -> Result { + pub async fn inspect(&self, interface: String, refresh: bool) -> Result { let prepared = { let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); let session = state.session.clone(); @@ -1946,10 +1976,11 @@ impl WipRuntime { .fetch_add(1, Ordering::Relaxed); let response = self.dispatch_retrieval(&prepared.request).await?; let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); - state + let completion = state .client .complete(prepared.id, response) .map_err(client_tool_error)?; + check_retrieval_completion(completion)?; } let state = self.state.lock().unwrap_or_else(|error| error.into_inner()); let observed = state @@ -1968,7 +1999,7 @@ impl WipRuntime { )) } - async fn call( + pub async fn call( &self, path: String, interface: String, @@ -2752,6 +2783,19 @@ fn tool_output_value(output: &ToolOutput) -> Value { Value::Record(record) } +fn check_retrieval_completion(completion: Completion) -> Result<(), ToolError> { + match completion { + Completion::Object(_) | Completion::Interface(_) => Ok(()), + Completion::ProtocolFailure(error) => Err(ToolError::ExecutionFailed(error.to_string())), + Completion::StaleResponseRejected(_) => Err(ToolError::ExecutionFailed( + "WIP retrieval superseded; refresh observations".into(), + )), + Completion::Call(_) => Err(ToolError::Internal( + "WIP retrieval returned a call result".into(), + )), + } +} + fn render_call_completion(completion: Completion) -> Result { let Completion::Call(record) = completion else { return Err(ToolError::Internal( @@ -4142,9 +4186,13 @@ mod tests { .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"); + let error = runtime.inspect(interface.clone(), true).await.unwrap_err(); + assert!(error.to_string().contains("InterfaceNotFound")); + { + let state = runtime.state.lock().unwrap(); + let observed = state.client.interface(&state.session, &interface).unwrap(); + assert!(matches!(observed.state, ObservationState::Error(_))); + } assert!( runtime .call( diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 3d3ec8071..2a127bd9c 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -116,6 +116,52 @@ pub struct EvaluatedConfigCandidate { pub evaluation: EvaluationResult, } +/// Validate feature semantics before committing a schema-valid candidate. +/// +/// Keep this shared by the UI and logical config operations. Schema evaluation +/// alone does not resolve Profile sources or reject blocking Skill diagnostics. +/// This function has no publication or repository-secret side effects. +pub(crate) fn validate_workspace_config_candidate_projections( + workspace_id: &str, + candidate: &EvaluatedConfigCandidate, +) -> Result<()> { + let state = WorkspaceConfigState { + snapshot: candidate.snapshot.clone(), + contract: candidate.contract.clone(), + projection_digest: candidate.evaluation.projection_digest.clone(), + }; + let has_provider = |id: &str| { + state + .contract + .schema_bundle + .contributions + .iter() + .any(|provider| provider.provider_id == id) + }; + crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; + if has_provider("builtin:profile") { + crate::profile_settings::project_profiles_from_workspace_config(workspace_id, &state)?; + } + if has_provider("builtin:runtime") { + crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?; + } + if has_provider("builtin:skills") { + let catalog = crate::skills::catalog(&state) + .map_err(|_| Error::InvalidInput("invalid Workspace Skill projection".to_string()))?; + if catalog.entries.iter().any(|entry| { + entry + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == server_api::SkillDiagnosticSeverity::Error) + }) { + return Err(Error::InvalidInput( + "Workspace Skills have blocking diagnostics".to_string(), + )); + } + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] pub struct ConfigCommitRequest { #[ts(type = "number")] @@ -911,6 +957,93 @@ mod tests { } } + fn semantic_candidate( + contribution: ConfigSchemaContribution, + source: &str, + ) -> EvaluatedConfigCandidate { + let schema = WorkspaceConfigSchemaBundle::compose([ + crate::prompt_settings::PromptConfigSchemaProvider + .contribution() + .unwrap(), + contribution, + ]) + .unwrap(); + let current = initial_state_with_schema(schema.clone()).unwrap(); + let change = update_main(¤t, source); + evaluate_candidate(current, &[change], schema).unwrap() + } + + #[test] + fn candidate_semantics_reject_unknown_default_profile() { + let candidate = semantic_candidate( + crate::profile_settings::ProfileConfigSchemaProvider + .contribution() + .unwrap(), + r#"{ profile = { default_profile = "project:missing"; }; }"#, + ); + let error = + validate_workspace_config_candidate_projections("w-config", &candidate).unwrap_err(); + assert!(error.to_string().contains("unknown_default_profile")); + let valid = semantic_candidate( + crate::profile_settings::ProfileConfigSchemaProvider + .contribution() + .unwrap(), + "{}", + ); + validate_workspace_config_candidate_projections("w-config", &valid).unwrap(); + } + + #[test] + fn candidate_semantics_reject_invalid_runtime_identifier() { + let candidate = semantic_candidate( + crate::runtime_settings::RuntimeConfigSchemaProvider + .contribution() + .unwrap(), + r#"{ runtime = { default_runtime_id = "bad\nidentifier"; }; }"#, + ); + assert!(matches!( + validate_workspace_config_candidate_projections("w-config", &candidate), + Err(Error::InvalidRuntimeIdentifier { .. }) + )); + let valid = semantic_candidate( + crate::runtime_settings::RuntimeConfigSchemaProvider + .contribution() + .unwrap(), + "{}", + ); + validate_workspace_config_candidate_projections("w-config", &valid).unwrap(); + } + + #[test] + fn candidate_semantics_reject_skill_without_canonical_source() { + let candidate = semantic_candidate( + crate::skills::SkillConfigSchemaProvider + .contribution() + .unwrap(), + r#"{ + skills = { + debug_rust = { + frontmatter = { name = "debug-rust"; description = "Debug Rust"; }; + content = "inline"; + }; + }; + }"#, + ); + let error = + validate_workspace_config_candidate_projections("w-config", &candidate).unwrap_err(); + assert!(error.to_string().contains("blocking diagnostics")); + // Do not copy Skill source bodies or diagnostic paths into this error. + assert!(!error.to_string().contains("inline")); + assert!(!error.to_string().contains("SKILL.md")); + let valid = semantic_candidate( + crate::skills::SkillConfigSchemaProvider + .contribution() + .unwrap(), + "{}", + ); + validate_workspace_config_candidate_projections("w-config", &valid).unwrap(); + } + #[tokio::test] async fn schema_registry_applies_normal_decodal_composition() { struct WebSchema; diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 16d98f1f4..78efa519b 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -668,7 +668,7 @@ CREATE TABLE "workdir_registry" ( workspace_id TEXT NOT NULL, workdir_id TEXT NOT NULL, display_name TEXT, - source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')), + source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant', 'workspace_config')), runtime_id TEXT, repository_id TEXT, external_grant_id TEXT, @@ -680,16 +680,20 @@ CREATE TABLE "workdir_registry" ( updated_at TEXT NOT NULL, current_selector TEXT, current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER, + workspace_config_grant_id TEXT, PRIMARY KEY (workspace_id, workdir_id), CHECK ( - (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL) - OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL) + (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NULL) + OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL AND workspace_config_grant_id IS NULL) + OR (source_kind = 'workspace_config' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NOT NULL) ), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, repository_id) REFERENCES "repositories"(workspace_id, repository_id), FOREIGN KEY (workspace_id, external_grant_id) - REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED + REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (workspace_id, workspace_config_grant_id) + REFERENCES workspace_config_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED ); CREATE TABLE external_workdir_grants ( grant_id TEXT NOT NULL, @@ -1605,3 +1609,20 @@ CREATE TRIGGER ticket_worker_assignments_validate_update BEGIN SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); END; + +CREATE TABLE workspace_config_grants ( + workspace_id TEXT NOT NULL, + grant_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, grant_id), + UNIQUE(workspace_id, workdir_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX workspace_config_grants_active_worker + ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cddade2c1..2aa6ecc63 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1,3 +1,5 @@ +#[path = "server_workspace_config.rs"] +mod workspace_config; use std::collections::{BTreeMap, HashMap, HashSet}; use std::net::IpAddr; use std::path::{Component, Path, PathBuf}; @@ -4985,6 +4987,7 @@ impl WorkspaceApi { .map_err(RuntimeRegistryError::into_error)?; } } + WorkdirRegistrySource::WorkspaceConfig { .. } => continue, WorkdirRegistrySource::ExternalGrant { grant_id } => { let grant = self .store @@ -5151,7 +5154,8 @@ impl WorkspaceApi { runtime_id, repository_id, } => (runtime_id, repository_id), - WorkdirRegistrySource::ExternalGrant { .. } => { + WorkdirRegistrySource::ExternalGrant { .. } + | WorkdirRegistrySource::WorkspaceConfig { .. } => { return Err(Error::InvalidInput(format!( "Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target", claim.alias @@ -5294,6 +5298,9 @@ fn workdir_source_capabilities( ) -> Result { match &workdir.source { WorkdirRegistrySource::Repository { .. } => Ok(workdir::WorkdirSessionCapabilities::ALL), + WorkdirRegistrySource::WorkspaceConfig { grant_id } => { + workspace_config::grant_capabilities(api, grant_id) + } WorkdirRegistrySource::ExternalGrant { grant_id } => api .store .get_external_workdir_grant(&api.config.workspace_id, grant_id)? @@ -5399,6 +5406,14 @@ fn ticket_target_capabilities_for_workdir( workdir: &WorkdirRegistryRecord, ) -> Result { let source_capabilities = workdir_source_capabilities(api, workdir)?; + if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &workdir.source { + if !workspace_config::granted_to(api, worker, grant_id) { + return Err(Error::WorkspacePermissionDenied( + "Workspace config grant unavailable".into(), + )); + } + return Ok(source_capabilities); + } let Some(assignment) = api .store .get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)? @@ -5501,12 +5516,23 @@ fn validate_manual_ticket_coder_workdir_binding( )) .into()); } + if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &workdir.source { + if !workspace_config::granted_to(api, worker, grant_id) { + return Err(Error::WorkspacePermissionDenied( + "Workspace config grant unavailable".into(), + ) + .into()); + } + effective_links.push(link.clone()); + continue; + } let (workdir_runtime_id, repository_id) = match &workdir.source { WorkdirRegistrySource::Repository { runtime_id, repository_id, } => (runtime_id, repository_id), - WorkdirRegistrySource::ExternalGrant { .. } => { + WorkdirRegistrySource::ExternalGrant { .. } + | WorkdirRegistrySource::WorkspaceConfig { .. } => { return Err(Error::InvalidInput(format!( "Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target", link.alias @@ -6044,6 +6070,19 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::current_worker_workdir_catalog( service.clone(), )) + .merge(server_api::server_api_axum::current_worker_workspace_config_get(service.clone())) + .merge(server_api::server_api_axum::current_worker_workspace_config_attach(service.clone())) + .merge( + server_api::server_api_axum::current_worker_workspace_config_observe(service.clone()), + ) + .merge(server_api::server_api_axum::current_worker_workspace_config_read(service.clone())) + .merge(server_api::server_api_axum::current_worker_workspace_config_commit(service.clone())) + .merge(server_api::server_api_axum::workspace_config_grant_create( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_config_grant_revoke( + service.clone(), + )) .merge(server_api::server_api_axum::current_worker_workdir_attachment_list(service.clone())) .merge(server_api::server_api_axum::current_worker_workdir_attach( service.clone(), @@ -10579,6 +10618,110 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn current_worker_workspace_config_get( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result< + server_api::WorkspaceConfigCurrentResponse, + server_api::WorkspaceConfigApiError, + > { + let api = self.workspace_api().map_err(|_| { + workspace_config::pre_error(Error::Store("Workspace unavailable".into())) + })?; + let worker = workspace_config::identity(api, &context, &workspace_id)?; + workspace_config::get(api, &worker) + .await + .map(server_api::WorkspaceConfigCurrentResponse) + } + async fn current_worker_workspace_config_attach( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceConfigAttachRequest, + ) -> std::result::Result< + server_api::WorkspaceConfigAttachment, + server_api::WorkspaceConfigApiError, + > { + let api = self.workspace_api().map_err(|_| { + workspace_config::pre_error(Error::Store("Workspace unavailable".into())) + })?; + let worker = workspace_config::identity(api, &context, &workspace_id)?; + workspace_config::attach(api, &worker, request).await + } + async fn current_worker_workspace_config_observe( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceConfigObserveRequest, + ) -> std::result::Result< + server_api::WorkspaceConfigObserveResponse, + server_api::WorkspaceConfigApiError, + > { + let api = self.workspace_api().map_err(|_| { + workspace_config::pre_error(Error::Store("Workspace unavailable".into())) + })?; + let worker = workspace_config::identity(api, &context, &workspace_id)?; + workspace_config::observe(api, &worker, request).await + } + async fn current_worker_workspace_config_read( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceConfigReadRequest, + ) -> std::result::Result< + server_api::WorkspaceConfigReadResponse, + server_api::WorkspaceConfigApiError, + > { + let api = self.workspace_api().map_err(|_| { + workspace_config::pre_error(Error::Store("Workspace unavailable".into())) + })?; + let worker = workspace_config::identity(api, &context, &workspace_id)?; + workspace_config::read(api, &worker, request).await + } + async fn current_worker_workspace_config_commit( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceConfigCommitRequest, + ) -> std::result::Result< + server_api::WorkspaceConfigCommitResponse, + server_api::WorkspaceConfigApiError, + > { + let api = self.workspace_api().map_err(|_| { + workspace_config::pre_error(Error::Store("Workspace unavailable".into())) + })?; + let worker = workspace_config::identity(api, &context, &workspace_id)?; + workspace_config::commit(api, &worker, request).await + } + async fn workspace_config_grant_create( + &self, + actor: RequestActor, + workspace_id: String, + request: server_api::WorkspaceConfigGrantCreateRequest, + ) -> std::result::Result< + server_api::WorkspaceConfigGrantResponse, + server_api::WorkspaceConfigApiError, + > { + let api = self.workspace_api().map_err(|_| { + workspace_config::pre_error(Error::Store("Workspace unavailable".into())) + })?; + workspace_config::create_grant(api, &actor, &workspace_id, request).await + } + async fn workspace_config_grant_revoke( + &self, + actor: RequestActor, + workspace_id: String, + grant_id: String, + ) -> std::result::Result< + server_api::WorkspaceConfigGrantResponse, + server_api::WorkspaceConfigApiError, + > { + let api = self.workspace_api().map_err(|_| { + workspace_config::pre_error(Error::Store("Workspace unavailable".into())) + })?; + workspace_config::revoke_grant(api, &actor, &workspace_id, &grant_id).await + } async fn current_worker_workdir_catalog( &self, context: server_api::ServerRequestContext, @@ -10591,14 +10734,19 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api()?; validate_workspace_scope(api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; - current_worker_identity( + let worker = current_worker_identity( api, &workspace_id, ¤t_worker_contract_headers(&context)?, ) .map_err(ApiError::from) .map_err(ApiError::into_repository_api_error)?; - list_current_worker_workdir_catalog(api, query).map_err(ApiError::into_repository_api_error) + { + let session_lock = current_worker_session_lock(api, &worker); + let _guard = session_lock.lock().await; + list_current_worker_workdir_catalog_for_worker(api, &worker, query) + } + .map_err(ApiError::into_repository_api_error) } async fn current_worker_workdir_attachment_list( @@ -12185,37 +12333,70 @@ async fn scoped_commit_workspace_config_tree( ) -> ApiResult<(StatusCode, Json)> { let request = config_commit_request_from_api(request)?; validate_workspace_scope(&api, &path.workspace_id)?; - let candidate = api - .config_store - .evaluate_workspace_config_candidate_with_schema( - &path.workspace_id, - &request, - api.config_schema_registry.compose()?, - )?; - crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; - project_repository_access_candidate( - &*api.store, - &api.repository_secrets, - &path.workspace_id, - &candidate, - )?; - let state = api - .config_store - .commit_evaluated_workspace_config(&path.workspace_id, &candidate)?; - if let Ok(projection) = api - .prompt_projection_cache - .resolve(&path.workspace_id, &state) - { - let _diagnostics = api - .runtime - .observe_workspace_prompt_projection((*projection).clone()); - } + let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?; Ok(( StatusCode::CREATED, Json(workspace_config_state_to_api(state)), )) } +/// Shared canonical commit and activation boundary for UI and logical config operations. +/// Callers must authorize access before entering this boundary. +/// +/// Repository access projection may materialize a default credential before the +/// config CAS commits. An error here therefore does not promise that every +/// auxiliary side effect was rolled back. Prompt publication is best effort, +/// exactly as it is for UI saves, and must not turn a committed save into an error. +fn commit_workspace_config_tree( + api: &WorkspaceApi, + workspace_id: &str, + request: &ConfigCommitRequest, +) -> Result { + let candidate = prepare_workspace_config_tree(api, workspace_id, request)?; + persist_workspace_config_candidate(api, workspace_id, &candidate) +} + +fn prepare_workspace_config_tree( + api: &WorkspaceApi, + workspace_id: &str, + request: &ConfigCommitRequest, +) -> Result { + let candidate = api + .config_store + .evaluate_workspace_config_candidate_with_schema( + workspace_id, + request, + api.config_schema_registry.compose()?, + )?; + crate::config_source::validate_workspace_config_candidate_projections( + workspace_id, + &candidate, + )?; + Ok(candidate) +} + +fn persist_workspace_config_candidate( + api: &WorkspaceApi, + workspace_id: &str, + candidate: &crate::config_source::EvaluatedConfigCandidate, +) -> Result { + project_repository_access_candidate( + &*api.store, + &api.repository_secrets, + workspace_id, + candidate, + )?; + let state = api + .config_store + .commit_evaluated_workspace_config(workspace_id, &candidate)?; + if let Ok(projection) = api.prompt_projection_cache.resolve(workspace_id, &state) { + let _diagnostics = api + .runtime + .observe_workspace_prompt_projection((*projection).clone()); + } + Ok(state) +} + async fn scoped_get_profile_settings( State(api): State, AxumPath(path): AxumPath, @@ -16184,6 +16365,14 @@ async fn open_current_worker_workdir_session_locked( link.workdir_id ), })?; + if matches!( + workdir.source, + WorkdirRegistrySource::WorkspaceConfig { .. } + ) { + return Err(Error::WorkspacePermissionDenied( + "Logical config has no filesystem session".into(), + )); + } let effective_capabilities = effective_worker_workdir_capabilities(api, worker, &workdir, link.capabilities)?; if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source { @@ -16263,7 +16452,8 @@ async fn open_current_worker_workdir_session_locked( runtime_id, repository_id, } => (runtime_id.as_str(), repository_id.as_str()), - WorkdirRegistrySource::ExternalGrant { .. } => { + WorkdirRegistrySource::ExternalGrant { .. } + | WorkdirRegistrySource::WorkspaceConfig { .. } => { return Err(Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), code: "external_workdir_provider_unavailable".to_string(), @@ -16389,6 +16579,15 @@ fn sync_runtime_worker_workdir_attachments( .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) + .filter(|link| { + api.store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) + .ok() + .flatten() + .is_none_or(|record| { + !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) + }) + }) .map(|mut link| { let workdir = api .store @@ -16446,6 +16645,15 @@ async fn refresh_current_worker_session_locked( .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) + .filter(|link| { + api.store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) + .ok() + .flatten() + .is_none_or(|record| { + !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) + }) + }) .collect::>(); if remaining.len() == 1 { open_current_worker_workdir_session_locked(api, worker, &remaining[0]).await?; @@ -16529,6 +16737,12 @@ async fn scoped_attach_current_worker_workdir( } match &workdir.source { WorkdirRegistrySource::Repository { .. } => {} + WorkdirRegistrySource::WorkspaceConfig { .. } => { + return Err(Error::WorkspacePermissionDenied( + "Use the grant-bound Workspace config attachment operation".into(), + ) + .into()); + } WorkdirRegistrySource::ExternalGrant { grant_id } => { let grant = api .store @@ -16633,6 +16847,15 @@ async fn scoped_attach_current_worker_workdir( .list_worker_workdir_links(&api.config.workspace_id, &worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) + .filter(|link| { + api.store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) + .ok() + .flatten() + .is_none_or(|record| { + !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) + }) + }) .count(); if active_count == 1 && let Err(error) = open_current_worker_workdir_session_locked(&api, &worker, &link).await @@ -16670,9 +16893,22 @@ async fn scoped_attach_current_worker_workdir( })) } +#[cfg(test)] fn list_current_worker_workdir_catalog( api: &WorkspaceApi, query: server_api::CurrentWorkerWorkdirCatalogQuery, +) -> ApiResult { + list_current_worker_workdir_catalog_for_worker( + api, + &RuntimeWorkerRef::new("catalog-test", "ungranted"), + query, + ) +} + +fn list_current_worker_workdir_catalog_for_worker( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + query: server_api::CurrentWorkerWorkdirCatalogQuery, ) -> ApiResult { // No Runtime inventory refresh: both projection inputs and complete digest are authoritative // Backend records captured by the store in one read transaction. @@ -16685,6 +16921,19 @@ fn list_current_worker_workdir_catalog( .entries .into_iter() .filter_map(|entry| { + if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &entry.record.source { + if !workspace_config::granted_to(api, worker, grant_id) { + return None; + } + let grant = api + .store + .get_workspace_config_grant(&api.config.workspace_id, grant_id) + .ok() + .flatten()?; + let mut summary = workspace_config::summary(&grant, &entry.record); + summary.occupied_by = entry.occupied_by.map(Into::into); + return Some(summary); + } if matches!( entry.record.source, WorkdirRegistrySource::ExternalGrant { .. } @@ -16818,6 +17067,15 @@ async fn detach_current_worker_workdir( .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() .filter(|link| link.unlinked_at.is_none()) + .filter(|link| { + api.store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) + .ok() + .flatten() + .is_none_or(|record| { + !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) + }) + }) .collect::>(); if remaining.len() == 1 && let Err(error) = @@ -24143,6 +24401,20 @@ async fn scoped_working_directory_detail( code: "working_directory_not_found".to_string(), message: format!("Unknown Workdir `{}`", path.working_directory_id), })?; + if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &record.source { + let grant = api + .store + .get_workspace_config_grant(&api.config.workspace_id, grant_id)? + .ok_or_else(|| { + Error::WorkspacePermissionDenied("Workspace config grant unavailable".into()) + })?; + return Ok(Json(BrowserWorkingDirectoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: None, + item: workspace_config::summary(&grant, &record), + diagnostics: vec![], + })); + } if let Some(runtime_id) = record.source.runtime_id().map(str::to_string) { working_directory_detail_for_runtime(api, &runtime_id, &path.working_directory_id) } else { @@ -30790,6 +31062,9 @@ fn working_directory_summaries( .list_workdir_registry(&api.config.workspace_id, 200)?; let mut items = Vec::new(); for record in records { + if matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) { + continue; + } if let Some(grant_id) = legacy_external_workdir_grant_id(api, &record)? { diagnostics.push(RuntimeDiagnostic { code: "external_workdir_legacy_permissions".to_string(), @@ -30818,6 +31093,9 @@ fn available_working_directory_summaries( .list_workdir_registry(&api.config.workspace_id, 200)?; let mut available = Vec::new(); for record in records { + if matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) { + continue; + } if legacy_external_workdir_grant_id(api, &record)?.is_some() { continue; } @@ -30827,6 +31105,7 @@ fn available_working_directory_summaries( summary.cleanliness.as_deref() == Some("clean") } WorkdirRegistrySource::ExternalGrant { .. } => true, + WorkdirRegistrySource::WorkspaceConfig { .. } => false, }; if summary.status != WorkingDirectoryStatusKind::Active || !source_is_available { continue; @@ -31057,6 +31336,30 @@ fn project_worker_registry_projection( else { continue; }; + if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &workdir.source { + if !workspace_config::granted_to(api, &record.worker, grant_id) { + continue; + } + let grant = api + .store + .get_workspace_config_grant(&api.config.workspace_id, grant_id)? + .ok_or_else(|| { + Error::WorkspacePermissionDenied("Workspace config grant unavailable".into()) + })?; + workdir_attachments.push(server_api::WorkerWorkdirAttachmentSummary { + alias: link.alias, + effective_permissions: workdir_permission_summary( + effective_worker_workdir_capabilities( + api, + &record.worker, + workdir, + link.capabilities, + )?, + ), + working_directory: workspace_config::summary(&grant, workdir), + }); + continue; + } let effective_permissions = workdir_permission_summary( effective_worker_workdir_capabilities(api, &record.worker, workdir, link.capabilities)?, ); @@ -31458,6 +31761,9 @@ fn workdir_summary_from_record( _ => WorkingDirectoryStatusKind::Unknown, }; let (source, materializer_kind, cleanup_target) = match &record.source { + WorkdirRegistrySource::WorkspaceConfig { .. } => { + unreachable!("logical config never enters filesystem summary") + } WorkdirRegistrySource::Repository { .. } => { let repository_key = repository_key.expect("Repository Workdir projection requires key"); @@ -31537,6 +31843,11 @@ fn projected_workdir_summary_from_record( record: &WorkdirRegistryRecord, ) -> Result { let (repository_key, external_grant_permissions) = match &record.source { + WorkdirRegistrySource::WorkspaceConfig { .. } => { + return Err(Error::WorkspacePermissionDenied( + "Logical config is not a filesystem Workdir".into(), + )); + } WorkdirRegistrySource::Repository { repository_id, .. } => ( Some( api.store @@ -32761,6 +33072,7 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { + include!("server_workspace_config_tests.rs"); use super::*; use axum::body::{Body, to_bytes}; use axum::http::Request; diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs new file mode 100644 index 000000000..eb5935d75 --- /dev/null +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -0,0 +1,652 @@ +//! Logical configuration operations share the durable attachment ledger and canonical CAS. +//! This module never opens a Runtime filesystem session or exports a host path. +//! Files support read/edit/write and non-entrypoint deletion; directories support +//! create, with atomic change sets (including non-entrypoint renames) on the root. +//! The canonical main.dcdl entrypoint cannot be deleted or renamed. +use super::*; +use server_api::{ + WorkspaceConfigAccess as Access, WorkspaceConfigApiError as Failure, + WorkspaceConfigFailureClassification as Classification, WorkspaceConfigNodeKind as NodeKind, +}; +use server_api::{ + WorkspaceConfigAttachRequest, WorkspaceConfigAttachment, WorkspaceConfigCommitRequest, + WorkspaceConfigCommitResponse, WorkspaceConfigGrantCreateRequest, WorkspaceConfigGrantResponse, + WorkspaceConfigNode, WorkspaceConfigObserveRequest, WorkspaceConfigObserveResponse, + WorkspaceConfigReadRequest, WorkspaceConfigReadResponse, +}; + +const ALIAS: &str = "workspace-config"; +const MAX_NODES: usize = 256; +const MAX_TEXT: usize = 256 * 1024; +const DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); +type ConfigResult = std::result::Result; + +fn fail(status: u16, code: &str) -> Failure { + Failure { + status, + code: code.into(), + message: format!("Workspace configuration: {code}"), + classification: Classification::NotCommitted, + } +} +pub(super) fn pre_error(error: impl Into) -> Failure { + let error = error.into(); + let status = api_error_status(&error.error).as_u16(); + fail( + status, + match status { + 401 | 403 => "access_denied", + 404 => "not_found", + 409 => "conflict", + 413 => "limit_exceeded", + 500..=599 => "unavailable", + _ => "invalid_request", + }, + ) +} +fn mutation_error(error: Error) -> Failure { + // Storage/service failures at a mutation boundary may include a commit + // acknowledgement failure. Never describe those as a guaranteed rollback. + if matches!(&error, Error::Sqlite(_) | Error::Store(_)) { + unknown() + } else { + pre_error(error) + } +} +fn unknown() -> Failure { + Failure { + classification: Classification::Unknown, + ..fail(500, "outcome_unknown") + } +} +pub(super) fn identity( + api: &WorkspaceApi, + context: &server_api::ServerRequestContext, + workspace_id: &str, +) -> ConfigResult { + validate_workspace_scope(api, workspace_id).map_err(pre_error)?; + let headers = + current_worker_contract_headers(context).map_err(|_| fail(403, "access_denied"))?; + current_worker_identity(api, workspace_id, &headers).map_err(pre_error) +} +async fn lock( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ConfigResult> { + tokio::time::timeout( + DEADLINE, + current_worker_session_lock(api, worker).lock_owned(), + ) + .await + .map_err(|_| fail(503, "busy")) +} +fn grant_for_worker( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ConfigResult { + api.store + .current_workspace_config_grant(&api.config.workspace_id, worker) + .map_err(pre_error)? + .filter(|grant| !grant.revoked) + .ok_or_else(|| fail(403, "access_denied")) +} +fn access_capabilities(access: Access) -> workdir::WorkdirSessionCapabilities { + match access { + Access::ReadOnly => workdir::WorkdirSessionCapabilities::READ_ONLY, + Access::ReadWrite => workdir::WorkdirSessionCapabilities::READ_WRITE, + } +} +pub(super) fn grant_capabilities( + api: &WorkspaceApi, + grant_id: &str, +) -> Result { + let grant = api + .store + .get_workspace_config_grant(&api.config.workspace_id, grant_id)? + .filter(|grant| !grant.revoked) + .ok_or_else(|| { + Error::WorkspacePermissionDenied("Workspace config grant unavailable".into()) + })?; + Ok(access_capabilities(grant.access)) +} +pub(super) fn granted_to(api: &WorkspaceApi, worker: &RuntimeWorkerRef, grant_id: &str) -> bool { + grant_for_worker(api, worker).is_ok_and(|grant| grant.grant_id == grant_id) +} +fn active_link( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + grant: &WorkspaceConfigGrantResponse, + connection_id: Option<&str>, +) -> ConfigResult> { + if connection_id.is_some_and(|id| id.is_empty() || id.len() > 128) { + return Err(fail(400, "invalid_connection")); + } + let link = api + .store + // The T697 exclusive-active-Workdir ledger bounds this lookup to one + // current lifetime, regardless of accumulated detached history. + .list_workdir_worker_links(&api.config.workspace_id, &grant.working_directory_id) + .map_err(pre_error)? + .into_iter() + .find(|link| link.unlinked_at.is_none() && link.worker == *worker && link.alias == ALIAS); + if let Some(link) = &link { + let record = api + .store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) + .map_err(pre_error)? + .ok_or_else(|| fail(403, "access_denied"))?; + if link.workdir_id != grant.working_directory_id + || !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { ref grant_id } if grant_id == &grant.grant_id) + { + return Err(fail(403, "access_denied")); + } + } + if let Some(id) = connection_id { + if !link.as_ref().is_some_and(|link| link.connection_id == id) { + return Err(fail(409, "connection_changed")); + } + } + Ok(link) +} +fn effective_access( + grant: &WorkspaceConfigGrantResponse, + link: &WorkerWorkdirLinkRecord, +) -> Access { + if grant.access == Access::ReadWrite + && link + .capabilities + .supports(workdir::WorkdirSessionCapability::Write) + && link + .capabilities + .supports(workdir::WorkdirSessionCapability::Edit) + { + Access::ReadWrite + } else { + Access::ReadOnly + } +} +fn attachment( + grant: &WorkspaceConfigGrantResponse, + link: &WorkerWorkdirLinkRecord, + already_attached: bool, +) -> WorkspaceConfigAttachment { + WorkspaceConfigAttachment { + workspace_id: grant.workspace_id.clone(), + connection_id: link.connection_id.clone(), + alias: ALIAS.into(), + working_directory_id: link.workdir_id.clone(), + access: effective_access(grant, link), + name: "Workspace configuration".into(), + purpose: "Explore and edit this Workspace's canonical configuration".into(), + content_path: "/workspace-config".into(), + already_attached, + } +} +pub(super) async fn get( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ConfigResult> { + let _guard = lock(api, worker).await?; + let grant = grant_for_worker(api, worker)?; + Ok(active_link(api, worker, &grant, None)?.map(|link| attachment(&grant, &link, true))) +} +pub(super) async fn attach( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: WorkspaceConfigAttachRequest, +) -> ConfigResult { + if request.alias.as_deref().is_some_and(|alias| alias != ALIAS) { + return Err(fail(400, "invalid_alias")); + } + let _guard = lock(api, worker).await?; + let grant = grant_for_worker(api, worker)?; + let access = request.access.unwrap_or(grant.access); + if access == Access::ReadWrite && grant.access != Access::ReadWrite { + return Err(fail(403, "access_denied")); + } + if let Some(mut link) = active_link(api, worker, &grant, None)? { + // Same active alias/identity is idempotent; explicit write never silently downgrades. + if request.access == Some(Access::ReadWrite) + && effective_access(&grant, &link) != Access::ReadWrite + { + return Err(fail(403, "access_denied")); + } + if access == Access::ReadOnly && effective_access(&grant, &link) != Access::ReadOnly { + link.capabilities = access_capabilities(access); + link = api + .store + .attach_worker_workdir(&link) + .map_err(mutation_error)?; + } + return Ok(attachment(&grant, &link, true)); + } + let link = api + .store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), + workspace_id: grant.workspace_id.clone(), + worker: worker.clone(), + workdir_id: grant.working_directory_id.clone(), + alias: ALIAS.into(), + capabilities: access_capabilities(access), + linked_at: now_registry_timestamp(), + unlinked_at: None, + }) + .map_err(mutation_error)?; + // No Runtime filesystem/session synchronization for a logical config target. + api.worker_projection + .refresh(worker) + .map_err(|_| unknown())?; + Ok(attachment(&grant, &link, false)) +} +fn resolve( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + connection_id: &str, +) -> ConfigResult<(WorkspaceConfigGrantResponse, WorkerWorkdirLinkRecord)> { + let grant = grant_for_worker(api, worker)?; + let link = active_link(api, worker, &grant, Some(connection_id))? + .ok_or_else(|| fail(409, "connection_changed"))?; + Ok((grant, link)) +} +fn path_valid(path: &str, root: bool) -> ConfigResult<()> { + if root && path.is_empty() { + return Ok(()); + } + config_source::VirtualPath::parse(path).map_err(|_| fail(400, "invalid_path"))?; + if path.chars().any(char::is_control) { + return Err(fail(400, "invalid_path")); + } + Ok(()) +} +fn tree(api: &WorkspaceApi) -> ConfigResult { + api.config_store + .load_workspace_config(&api.config.workspace_id) + .map_err(pre_error)? + .ok_or_else(|| fail(404, "not_found")) +} +fn validator( + grant: &WorkspaceConfigGrantResponse, + link: &WorkerWorkdirLinkRecord, + snapshot: &ConfigTreeSnapshot, + path: &str, +) -> String { + let bound = serde_json::to_vec(&( + "workspace-config:v1", + &grant.workspace_id, + &grant.grant_id, + &grant.runtime_id, + &grant.worker_id, + &link.connection_id, + effective_access(grant, link), + path, + snapshot.revision, + &snapshot.digest, + )) + .expect("bounded configuration validator tuple"); + format!( + "wc1:{}", + Sha256::digest(bound) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} +fn check_validator(actual: &str, expected: &str) -> ConfigResult<()> { + if actual != expected { + Err(fail(409, "stale_validator")) + } else { + Ok(()) + } +} +pub(super) async fn observe( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: WorkspaceConfigObserveRequest, +) -> ConfigResult { + if request.paths.is_empty() || request.paths.len() > MAX_NODES || request.depth > 8 { + return Err(fail(400, "limit_exceeded")); + } + for path in &request.paths { + path_valid(path, true)?; + } + let _guard = lock(api, worker).await?; + let (grant, link) = resolve(api, worker, &request.connection_id)?; + let state = tree(api)?; + let snapshot = &state.snapshot; + let writable = effective_access(&grant, &link) == Access::ReadWrite; + // Build only bounded metadata (the canonical tree itself is bounded by config-source). + let mut kinds = BTreeMap::from([(String::new(), NodeKind::Directory)]); + for path in snapshot.entries.keys() { + let text = path.as_str(); + kinds.insert(text.to_string(), NodeKind::File); + let mut cursor = text; + while let Some((parent, _)) = cursor.rsplit_once('/') { + kinds.entry(parent.into()).or_insert(NodeKind::Directory); + cursor = parent; + } + } + let mut selected = BTreeMap::new(); + for path in request.paths { + selected.insert( + path.clone(), + *kinds.get(&path).unwrap_or(&NodeKind::Missing), + ); + let prefix = if path.is_empty() { + String::new() + } else { + format!("{path}/") + }; + for (child, kind) in kinds.range(prefix.clone()..) { + if !child.starts_with(&prefix) { + break; + } + if child == &path { + continue; + } + let relative = &child[prefix.len()..]; + if relative.split('/').count() <= request.depth as usize { + selected.insert(child.clone(), *kind); + } + if selected.len() > MAX_NODES { + return Err(fail(413, "limit_exceeded")); + } + } + } + if selected.len() > MAX_NODES { + return Err(fail(413, "limit_exceeded")); + } + let nodes = selected + .into_iter() + .map(|(path, kind)| { + let entry = snapshot + .entries + .iter() + .find(|(key, _)| key.as_str() == path) + .map(|(_, entry)| entry); + let mut operations = match kind { + NodeKind::File => vec!["read".to_string()], + NodeKind::Directory | NodeKind::Missing => vec![], + }; + if writable { + match kind { + NodeKind::File => { + operations.extend(["edit", "write"].map(str::to_string)); + if path != crate::config_source::MAIN_CONFIG_ENTRYPOINT { + operations.push("delete".into()); + } + } + NodeKind::Missing => operations.push("create".into()), + NodeKind::Directory => { + operations.push("create".into()); + if path.is_empty() { + operations.push("apply_changes".into()); + } + } + } + } + WorkspaceConfigNode { + validator: validator(&grant, &link, snapshot, &path), + path, + kind, + digest: entry.map(|entry| entry.content_digest.clone()), + content_type: entry.map(|entry| match entry.content_type { + config_source::ConfigContentType::Decodal => { + server_api::ConfigContentType::Decodal + } + config_source::ConfigContentType::Text => server_api::ConfigContentType::Text, + }), + operations, + } + }) + .collect(); + Ok(WorkspaceConfigObserveResponse { + connection_id: link.connection_id.clone(), + validator: validator(&grant, &link, snapshot, ""), + revision: snapshot.revision, + digest: snapshot.digest.clone(), + entrypoints: state + .contract + .entrypoints + .iter() + .map(ToString::to_string) + .collect(), + nodes, + }) +} +pub(super) async fn read( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: WorkspaceConfigReadRequest, +) -> ConfigResult { + path_valid(&request.path, false)?; + let _guard = lock(api, worker).await?; + let (grant, link) = resolve(api, worker, &request.connection_id)?; + let state = tree(api)?; + let expected = validator(&grant, &link, &state.snapshot, &request.path); + check_validator(&request.validator, &expected)?; + let path = + config_source::VirtualPath::parse(&request.path).map_err(|_| fail(400, "invalid_path"))?; + let entry = state + .snapshot + .entries + .get(&path) + .ok_or_else(|| fail(404, "not_found"))?; + if entry.content.len() > MAX_TEXT { + return Err(fail(413, "limit_exceeded")); + } + Ok(WorkspaceConfigReadResponse { + path: request.path, + content: entry.content.clone(), + content_type: match entry.content_type { + config_source::ConfigContentType::Decodal => server_api::ConfigContentType::Decodal, + config_source::ConfigContentType::Text => server_api::ConfigContentType::Text, + }, + digest: entry.content_digest.clone(), + validator: expected, + }) +} +pub(super) async fn commit( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: WorkspaceConfigCommitRequest, +) -> ConfigResult { + if request.request.changes.len() > MAX_NODES { + return Err(fail(413, "limit_exceeded")); + } + for change in &request.request.changes { + match change { + server_api::ConfigTreeChange::Create { path, content, .. } + | server_api::ConfigTreeChange::Update { path, content, .. } => { + path_valid(path, false)?; + if content.len() > MAX_TEXT { + return Err(fail(413, "limit_exceeded")); + } + } + server_api::ConfigTreeChange::Rename { from, to, .. } => { + path_valid(from, false)?; + path_valid(to, false)?; + } + server_api::ConfigTreeChange::Delete { path, .. } => path_valid(path, false)?, + } + } + let _guard = lock(api, worker).await?; + let (grant, link) = resolve(api, worker, &request.connection_id)?; + if effective_access(&grant, &link) != Access::ReadWrite { + return Err(fail(403, "access_denied")); + } + let state = tree(api)?; + check_validator( + &request.validator, + &validator(&grant, &link, &state.snapshot, ""), + )?; + let canonical = config_commit_request_from_api(request.request).map_err(pre_error)?; + // Evaluation/semantic validation are pure preparation. Bound this stage + // without ever dispatching persistence in a timed-out background task. + let preparation_api = api.clone(); + let preparation = tokio::task::spawn_blocking(move || { + prepare_workspace_config_tree( + &preparation_api, + &preparation_api.config.workspace_id, + &canonical, + ) + }); + let candidate = tokio::time::timeout(DEADLINE, preparation) + .await + .map_err(|_| fail(503, "evaluation_timeout"))? + .map_err(|_| fail(500, "evaluation_unavailable"))? + .map_err(pre_error)?; + // Recheck durable authority before the first auxiliary/canonical effects. + let (current_grant, current_link) = resolve(api, worker, &request.connection_id)?; + if current_grant != grant || current_link.capabilities != link.capabilities { + return Err(fail(409, "authority_changed")); + } + // From this boundary repository credential projection may have side effects. + // Never falsely claim rollback or automatically replay a failed/uncertain save. + let state = persist_workspace_config_candidate(api, &api.config.workspace_id, &candidate) + .map_err(|_| unknown())?; + Ok(WorkspaceConfigCommitResponse { + validator: validator(&grant, &link, &state.snapshot, ""), + revision: state.snapshot.revision, + digest: state.snapshot.digest, + }) +} +pub(super) async fn create_grant( + api: &WorkspaceApi, + actor: &RequestActor, + workspace_id: &str, + request: WorkspaceConfigGrantCreateRequest, +) -> ConfigResult { + require_workspace_owner(api, workspace_id, actor, "Workspace config grants") + .await + .map_err(pre_error)?; + let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id); + let _guard = lock(api, &worker).await?; + let summary = api + .runtime + .worker(&worker) + .map_err(|_| fail(404, "worker_not_found"))?; + if summary.workspace.workspace_id.as_deref() != Some(workspace_id) { + return Err(fail(403, "access_denied")); + } + if let Some(grant) = api + .store + .current_workspace_config_grant(workspace_id, &worker) + .map_err(pre_error)? + { + if grant.access != request.access { + return Err(fail(409, "grant_exists_revoke_first")); + } + return Ok(grant); + } + let grant = WorkspaceConfigGrantResponse { + grant_id: Uuid::now_v7().to_string(), + workspace_id: workspace_id.into(), + runtime_id: worker.runtime_id, + worker_id: worker.worker_id, + working_directory_id: Uuid::now_v7().to_string(), + access: request.access, + revoked: false, + }; + api.store + .create_workspace_config_grant(&grant, &actor.account_id) + .map_err(mutation_error)?; + Ok(grant) +} +pub(super) async fn revoke_grant( + api: &WorkspaceApi, + actor: &RequestActor, + workspace_id: &str, + grant_id: &str, +) -> ConfigResult { + require_workspace_owner(api, workspace_id, actor, "Workspace config grants") + .await + .map_err(pre_error)?; + let mut grant = api + .store + .get_workspace_config_grant(workspace_id, grant_id) + .map_err(pre_error)? + .ok_or_else(|| fail(404, "not_found"))?; + let worker = RuntimeWorkerRef::new(&grant.runtime_id, &grant.worker_id); + let _guard = lock(api, &worker).await?; + api.store + .revoke_workspace_config_grant(workspace_id, grant_id) + .map_err(mutation_error)?; + grant.revoked = true; + // Revocation is durable before unlink; a failed cleanup cannot restore access. + if let Some(link) = api + .store + .list_workdir_worker_links(workspace_id, &grant.working_directory_id) + .map_err(|_| unknown())? + .into_iter() + .find(|link| link.unlinked_at.is_none() && link.worker == worker) + { + api.store + .detach_worker_workdir_connection( + workspace_id, + &worker, + &link.alias, + &link.connection_id, + &now_registry_timestamp(), + ) + .map_err(|_| unknown())?; + } + api.worker_projection + .refresh(&worker) + .map_err(|_| unknown())?; + Ok(grant) +} + +pub(super) fn summary( + grant: &WorkspaceConfigGrantResponse, + record: &WorkdirRegistryRecord, +) -> server_api::WorkingDirectorySummary { + server_api::WorkingDirectorySummary { + working_directory_id: record.workdir_id.clone(), + display_name: Some("Workspace configuration".into()), + source: server_api::WorkingDirectorySource::WorkspaceConfig { + access: grant.access, + content_path: "/workspace-config".into(), + purpose: "Workspace configuration".into(), + }, + creation_selector: None, + creation_ref: None, + creation_tree: None, + current_selector: None, + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materializer_kind: server_api::WorkingDirectoryMaterializerKind::LogicalWorkspaceConfig, + cleanup_target: None, + status: if grant.revoked { + server_api::WorkingDirectoryStatusKind::NotFound + } else { + server_api::WorkingDirectoryStatusKind::Active + }, + cleanliness: None, + occupied_by: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn workspace_config_mutation_storage_failures_are_conservatively_unknown() { + assert_eq!( + mutation_error(Error::Sqlite(rusqlite::Error::InvalidQuery)).classification, + Classification::Unknown + ); + assert_eq!( + mutation_error(Error::Store("private /host/storage acknowledgement".into())) + .classification, + Classification::Unknown + ); + let denied = mutation_error(Error::WorkspacePermissionDenied( + "secret permission context".into(), + )); + assert_eq!(denied.classification, Classification::NotCommitted); + assert_eq!(denied.status, 403); + assert!(!denied.message.contains("secret")); + } +} diff --git a/crates/workspace-server/src/server_workspace_config_tests.rs b/crates/workspace-server/src/server_workspace_config_tests.rs new file mode 100644 index 000000000..628a16456 --- /dev/null +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -0,0 +1,1297 @@ +// Included inside server::tests so the existing real Workspace/Runtime fixtures are reused. +mod workspace_config_integration { + include!("server_workspace_config_wip_tests.rs"); + use super::*; + use server_api::{ + WorkspaceConfigAccess as Access, WorkspaceConfigAttachRequest as Attach, + WorkspaceConfigAttachment as Attachment, WorkspaceConfigCommitRequest as Commit, + WorkspaceConfigFailureClassification as Classification, + WorkspaceConfigGrantCreateRequest as Grant, WorkspaceConfigObserveRequest as Observe, + WorkspaceConfigObserveResponse as Observed, WorkspaceConfigReadRequest as Read, + }; + + async fn grant( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + access: Access, + ) -> server_api::WorkspaceConfigGrantResponse { + workspace_config::create_grant( + api, + &test_owner_actor(), + TEST_WORKSPACE_ID, + Grant { + runtime_id: worker.runtime_id.clone(), + worker_id: worker.worker_id.clone(), + access, + }, + ) + .await + .unwrap() + } + async fn attach(api: &WorkspaceApi, worker: &RuntimeWorkerRef) -> Attachment { + workspace_config::attach( + api, + worker, + Attach { + alias: None, + access: None, + }, + ) + .await + .unwrap() + } + async fn observe( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + connection_id: &str, + paths: &[&str], + depth: u32, + ) -> Observed { + workspace_config::observe( + api, + worker, + Observe { + connection_id: connection_id.into(), + paths: paths.iter().map(|path| (*path).into()).collect(), + depth, + }, + ) + .await + .unwrap() + } + fn commit_request( + connection: &str, + snapshot: &Observed, + changes: Vec, + ) -> Commit { + Commit { + connection_id: connection.into(), + validator: snapshot.validator.clone(), + request: server_api::ConfigCommitRequest { + base_revision: snapshot.revision, + base_digest: snapshot.digest.clone(), + changes, + entrypoints: snapshot.entrypoints.clone(), + }, + } + } + fn create(path: &str, content: &str) -> server_api::ConfigTreeChange { + server_api::ConfigTreeChange::Create { + path: path.into(), + content_type: server_api::ConfigContentType::Text, + content: content.into(), + } + } + async fn second_worker(fixture: &ManualCoderAssignmentFixture) -> RuntimeWorkerRef { + let mut summary = fixture.api.runtime.worker(&fixture.worker).unwrap(); + summary.worker.worker_id = Uuid::now_v7().to_string(); + summary.display_name = "Second config editor".into(); + summary.label = summary.display_name.clone(); + summary.workdir_attachments.clear(); + fixture + .runtime + .workers + .lock() + .unwrap() + .push(summary.clone()); + sync_worker_observation(&fixture.api, &summary).unwrap(); + summary.worker + } + async fn signed_call( + api: &WorkspaceApi, + identity: &RuntimeIdentityMaterial, + worker: Option<&str>, + method: &str, + path: &str, + body: Value, + ) -> (StatusCode, Value) { + let bytes = if method == "GET" { + vec![] + } else { + serde_json::to_vec(&body).unwrap() + }; + let response = build_router(api.clone()) + .oneshot(runtime_source_request( + identity, worker, method, path, bytes, + )) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + ( + status, + serde_json::from_slice(&bytes) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned())), + ) + } + + #[tokio::test] + async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + grant(api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(api, &fixture.worker).await; + let observed = observe( + api, + &fixture.worker, + &attached.connection_id, + &["", "main.dcdl"], + 1, + ) + .await; + let main = observed + .nodes + .iter() + .find(|node| node.path == "main.dcdl") + .unwrap(); + assert_eq!(main.operations, ["read", "edit", "write"]); + let before = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + for change in [ + server_api::ConfigTreeChange::Delete { + path: "main.dcdl".into(), + expected_digest: main.digest.clone().unwrap(), + }, + server_api::ConfigTreeChange::Rename { + from: "main.dcdl".into(), + to: "renamed.dcdl".into(), + expected_digest: main.digest.clone().unwrap(), + }, + ] { + let failure = workspace_config::commit( + api, + &fixture.worker, + commit_request(&attached.connection_id, &observed, vec![change]), + ) + .await + .unwrap_err(); + assert_eq!(failure.classification, Classification::NotCommitted); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + before + ); + } + workspace_config::commit( + api, + &fixture.worker, + commit_request( + &attached.connection_id, + &observed, + vec![create("notes/deletable.md", "note")], + ), + ) + .await + .unwrap(); + let note = observe( + api, + &fixture.worker, + &attached.connection_id, + &["notes/deletable.md"], + 0, + ) + .await; + assert_eq!( + note.nodes[0].operations, + ["read", "edit", "write", "delete"] + ); + } + + #[tokio::test] + async fn workspace_config_grants_owner_scope_and_signed_routes() { + let mut fixture = manual_coder_assignment_fixture().await; + let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let api = &fixture.api; + let base = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workspace-config"); + assert_eq!( + signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "GET", + &base, + Value::Null + ) + .await + .0, + StatusCode::FORBIDDEN, + "unattached GET must still require a grant" + ); + assert_eq!( + signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "POST", + &base, + json!({}) + ) + .await + .0, + StatusCode::FORBIDDEN + ); + assert!( + api.store + .current_workspace_config_grant(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .is_none(), + "attach cannot create a grant" + ); + let mut other_owner = test_owner_actor(); + other_owner.account_id = "not-owner".into(); + let request = Grant { + runtime_id: fixture.worker.runtime_id.clone(), + worker_id: fixture.worker.worker_id.clone(), + access: Access::ReadWrite, + }; + assert_eq!( + workspace_config::create_grant(api, &other_owner, TEST_WORKSPACE_ID, request.clone()) + .await + .unwrap_err() + .status, + 403 + ); + assert!( + workspace_config::create_grant( + api, + &test_owner_actor(), + "foreign-workspace", + request.clone() + ) + .await + .is_err() + ); + // Exercise generated browser grant router with the authenticated owner extension. + let path = format!("/api/w/{TEST_WORKSPACE_ID}/workspace-config-grants"); + let response = build_inner_router(api.clone()) + .layer(Extension(test_owner_actor())) + .oneshot( + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&request).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let granted: server_api::WorkspaceConfigGrantResponse = serde_json::from_slice( + &to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + grant(api, &fixture.worker, Access::ReadWrite) + .await + .grant_id, + granted.grant_id + ); + let (status, current) = signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "GET", + &base, + Value::Null, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(current.is_null()); + let (status, current) = signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "POST", + &base, + json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK); + let attached: Attachment = serde_json::from_value(current).unwrap(); + let again: Attachment = serde_json::from_value( + signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "POST", + &base, + json!({}), + ) + .await + .1, + ) + .unwrap(); + assert!(again.already_attached); + assert_eq!(again.connection_id, attached.connection_id); + assert_eq!( + signed_call(api, &identity, None, "GET", &base, Value::Null) + .await + .0, + StatusCode::FORBIDDEN + ); + let mut forged = runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + "GET", + &base, + vec![], + ); + forged + .headers_mut() + .insert("x-yoi-worker-id", "forged-worker".parse().unwrap()); + assert_eq!( + build_router(api.clone()) + .oneshot(forged) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + let foreign = base.replace(TEST_WORKSPACE_ID, "foreign-workspace"); + assert_ne!( + signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "GET", + &foreign, + Value::Null + ) + .await + .0, + StatusCode::OK + ); + // A Worker proof is not an owner grant capability. + assert_ne!( + signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "POST", + &path, + serde_json::to_value(request).unwrap() + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + workspace_config::attach( + api, + &fixture.worker, + Attach { + alias: Some("other".into()), + access: None + } + ) + .await + .unwrap_err() + .status, + 400 + ); + let (status, metadata) = signed_call( + api, + &identity, + Some(&fixture.worker.worker_id), + "POST", + &format!("{base}/observe"), + json!({"connection_id":attached.connection_id,"paths":[""],"depth":1}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + metadata["nodes"] + .as_array() + .unwrap() + .iter() + .all(|node| node.get("content").is_none()) + ); + assert_eq!(metadata["entrypoints"], json!(["main.dcdl"])); + assert!( + api.runtime + .worker(&fixture.worker) + .unwrap() + .workdir_attachments + .is_empty(), + "logical source never creates a Runtime filesystem attachment" + ); + let runtime_logical = fixture + .runtime + .logical_attachments(&fixture.worker.worker_id); + assert!( + runtime_logical + .iter() + .all(|item| item.working_directory_id != attached.working_directory_id) + ); + let catalog = list_current_worker_workdir_catalog_for_worker( + api, + &fixture.worker, + Default::default(), + ) + .unwrap(); + let logical = catalog + .items + .iter() + .find(|item| item.working_directory_id == attached.working_directory_id) + .unwrap(); + assert!(logical.cleanup_target.is_none()); + assert_eq!( + logical.materializer_kind, + server_api::WorkingDirectoryMaterializerKind::LogicalWorkspaceConfig + ); + assert!(matches!( + logical.source, + server_api::WorkingDirectorySource::WorkspaceConfig { .. } + )); + assert!( + api.store + .delete_workdir_registry(TEST_WORKSPACE_ID, &attached.working_directory_id) + .is_err() + ); + let generic = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-attachments"); + assert_eq!(signed_call(api,&identity,Some(&fixture.worker.worker_id),"POST",&generic,json!({"alias":"workspace-config","working_directory_id":attached.working_directory_id})).await.0,StatusCode::FORBIDDEN); + let link = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .into_iter() + .find(|link| link.alias == "workspace-config") + .unwrap(); + assert!( + !link + .capabilities + .supports(workdir::WorkdirSessionCapability::Command) + ); + let lock = current_worker_session_lock(api, &fixture.worker); + let _guard = lock.lock().await; + assert!( + open_current_worker_workdir_session_locked(api, &fixture.worker, &link) + .await + .is_err() + ); + drop(_guard); + assert!( + cleanup_working_directory( + api.clone(), + ScopedWorkingDirectoryPath { + workspace_id: TEST_WORKSPACE_ID.into(), + working_directory_id: attached.working_directory_id.clone() + }, + "owner".into(), + WorkingDirectoryRemovalRequest { + reason: "unsupported logical cleanup".into() + } + ) + .await + .is_err() + ); + let revoke_path = format!( + "/api/w/{TEST_WORKSPACE_ID}/workspace-config-grants/{}", + granted.grant_id + ); + let response = build_inner_router(api.clone()) + .layer(Extension(test_owner_actor())) + .oneshot( + Request::builder() + .method("DELETE") + .uri(&revoke_path) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + workspace_config::get(api, &fixture.worker) + .await + .unwrap_err() + .status, + 403 + ); + } + + #[tokio::test] + async fn workspace_config_readonly_revoke_and_conditional_lifetime_fences() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + let read_grant = grant(api, &fixture.worker, Access::ReadOnly).await; + assert!( + workspace_config::get(api, &fixture.worker) + .await + .unwrap() + .is_none() + ); + assert_eq!( + workspace_config::attach( + api, + &fixture.worker, + Attach { + alias: None, + access: Some(Access::ReadWrite) + } + ) + .await + .unwrap_err() + .status, + 403 + ); + let attached = attach(api, &fixture.worker).await; + let observed = observe( + api, + &fixture.worker, + &attached.connection_id, + &["", "main.dcdl"], + 1, + ) + .await; + assert!( + observed + .nodes + .iter() + .all(|node| node.operations.iter().all(|operation| operation == "read")) + ); + let read_node = observed + .nodes + .iter() + .find(|node| node.path == "main.dcdl") + .unwrap(); + let read = Read { + connection_id: attached.connection_id.clone(), + path: "main.dcdl".into(), + validator: read_node.validator.clone(), + }; + assert!( + !workspace_config::read(api, &fixture.worker, read.clone()) + .await + .unwrap() + .content + .is_empty() + ); + let denied = workspace_config::commit( + api, + &fixture.worker, + commit_request( + &attached.connection_id, + &observed, + vec![create("notes/no.md", "no")], + ), + ) + .await + .unwrap_err(); + assert_eq!(denied.status, 403); + assert_eq!(denied.classification, Classification::NotCommitted); + let second = second_worker(&fixture).await; + assert_eq!( + workspace_config::read(api, &second, read.clone()) + .await + .unwrap_err() + .status, + 403 + ); + let second_grant = grant(api, &second, Access::ReadWrite).await; + assert_ne!( + second_grant.working_directory_id, + read_grant.working_directory_id + ); + assert_eq!( + workspace_config::read(api, &second, read.clone()) + .await + .unwrap_err() + .code, + "connection_changed" + ); + let detached = detach_current_worker_workdir( + api, + &fixture.worker, + "workspace-config", + Some(&attached.connection_id), + ) + .await + .unwrap(); + assert!(!detached.attached); + assert_eq!( + workspace_config::read(api, &fixture.worker, read.clone()) + .await + .unwrap_err() + .code, + "connection_changed" + ); + let reattached = attach(api, &fixture.worker).await; + assert_ne!(reattached.connection_id, attached.connection_id); + assert!( + detach_current_worker_workdir( + api, + &fixture.worker, + "workspace-config", + Some(&attached.connection_id) + ) + .await + .is_err() + ); + assert_eq!( + workspace_config::get(api, &fixture.worker) + .await + .unwrap() + .unwrap() + .connection_id, + reattached.connection_id + ); + workspace_config::revoke_grant( + api, + &test_owner_actor(), + TEST_WORKSPACE_ID, + &read_grant.grant_id, + ) + .await + .unwrap(); + assert_eq!( + workspace_config::get(api, &fixture.worker) + .await + .unwrap_err() + .status, + 403 + ); + assert_eq!( + workspace_config::read(api, &fixture.worker, read) + .await + .unwrap_err() + .status, + 403 + ); + assert_eq!( + workspace_config::attach( + api, + &fixture.worker, + Attach { + alias: None, + access: None + } + ) + .await + .unwrap_err() + .status, + 403 + ); + assert!( + workspace_config::revoke_grant( + api, + &test_owner_actor(), + "foreign-workspace", + &second_grant.grant_id + ) + .await + .is_err() + ); + assert!( + api.store + .current_workspace_config_grant(TEST_WORKSPACE_ID, &second) + .unwrap() + .is_some() + ); + let writable = attach(api, &second).await; + let restricted = workspace_config::attach( + api, + &second, + Attach { + alias: None, + access: Some(Access::ReadOnly), + }, + ) + .await + .unwrap(); + assert_eq!(restricted.connection_id, writable.connection_id); + assert_eq!(restricted.access, Access::ReadOnly); + assert_eq!( + attach(api, &second).await.access, + Access::ReadOnly, + "default repeat must honor current attenuation" + ); + assert_eq!( + workspace_config::attach( + api, + &second, + Attach { + alias: None, + access: Some(Access::ReadWrite) + } + ) + .await + .unwrap_err() + .status, + 403 + ); + } + + #[tokio::test] + async fn workspace_config_two_workers_ui_cas_and_identical_virtual_replacement() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + let second = second_worker(&fixture).await; + grant(api, &fixture.worker, Access::ReadWrite).await; + grant(api, &second, Access::ReadWrite).await; + let a = attach(api, &fixture.worker).await; + let b = attach(api, &second).await; + let first = observe(api, &fixture.worker, &a.connection_id, &[""], 0).await; + let other = observe(api, &second, &b.connection_id, &[""], 0).await; + assert_eq!(first.revision, other.revision); + assert_eq!(first.digest, other.digest); + assert_ne!(first.validator, other.validator); + workspace_config::commit( + api, + &fixture.worker, + commit_request( + &a.connection_id, + &first, + vec![create("notes/concurrent.md", "first")], + ), + ) + .await + .unwrap(); + let rejected = workspace_config::commit( + api, + &second, + commit_request( + &b.connection_id, + &other, + vec![create("notes/second.md", "second")], + ), + ) + .await + .unwrap_err(); + assert_eq!(rejected.code, "stale_validator"); + assert_eq!(rejected.classification, Classification::NotCommitted); + let fresh = observe(api, &second, &b.connection_id, &["notes/concurrent.md"], 0).await; + let file = fresh.nodes.first().unwrap(); + let old_read = Read { + connection_id: b.connection_id.clone(), + path: file.path.clone(), + validator: file.validator.clone(), + }; + let canonical = config_commit_request_from_api(server_api::ConfigCommitRequest { + base_revision: fresh.revision, + base_digest: fresh.digest.clone(), + entrypoints: fresh.entrypoints.clone(), + changes: vec![server_api::ConfigTreeChange::Update { + path: file.path.clone(), + expected_digest: file.digest.clone().unwrap(), + content: "first".into(), + }], + }) + .unwrap(); + // Same bytes, new virtual identity/revision via the ORIGINAL UI commit boundary. + commit_workspace_config_tree(api, TEST_WORKSPACE_ID, &canonical).unwrap(); + let after = observe(api, &second, &b.connection_id, &["notes/concurrent.md"], 0).await; + assert_eq!(after.digest, fresh.digest); + assert!(after.revision > fresh.revision); + assert_ne!(after.validator, fresh.validator); + assert_eq!( + workspace_config::read(api, &second, old_read) + .await + .unwrap_err() + .code, + "stale_validator" + ); + assert_eq!( + workspace_config::commit( + api, + &second, + commit_request( + &b.connection_id, + &fresh, + vec![create("notes/stale.md", "stale")] + ) + ) + .await + .unwrap_err() + .code, + "stale_validator" + ); + let delete = vec![server_api::ConfigTreeChange::Delete { + path: file.path.clone(), + expected_digest: file.digest.clone().unwrap(), + }]; + workspace_config::commit( + api, + &second, + commit_request(&b.connection_id, &after, delete), + ) + .await + .unwrap(); + let deleted = observe(api, &second, &b.connection_id, &[""], 0).await; + workspace_config::commit( + api, + &second, + commit_request( + &b.connection_id, + &deleted, + vec![create(&file.path, "replacement")], + ), + ) + .await + .unwrap(); + let state = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + assert_eq!( + state + .snapshot + .get(&config_source::VirtualPath::parse("notes/concurrent.md").unwrap()) + .unwrap() + .content, + "replacement" + ); + assert!( + state + .snapshot + .get(&config_source::VirtualPath::parse("notes/second.md").unwrap()) + .is_none() + ); + // Canonical immediate transaction still rejects a race after evaluation. + let current = observe(api, &second, &b.connection_id, &[""], 0).await; + let request = config_commit_request_from_api( + commit_request( + &b.connection_id, + ¤t, + vec![create("notes/raced.md", "race")], + ) + .request, + ) + .unwrap(); + let candidate = prepare_workspace_config_tree(api, TEST_WORKSPACE_ID, &request).unwrap(); + let ui = config_commit_request_from_api( + commit_request( + &b.connection_id, + ¤t, + vec![create("notes/ui.md", "ui")], + ) + .request, + ) + .unwrap(); + commit_workspace_config_tree(api, TEST_WORKSPACE_ID, &ui).unwrap(); + assert!( + api.config_store + .commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate) + .is_err() + ); + } + + #[tokio::test] + async fn workspace_config_invalid_bounds_and_failed_save_do_not_corrupt_tree() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + grant(api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(api, &fixture.worker).await; + let before = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let observed = observe( + api, + &fixture.worker, + &attached.connection_id, + &["", "main.dcdl"], + 0, + ) + .await; + let main = observed + .nodes + .iter() + .find(|node| node.path == "main.dcdl") + .unwrap(); + for changes in [ + vec![server_api::ConfigTreeChange::Update { + path: "main.dcdl".into(), + expected_digest: main.digest.clone().unwrap(), + content: "not valid Decodal".into(), + }], + vec![server_api::ConfigTreeChange::Delete { + path: "main.dcdl".into(), + expected_digest: main.digest.clone().unwrap(), + }], + vec![create("../escape", "no")], + vec![create("bad/\nsecret", "no")], + vec![create(&"x".repeat(513), "no")], + vec![create("notes/large.md", &"x".repeat(256 * 1024 + 1))], + (0..257) + .map(|i| create(&format!("notes/{i}.md"), "no")) + .collect(), + ] { + let error = workspace_config::commit( + api, + &fixture.worker, + commit_request(&attached.connection_id, &observed, changes), + ) + .await + .unwrap_err(); + assert_eq!(error.classification, Classification::NotCommitted); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + before + ); + assert!(!error.message.contains("Decodal\"")); + } + for (paths, depth) in [ + (vec!["".to_string()], 9), + (vec!["/host/secret".to_string()], 0), + (vec!["".to_string(); 257], 0), + ] { + assert!( + workspace_config::observe( + api, + &fixture.worker, + Observe { + connection_id: attached.connection_id.clone(), + paths, + depth + } + ) + .await + .is_err() + ); + } + let missing = observe( + api, + &fixture.worker, + &attached.connection_id, + &["notes/missing.md"], + 0, + ) + .await; + assert_eq!( + missing.nodes[0].kind, + server_api::WorkspaceConfigNodeKind::Missing + ); + assert_eq!(missing.nodes[0].operations, ["create"]); + api.config_store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER config_test_save_failure BEFORE INSERT ON workspace_config_entries WHEN NEW.path='notes/fail.md' BEGIN SELECT RAISE(ABORT,'private /host/path'); END;")?;Ok(()) }).unwrap(); + let error = workspace_config::commit( + api, + &fixture.worker, + commit_request( + &attached.connection_id, + &observed, + vec![create("notes/fail.md", "safe")], + ), + ) + .await + .unwrap_err(); + // The API conservatively marks failures past the auxiliary-effects boundary unknown. + assert_eq!(error.classification, Classification::Unknown); + assert!(!error.message.contains("private")); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + before + ); + api.config_store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER config_test_save_failure;")?; + Ok(()) + }) + .unwrap(); + workspace_config::commit( + api, + &fixture.worker, + commit_request( + &attached.connection_id, + &observed, + vec![create("notes/ok.md", "ok")], + ), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn workspace_config_operation_and_revoke_share_the_detach_session_lock() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + let granted = grant(api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(api, &fixture.worker).await; + let observed = observe(api, &fixture.worker, &attached.connection_id, &[""], 0).await; + let lock = current_worker_session_lock(api, &fixture.worker); + let held = lock.lock().await; + let mut operation = Box::pin(workspace_config::commit( + api, + &fixture.worker, + commit_request( + &attached.connection_id, + &observed, + vec![create("notes/locked.md", "no")], + ), + )); + let owner = test_owner_actor(); + let mut revoke = Box::pin(workspace_config::revoke_grant( + api, + &owner, + TEST_WORKSPACE_ID, + &granted.grant_id, + )); + // Poll once, without sleeps/time-based ordering: both must be fenced by held lock. + assert!(matches!( + futures::poll!(operation.as_mut()), + std::task::Poll::Pending + )); + assert!(matches!( + futures::poll!(revoke.as_mut()), + std::task::Poll::Pending + )); + drop(held); + // Commit queued first, then revocation; subsequent calls cannot use its cached validator. + operation.await.unwrap(); + revoke.await.unwrap(); + assert_eq!( + workspace_config::commit( + api, + &fixture.worker, + commit_request( + &attached.connection_id, + &observed, + vec![create("notes/after.md", "no")] + ) + ) + .await + .unwrap_err() + .status, + 403 + ); + } + #[tokio::test] + async fn workspace_config_grant_restart_source_immutability_and_owner_acceptance_guard() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + let granted = grant(api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(api, &fixture.worker).await; + let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + assert_eq!( + restarted + .get_workspace_config_grant(TEST_WORKSPACE_ID, &granted.grant_id) + .unwrap(), + Some(granted.clone()) + ); + assert!( + restarted + .get_workspace_config_grant("foreign-workspace", &granted.grant_id) + .unwrap() + .is_none() + ); + let record = api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &attached.working_directory_id) + .unwrap() + .unwrap(); + let mut forged = record.clone(); + forged.source = WorkdirRegistrySource::Repository { + runtime_id: fixture.worker.runtime_id.clone(), + repository_id: test_repository_id(api), + }; + assert!( + api.store.upsert_workdir_registry(&forged).is_err(), + "Runtime observation must never replace a logical config source identity" + ); + assert_eq!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, &attached.working_directory_id) + .unwrap(), + Some(record) + ); + let second = second_worker(&fixture).await; + let requested = server_api::WorkspaceConfigGrantResponse { + grant_id: Uuid::now_v7().to_string(), + workspace_id: TEST_WORKSPACE_ID.into(), + runtime_id: second.runtime_id.clone(), + worker_id: second.worker_id.clone(), + working_directory_id: Uuid::now_v7().to_string(), + access: Access::ReadOnly, + revoked: false, + }; + assert!( + api.store + .create_workspace_config_grant(&requested, "not-owner") + .is_err(), + "storage transaction rechecks owner acceptance" + ); + api.config_store + .with_conn(|conn| { + conn.execute( + "UPDATE workspaces SET state='deleting' WHERE workspace_id=?1", + [TEST_WORKSPACE_ID], + )?; + Ok(()) + }) + .unwrap(); + assert!( + api.store + .create_workspace_config_grant(&requested, &test_owner_actor().account_id) + .is_err(), + "existing Workspace mutation lifecycle gate must also fence grants" + ); + api.config_store + .with_conn(|conn| { + conn.execute( + "UPDATE workspaces SET state='active' WHERE workspace_id=?1", + [TEST_WORKSPACE_ID], + )?; + Ok(()) + }) + .unwrap(); + assert!( + api.store + .current_workspace_config_grant(TEST_WORKSPACE_ID, &second) + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn workspace_config_auxiliary_secret_effects_are_unknown_not_false_rollback() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + grant(api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(api, &fixture.worker).await; + let observed = observe( + api, + &fixture.worker, + &attached.connection_id, + &["", "main.dcdl"], + 0, + ) + .await; + let main = observed + .nodes + .iter() + .find(|node| node.path == "main.dcdl") + .unwrap(); + let before = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let count = || { + api.config_store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM repository_ssh_credentials WHERE workspace_id=?1", + [TEST_WORKSPACE_ID], + |row| row.get::<_, i64>(0), + ) + .map_err(Error::from) + }) + .unwrap() + }; + let before_secrets = count(); + let source = r#"{ repository_access = { missing_repository = { ssh = { credential = "workspace-default"; host_trust = "missing-host-trust"; access = "read_write"; }; }; }; } as WorkspaceConfigSchema"#; + let change = server_api::ConfigTreeChange::Update { + path: "main.dcdl".into(), + expected_digest: main.digest.clone().unwrap(), + content: source.into(), + }; + let error = workspace_config::commit( + api, + &fixture.worker, + commit_request(&attached.connection_id, &observed, vec![change]), + ) + .await + .unwrap_err(); + assert_eq!( + error.classification, + Classification::Unknown, + "repository projection can create a default credential before rejecting source/reference validation" + ); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + before, + "canonical config stays unchanged" + ); + assert!( + count() > before_secrets, + "auxiliary credential side effect must be demonstrably durable; cannot label this NotCommitted" + ); + assert!(!error.message.contains("host_trust") && !error.message.contains("private")); + } + #[tokio::test] + async fn workspace_config_observe_node_limit_is_complete_or_error_not_silent_truncation() { + let fixture = manual_coder_assignment_fixture().await; + let api = &fixture.api; + grant(api, &fixture.worker, Access::ReadWrite).await; + let attached = attach(api, &fixture.worker).await; + let observed = observe(api, &fixture.worker, &attached.connection_id, &[""], 0).await; + let state = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let count = config_source::MAX_ENTRY_COUNT - state.snapshot.entries.len(); + let changes = (0..count) + .map(|i| create(&format!("bounded/{i}.md"), "text")) + .collect(); + workspace_config::commit( + api, + &fixture.worker, + commit_request(&attached.connection_id, &observed, changes), + ) + .await + .unwrap(); + let overflow = workspace_config::observe( + api, + &fixture.worker, + Observe { + connection_id: attached.connection_id.clone(), + paths: vec!["".into()], + depth: 8, + }, + ) + .await + .unwrap_err(); + assert_eq!(overflow.status, 413); + let root = observe(api, &fixture.worker, &attached.connection_id, &[""], 0).await; + assert_eq!(root.nodes.len(), 1); + let directory = observe( + api, + &fixture.worker, + &attached.connection_id, + &["bounded"], + 1, + ) + .await; + assert_eq!(directory.nodes.len(), count + 1); + assert!(directory.nodes.len() <= 256); + let direct = observe( + api, + &fixture.worker, + &attached.connection_id, + &["bounded/0.md"], + 0, + ) + .await; + assert_eq!( + direct.nodes[0], + *directory + .nodes + .iter() + .find(|node| node.path == "bounded/0.md") + .unwrap(), + "listing and direct lookup must expose identical metadata/auth/validator" + ); + } +} diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs new file mode 100644 index 000000000..28a7c496e --- /dev/null +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -0,0 +1,863 @@ +#[derive(Clone)] +struct ConfigNoopLlmClient { + api: WorkspaceApi, + worker: RuntimeWorkerRef, + calls: Arc, +} +#[async_trait] +impl agen::llm_client::LlmClient for ConfigNoopLlmClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + request: agen::llm_client::Request, + ) -> std::result::Result< + std::pin::Pin< + Box< + dyn futures::Stream< + Item = std::result::Result< + agen::llm_client::event::Event, + agen::llm_client::ClientError, + >, + > + Send, + >, + >, + agen::llm_client::ClientError, + > { + let grant = self + .api + .store + .current_workspace_config_grant(TEST_WORKSPACE_ID, &self.worker) + .unwrap() + .unwrap(); + assert!( + self.api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &self.worker) + .unwrap() + .iter() + .any(|link| link.unlinked_at.is_none() + && link.alias == "workspace-config" + && link.workdir_id == grant.working_directory_id), + "real Backend attachment must precede the dependent LLM request" + ); + assert!( + format!("{:?}", request.items).contains("Workspace configuration is available"), + "selected handler's preparation context must reach the actual LLM request" + ); + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(Box::pin(futures::stream::empty())) + } +} +// Real production Feature/WIP adapters over a signed in-process Backend HTTP router. +// No fake config provider, copied tree, or fabricated attachment ledger is used. +#[derive(Clone)] +struct ConfigRouterClient { + api: WorkspaceApi, + worker: RuntimeWorkerRef, + identity: Arc, + runtime: tokio::runtime::Handle, + paths: Arc>>, + drop_next_commit_response: Arc, +} +impl std::fmt::Debug for ConfigRouterClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Authenticated ConfigRouterClient") + } +} +impl worker::WorkspaceClient for ConfigRouterClient { + fn workspace_id(&self) -> Option<&str> { + Some(TEST_WORKSPACE_ID) + } + fn kind(&self) -> &str { + "authenticated-backend-router" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: worker::WorkspaceRequest, + ) -> std::result::Result { + use worker::{ + WorkspaceClientError as Failure, WorkspaceRequestMethod as Method, WorkspaceResponse, + }; + let method = match request.method { + Method::Get => "GET", + Method::Post => "POST", + Method::Put => "PUT", + Method::Patch => "PATCH", + Method::Delete => "DELETE", + }; + self.paths + .lock() + .unwrap() + .push(format!("{method} {}", request.path)); + let is_commit = request.path.ends_with("/commit"); + let api = self.api.clone(); + let identity = self.identity.clone(); + let worker = self.worker.clone(); + let runtime = self.runtime.clone(); + // Feature validation is synchronous, including on a Tokio thread. A separate + // thread enters the existing runtime instead of nesting a Tokio runtime. + let response = std::thread::spawn(move || { + runtime.block_on(async move { + let body = request.body.unwrap_or_default().into_bytes(); + let signed = runtime_source_request( + &identity, + Some(&worker.worker_id), + method, + &request.path, + body, + ); + let response = build_router(api) + .oneshot(signed) + .await + .map_err(|_| Failure::Request("router dispatch failed".into()))?; + let status = response.status().as_u16(); + let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .map_err(|_| Failure::Request("response unavailable after dispatch".into()))?; + let body = String::from_utf8(bytes.to_vec()) + .map_err(|_| Failure::Request("invalid response".into()))?; + Ok(WorkspaceResponse { status, body }) + }) + }) + .join() + .map_err(|_| Failure::Request("dispatch completion unavailable".into()))??; + if is_commit + && response.is_success() + && self + .drop_next_commit_response + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + return Err(Failure::Request( + "simulated lost response AFTER committed dispatch".into(), + )); + } + Ok(response) + } +} +fn config_interface(path: &str) -> String { + // Use the published contextual interface identity, not a model-managed validator. + let hex = path + .bytes() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("yoi.workspace-config/node/v1/@/{hex}") +} +async fn config_discover(runtime: &worker::wip::WipRuntime, path: &str) { + let observed = runtime + .discover(path.into(), 0, true) + .await + .unwrap() + .content + .unwrap(); + assert!( + observed.contains(&config_interface(path)), + "interface must come from live discovery: {observed}" + ); + runtime.inspect(config_interface(path), true).await.unwrap(); +} +async fn config_call( + runtime: &worker::wip::WipRuntime, + path: &str, + operation: &str, + args: Value, +) -> std::result::Result { + runtime + .call( + path.into(), + config_interface(path), + operation.into(), + args, + agen::tool::ToolExecutionContext::direct(), + ) + .await +} + +async fn catalog_call( + runtime: &worker::wip::WipRuntime, + path: &str, + base: &str, + operation: &str, +) -> Value { + let hex = path + .bytes() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let interface = format!("{base}/@/{hex}"); + let discovered = runtime + .discover(path.into(), 0, true) + .await + .unwrap() + .content + .unwrap(); + assert!(discovered.contains(&interface), "{discovered}"); + runtime.inspect(interface.clone(), true).await.unwrap(); + let output = runtime + .call( + path.into(), + interface, + operation.into(), + json!({}), + agen::tool::ToolExecutionContext::direct(), + ) + .await + .unwrap(); + serde_json::from_str(output.content.as_deref().unwrap()).unwrap() +} + +async fn assert_config_catalog(runtime: &worker::wip::WipRuntime, attached: &Attachment) { + let list = catalog_call( + runtime, + "/workdir-attachments", + "yoi.workdir-attachment/collection/v1", + "list", + ) + .await; + let listed = list["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["connection_id"] == attached.connection_id) + .unwrap(); + let read = catalog_call( + runtime, + &format!("/workdir-attachments/{}", attached.connection_id), + "yoi.workdir-attachment/item/v1", + "read", + ) + .await; + for item in [listed, &read] { + assert_eq!(item["connection_id"], attached.connection_id); + assert_eq!(item["working_directory_id"], attached.working_directory_id); + assert_eq!(item["alias"], attached.alias); + assert_eq!(item["content_path"], "/workspace-config"); + assert_eq!( + item["access"], + serde_json::to_value(attached.access).unwrap() + ); + assert_eq!(item["name"], attached.name); + assert_eq!(item["purpose"], attached.purpose); + assert!(item.get("checkout_path").is_none() && item.get("checkout_slug").is_none()); + } + let workdir = catalog_call( + runtime, + &format!("/workdirs/{}", attached.working_directory_id), + "yoi.workdir/item/v1", + "read", + ) + .await; + assert_eq!(workdir["source"]["kind"], "workspace_config"); + assert_eq!(workdir["source"]["content_path"], "/workspace-config"); + assert!(workdir["source"].get("grant_id").is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_import_and_unknown() { + use worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip; + use worker::feature::builtin::workspace_config::{CONTENT_ROOT, WorkspaceConfigFeature}; + let mut fixture = manual_coder_assignment_fixture().await; + let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let granted = grant(&fixture.api, &fixture.worker, Access::ReadWrite).await; + let client = Arc::new(ConfigRouterClient { + api: fixture.api.clone(), + worker: fixture.worker.clone(), + identity: Arc::new(identity), + runtime: tokio::runtime::Handle::current(), + paths: Default::default(), + drop_next_commit_response: Default::default(), + }); + let feature = WorkspaceConfigFeature::for_workspace(client.clone(), true); + let mut mounts = worker::wip::WipMountRegistry::new(); + mount_workspace_config_wip(&mut mounts, &feature).unwrap(); + worker::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( + &mut mounts, + &worker::feature::builtin::manage_workdir::ManageWorkdirFeature::new(client.clone()), + true, + false, + None, + ) + .unwrap(); + let wip = worker::wip::WipRuntime::from_mounts( + mounts, + format!( + "{}:{}@{TEST_WORKSPACE_ID}", + fixture.worker.runtime_id, fixture.worker.worker_id + ), + ) + .unwrap(); + let manifest=worker::WorkerManifest::from_toml("[worker]\nname=\"config-e2e\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap(); + let calls = Arc::new(AtomicU64::new(0)); + let engine = + agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated( + ConfigNoopLlmClient { + api: fixture.api.clone(), + worker: fixture.worker.clone(), + calls: calls.clone(), + }, + ); + let store = + session_store::FsStore::new(fixture._workspace.path().join("config-e2e-sessions")).unwrap(); + let mut host = worker::Worker::new( + manifest, + engine, + store, + worker::WorkerWorkspaceContext::with_client( + Some(worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap()), + client.clone(), + ), + worker::WorkerFilesystemAuthority::None, + manifest::Scope::empty(), + ) + .await + .unwrap(); + let report = + host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module(feature)); + assert!( + report.installed_tool_names().is_empty() && !report.has_errors(), + "WIP-only installation cannot expose a compatibility config Tool" + ); + let selected = report + .chat_invocations + .feature_completions("workspace-c") + .remove(0) + .invocation + .unwrap(); + let invocation = protocol::FeatureInvocation { + invocation_id: "selected-config-invoke".into(), + identity: selected.identity, + name: selected.name, + arguments: vec![], + }; + assert!( + workspace_config::get(&fixture.api, &fixture.worker) + .await + .unwrap() + .is_none() + ); + host.run(vec![ + protocol::Segment::FeatureInvoke { + invocation: invocation.clone(), + }, + protocol::Segment::text("Edit this Workspace configuration"), + ]) + .await + .unwrap(); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "production Worker run must issue its dependent LLM request only after preparation" + ); + let attached = workspace_config::get(&fixture.api, &fixture.worker) + .await + .unwrap() + .unwrap(); + assert_eq!(attached.working_directory_id, granted.working_directory_id); + assert_config_catalog(&wip, &attached).await; + assert!( + report + .chat_invocations + .invoke(&protocol::FeatureInvocation { + invocation_id: "repeat-config-invoke".into(), + ..invocation.clone() + }) + .await + .unwrap() + .message + .starts_with("Already attached") + ); + assert_eq!( + workspace_config::get(&fixture.api, &fixture.worker) + .await + .unwrap() + .unwrap() + .connection_id, + attached.connection_id + ); + config_discover(&wip, CONTENT_ROOT).await; + config_call( + &wip, + CONTENT_ROOT, + "create", + json!({"path":"notes/wip.md","content_type":"text","content":"original note"}), + ) + .await + .unwrap(); + let path = "/workspace-config/notes/wip.md"; + config_discover(&wip, path).await; + assert!( + config_call(&wip, path, "read", json!({})) + .await + .unwrap() + .content + .unwrap() + .contains("original note") + ); + config_call( + &wip, + path, + "edit", + json!({"old_string":"original","new_string":"edited"}), + ) + .await + .unwrap(); + let state = fixture + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + assert_eq!( + state + .snapshot + .get(&config_source::VirtualPath::parse("notes/wip.md").unwrap()) + .unwrap() + .content, + "edited note" + ); + config_call(&wip, path, "write", json!({"content":"replacement note"})) + .await + .unwrap(); + config_discover(&wip, CONTENT_ROOT).await; + config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[ + {"kind":"create","path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"}, + {"kind":"update","path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"} + ]})).await.unwrap(); + // Confirm through ORIGINAL config reader/projection, not a copied WIP store. + let state = fixture + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap(); + assert_eq!(prompts.templates["common.language"], "CONFIG_WIP_E2E"); + config_discover(&wip, path).await; + config_call(&wip, path, "delete", json!({})).await.unwrap(); + assert!( + fixture + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap() + .snapshot + .get(&config_source::VirtualPath::parse("notes/wip.md").unwrap()) + .is_none() + ); + config_discover(&wip, CONTENT_ROOT).await; + let before = fixture + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let dispatched = client + .paths + .lock() + .unwrap() + .iter() + .filter(|path| path.ends_with("/commit")) + .count(); + client + .drop_next_commit_response + .store(true, std::sync::atomic::Ordering::SeqCst); + let unknown=config_call(&wip,CONTENT_ROOT,"create",json!({"path":"notes/lost-response.md","content_type":"text","content":"committed exactly once"})).await.unwrap_err(); + assert!(unknown.to_string().contains("unknown"), "{unknown}"); + let after = fixture + .api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + assert_eq!(after.snapshot.revision, before.snapshot.revision + 1); + assert!( + after + .snapshot + .get(&config_source::VirtualPath::parse("notes/lost-response.md").unwrap()) + .is_some() + ); + assert_eq!( + client + .paths + .lock() + .unwrap() + .iter() + .filter(|path| path.ends_with("/commit")) + .count(), + dispatched + 1, + "a lost response cannot trigger automatic retry" + ); + config_discover(&wip, CONTENT_ROOT).await; + let readonly = workspace_config::attach( + &fixture.api, + &fixture.worker, + Attach { + alias: None, + access: Some(Access::ReadOnly), + }, + ) + .await + .unwrap(); + assert_eq!(readonly.connection_id, attached.connection_id); + assert_config_catalog(&wip, &readonly).await; + workspace_config::revoke_grant( + &fixture.api, + &test_owner_actor(), + TEST_WORKSPACE_ID, + &granted.grant_id, + ) + .await + .unwrap(); + assert!( + config_call( + &wip, + CONTENT_ROOT, + "create", + json!({"path":"notes/revoked.md","content":"denied"}) + ) + .await + .is_err(), + "cached interface reference never grants access after revoke" + ); + let after_revoke = catalog_call( + &wip, + "/workdir-attachments", + "yoi.workdir-attachment/collection/v1", + "list", + ) + .await; + assert!( + after_revoke["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["connection_id"] != attached.connection_id) + ); + assert!( + client + .paths + .lock() + .unwrap() + .iter() + .all(|path| !path.contains("workdir-session") && !path.contains("bash")) + ); +} + +// Capture the client from an actual production embedded controller factory, +// rather than fabricating an "embedded" WorkspaceClient in the test. +struct ConfigCaptureFactory { + inner: worker_runtime::worker_backend::ProfileRuntimeWorkerFactory, + client: Arc>>>, + controller_abort: Arc>>, +} +#[async_trait] +impl worker_runtime::worker_backend::RuntimeWorkerFactory for ConfigCaptureFactory { + async fn spawn_controller( + &self, + mut request: worker_runtime::execution::WorkerExecutionSpawnRequest, + ) -> std::result::Result { + // No LLM request is issued; explicit unauthenticated test model avoids + // depending on developer credentials during production bootstrap. + let archive = worker_runtime::profile_archive::ProfileSourceArchive::build(worker_runtime::profile_archive::ProfileSourceArchiveInput { + id: "profile-source-archive:config-adapter-e2e".into(), + entrypoints: BTreeMap::from([("builtin:companion".into(), "profiles/default.dcdl".into())]), + imports: BTreeMap::new(), + sources: BTreeMap::from([("profiles/default.dcdl".into(), r#"{ + slug = "default"; description = "Config adapter test"; scope = "workspace_read"; + model = { scheme = "anthropic"; model_id = "test-model"; auth = { kind = "none"; }; }; + engine = { max_tokens = 100; }; + }"#.into())]), + }).map_err(|error| error.to_string())?; + request.request.profile_source = + worker_runtime::catalog::ProfileSourceArchiveSource::Embedded { archive }; + let controller = self.inner.spawn_controller(request).await?; + *self.client.lock().unwrap() = Some(controller.workspace_client.clone()); + *self.controller_abort.lock().unwrap() = Some(controller.controller_task.abort_handle()); + Ok(controller) + } + async fn restore_controller( + &self, + request: worker_runtime::execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result { + self.inner.restore_controller(request).await + } +} + +async fn assert_config_production_http_adapter( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + client: Arc, +) { + use worker::feature::builtin::workspace_config::backend::WorkspaceConfigBackend; + use worker::feature::builtin::workspace_config::{ + WorkspaceConfigAttachmentBackend, WorkspaceConfigFeature, + }; + assert_eq!(client.kind(), "runtime-owned-workspace-client"); + let backend = WorkspaceConfigBackend::new(client.clone()); + assert!( + backend.current().is_err(), + "valid source proof cannot manufacture a config grant" + ); + grant(api, worker, Access::ReadWrite).await; + backend + .attach("production-adapter-test", None) + .await + .unwrap(); + let attached = workspace_config::get(api, worker).await.unwrap().unwrap(); + let feature = WorkspaceConfigFeature::for_workspace(client.clone(), true); + let mut mounts = worker::wip::WipMountRegistry::new(); + worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip( + &mut mounts, + &feature, + ) + .unwrap(); + worker::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( + &mut mounts, + &worker::feature::builtin::manage_workdir::ManageWorkdirFeature::new(client), + true, + false, + None, + ) + .unwrap(); + let wip = worker::wip::WipRuntime::from_mounts( + mounts, + format!( + "{}:{}@{TEST_WORKSPACE_ID}", + worker.runtime_id, worker.worker_id + ), + ) + .unwrap(); + assert_config_catalog(&wip, &attached).await; + config_discover(&wip, "/workspace-config/main.dcdl").await; + let main_interface = wip + .inspect(config_interface("/workspace-config/main.dcdl"), true) + .await + .unwrap() + .content + .unwrap(); + assert!( + !main_interface.contains("\"delete\""), + "required entrypoint cannot advertise delete: {main_interface}" + ); + assert!( + config_call(&wip, "/workspace-config/main.dcdl", "read", json!({})) + .await + .unwrap() + .content + .unwrap() + .contains("WorkspaceConfigSchema") + ); + config_discover(&wip, "/workspace-config").await; + config_call( + &wip, + "/workspace-config", + "create", + json!({"path":"notes/production-adapter.md","content_type":"text","content":"production source proof"}), + ) + .await + .unwrap(); + assert!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap() + .snapshot + .get(&config_source::VirtualPath::parse("notes/production-adapter.md").unwrap()) + .is_some() + ); + workspace_config::revoke_grant( + api, + &test_owner_actor(), + TEST_WORKSPACE_ID, + &attached_grant_id(api, worker), + ) + .await + .unwrap(); + assert!(backend.current().is_err()); + assert!( + config_call( + &wip, + "/workspace-config", + "create", + json!({"path":"notes/revoked-adapter.md","content":"denied"}) + ) + .await + .is_err() + ); +} +fn attached_grant_id(api: &WorkspaceApi, worker: &RuntimeWorkerRef) -> String { + api.store + .current_workspace_config_grant(TEST_WORKSPACE_ID, worker) + .unwrap() + .unwrap() + .grant_id +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_workspace_request_scope() + { + use worker_runtime::auth::{ + RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WORKSPACE_REQUEST_PERMISSION, + decode_runtime_request_source_claims, + }; + // Remote Runtime-owned HTTP adapter, including its real request signer. + let mut fixture = manual_coder_assignment_fixture().await; + let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let claims = Arc::new(Mutex::new(Vec::new())); + let capture = claims.clone(); + let app = build_router(fixture.api.clone()).layer(axum::middleware::from_fn( + move |request: Request, next: axum::middleware::Next| { + let capture = capture.clone(); + async move { + let proof = request + .headers() + .get(RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .unwrap() + .to_str() + .unwrap(); + capture + .lock() + .unwrap() + .push(decode_runtime_request_source_claims(proof).unwrap()); + next.run(request).await + } + }, + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = worker_runtime::worker_source::RuntimeOwnedWorkspaceClient::new( + TEST_WORKSPACE_ID, + format!("http://{addr}"), + &fixture.worker.runtime_id, + &fixture.worker.worker_id, + ) + .with_runtime_request_source(&identity, "server-test"); + assert_config_production_http_adapter(&fixture.api, &fixture.worker, Arc::new(client)).await; + let observed = claims.lock().unwrap(); + assert!(observed.iter().any(|claim| claim.path.ends_with("/commit"))); + assert!( + observed + .iter() + .all(|claim| claim.permission == WORKSPACE_REQUEST_PERMISSION + && claim.iss == fixture.worker.runtime_id + && claim.worker_id.as_deref() == Some(fixture.worker.worker_id.as_str()) + && claim.workspace_id == TEST_WORKSPACE_ID) + ); + drop(observed); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workspace-config"); + let wrong_scope = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + TEST_WORKSPACE_ID, + Some(&fixture.worker.worker_id), + worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION, + "GET", + &path, + &[], + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(), + 30, + ) + .unwrap(); + let request = Request::builder() + .method("GET") + .uri(&path) + .header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, wrong_scope) + .body(Body::empty()) + .unwrap(); + assert_eq!( + build_router(fixture.api.clone()) + .oneshot(request) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED, + "valid signature under a different permission is not WorkspaceRequest authority" + ); + server.abort(); + + // Actual embedded Runtime factory -> real Worker controller -> captured + // production WorkspaceClient -> signed HTTP -> production Backend middleware. + let root = tempfile::tempdir().unwrap(); + init_clean_git_workspace(root.path()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base = format!("http://{addr}"); + let mut config = test_server_config(root.path()); + config.backend_base_url = Some("server-test".into()); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let captured = Arc::new(Mutex::new(None)); + let abort = Arc::new(Mutex::new(None)); + let factory = ConfigCaptureFactory { + inner: worker_runtime::worker_backend::ProfileRuntimeWorkerFactory::new(root.path()) + .with_embedded_worker_mutation_dispatcher( + EMBEDDED_RUNTIME_ID, + Arc::new( + crate::worker_source::EmbeddedServerWorkerMutationDispatcher::new( + config.clone(), + store.clone(), + ), + ), + ) + .with_workspace_request_client( + worker_runtime::workspace_request::RuntimeWorkspaceRequestClient::new( + TEST_WORKSPACE_ID, + &base, + EMBEDDED_RUNTIME_ID, + ) + .with_runtime_request_source(&EMBEDDED_RUNTIME_REQUEST_IDENTITY, "server-test"), + ) + .with_runtime_store_dir(root.path().join("production-controller-store")), + client: captured.clone(), + controller_abort: abort.clone(), + }; + let execution = Arc::new( + worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new(factory).unwrap(), + ); + let mut api = WorkspaceApi::new_with_execution_backend(config, store, execution) + .await + .unwrap(); + configure_runtime_request_auth( + &mut api, + &EMBEDDED_RUNTIME_REQUEST_IDENTITY, + EMBEDDED_RUNTIME_ID, + ); + let server_api = api.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, build_router(server_api)) + .await + .unwrap() + }); + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_RUNTIME_ID.into(), + display_name: "Production config adapter".into(), + singleton_key: None, + profile: Some("builtin:companion".into()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); + let client = captured + .lock() + .unwrap() + .take() + .expect("production embedded factory must inject a WorkspaceClient"); + assert_config_production_http_adapter(&api, &worker, client).await; + abort.lock().unwrap().take().unwrap().abort(); + server.abort(); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 27a63c9ba..69bb563d3 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -28,7 +28,8 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 78; +const LATEST_SCHEMA_VERSION: i64 = 79; +const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -265,6 +266,11 @@ const MIGRATIONS: &[Migration] = &[ name: WORKDIR_CONNECTION_ID_MIGRATION_NAME, apply: migrate_workdir_connection_id_v77_to_v78, }, + Migration { + version: 79, + name: WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME, + apply: migrate_workspace_config_v78_to_v79, + }, ]; #[derive(Clone, Copy)] @@ -900,6 +906,9 @@ pub struct WorkdirCreateOperationRecord { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum WorkdirRegistrySource { + WorkspaceConfig { + grant_id: String, + }, Repository { runtime_id: String, repository_id: String, @@ -913,20 +922,20 @@ impl WorkdirRegistrySource { pub fn runtime_id(&self) -> Option<&str> { match self { Self::Repository { runtime_id, .. } => Some(runtime_id), - Self::ExternalGrant { .. } => None, + Self::ExternalGrant { .. } | Self::WorkspaceConfig { .. } => None, } } pub fn repository_id(&self) -> Option<&str> { match self { Self::Repository { repository_id, .. } => Some(repository_id), - Self::ExternalGrant { .. } => None, + Self::ExternalGrant { .. } | Self::WorkspaceConfig { .. } => None, } } pub fn external_grant_id(&self) -> Option<&str> { match self { - Self::Repository { .. } => None, + Self::Repository { .. } | Self::WorkspaceConfig { .. } => None, Self::ExternalGrant { grant_id } => Some(grant_id), } } @@ -1921,6 +1930,23 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result; fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result; + fn create_workspace_config_grant( + &self, + grant: &server_api::WorkspaceConfigGrantResponse, + actor: &str, + ) -> Result<()>; + fn get_workspace_config_grant( + &self, + workspace_id: &str, + grant_id: &str, + ) -> Result>; + fn current_workspace_config_grant( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result>; + fn revoke_workspace_config_grant(&self, workspace_id: &str, grant_id: &str) -> Result<()>; + fn create_external_workdir_grant( &self, grant: &ExternalWorkdirGrantRecord, @@ -8849,13 +8875,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()> { self.with_conn(|conn| { - conn.execute( + let changed = conn.execute( r#"INSERT INTO workdir_registry ( workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, creation_selector, creation_ref, creation_tree, current_selector, current_ref, current_tree, observed_at_epoch_seconds, - materialization_status, cleanliness, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) + materialization_status, cleanliness, created_at, updated_at, workspace_config_grant_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET display_name = excluded.display_name, source_kind = excluded.source_kind, @@ -8871,12 +8897,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { observed_at_epoch_seconds = excluded.observed_at_epoch_seconds, materialization_status = excluded.materialization_status, cleanliness = excluded.cleanliness, - updated_at = excluded.updated_at"#, + updated_at = excluded.updated_at, workspace_config_grant_id = excluded.workspace_config_grant_id + WHERE workdir_registry.source_kind != 'workspace_config' OR (excluded.source_kind = 'workspace_config' AND workdir_registry.workspace_config_grant_id = excluded.workspace_config_grant_id)"#, params![ record.workspace_id, record.workdir_id, record.display_name, - match &record.source { WorkdirRegistrySource::Repository { .. } => "repository", WorkdirRegistrySource::ExternalGrant { .. } => "external_grant" }, + match &record.source { WorkdirRegistrySource::Repository { .. } => "repository", WorkdirRegistrySource::ExternalGrant { .. } => "external_grant", WorkdirRegistrySource::WorkspaceConfig { .. } => "workspace_config" }, record.source.runtime_id(), record.source.repository_id(), record.source.external_grant_id(), @@ -8891,8 +8918,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.cleanliness, record.created_at, record.updated_at, + match &record.source { WorkdirRegistrySource::WorkspaceConfig { grant_id } => Some(grant_id.as_str()), _ => None }, ], )?; + if changed == 0 { return Err(Error::WorkspacePermissionDenied("Logical config source identity cannot be replaced by a Runtime observation".into())); } Ok(()) }) } @@ -8999,6 +9028,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result { + if self + .get_workdir_registry(workspace_id, workdir_id)? + .is_some_and(|record| { + matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) + }) + { + return Err(Error::WorkspacePermissionDenied( + "Logical config Workdir cleanup is unsupported; revoke its grant".into(), + )); + } self.with_conn(|conn| { let tx = rusqlite::Transaction::new_unchecked( conn, @@ -9029,6 +9068,41 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn create_workspace_config_grant( + &self, + grant: &server_api::WorkspaceConfigGrantResponse, + actor: &str, + ) -> Result<()> { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let active: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')", params![grant.workspace_id, actor], |row|row.get(0))?; + if !active { return Err(Error::WorkspacePermissionDenied("Workspace no longer accepts new grants".into())); } + let now = chrono::Utc::now().to_rfc3339(); + let access = match grant.access { server_api::WorkspaceConfigAccess::ReadOnly => "read_only", server_api::WorkspaceConfigAccess::ReadWrite => "read_write" }; + tx.execute("INSERT INTO workspace_config_grants (workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked,created_by,created_at) VALUES (?1,?2,?3,?4,?5,?6,0,?7,?8)", params![grant.workspace_id,grant.grant_id,grant.runtime_id,grant.worker_id,grant.working_directory_id,access,actor,now])?; + tx.execute("INSERT INTO workdir_registry (workspace_id,workdir_id,display_name,source_kind,workspace_config_grant_id,materialization_status,cleanliness,created_at,updated_at) VALUES (?1,?2,'Workspace configuration','workspace_config',?3,'present','clean',?4,?4)", params![grant.workspace_id,grant.working_directory_id,grant.grant_id,now])?; + tx.commit()?; + Ok(()) + }) + } + fn get_workspace_config_grant( + &self, + workspace_id: &str, + grant_id: &str, + ) -> Result> { + self.with_conn(|conn| conn.query_row("SELECT workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked FROM workspace_config_grants WHERE workspace_id=?1 AND grant_id=?2", params![workspace_id,grant_id], read_workspace_config_grant).optional().map_err(Error::from)) + } + fn current_workspace_config_grant( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result> { + self.with_conn(|conn| conn.query_row("SELECT workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked FROM workspace_config_grants WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0", params![workspace_id,worker.runtime_id,worker.worker_id], read_workspace_config_grant).optional().map_err(Error::from)) + } + fn revoke_workspace_config_grant(&self, workspace_id: &str, grant_id: &str) -> Result<()> { + self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; tx.execute("UPDATE workspace_config_grants SET revoked=1 WHERE workspace_id=?1 AND grant_id=?2", params![workspace_id,grant_id])?; tx.execute("UPDATE workdir_registry SET materialization_status='not_found', updated_at=?3 WHERE workspace_id=?1 AND workspace_config_grant_id=?2", params![workspace_id,grant_id,chrono::Utc::now().to_rfc3339()])?; tx.commit()?; Ok(()) }) + } + fn create_external_workdir_grant( &self, grant: &ExternalWorkdirGrantRecord, @@ -11809,6 +11883,7 @@ fn read_workdir_catalog_entry( } None } + WorkdirRegistrySource::WorkspaceConfig { .. } => None, WorkdirRegistrySource::ExternalGrant { .. } => { let permissions: Option = row.get(19)?; match permissions.as_deref() { @@ -11861,7 +11936,7 @@ fn read_workdir_catalog_entry( fn workdir_registry_select_sql(where_clause: &str) -> String { format!( - "SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \ + "SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, COALESCE(external_grant_id, workspace_config_grant_id) AS external_grant_id, \ creation_selector, creation_ref, creation_tree, \ current_selector, current_ref, current_tree, observed_at_epoch_seconds, \ materialization_status, cleanliness, created_at, updated_at \ @@ -11893,6 +11968,15 @@ fn read_workdir_registry_record( ) })?, }, + "workspace_config" => WorkdirRegistrySource::WorkspaceConfig { + grant_id: external_grant_id.clone().ok_or_else(|| { + rusqlite::Error::InvalidColumnType( + 6, + "workspace_config_grant_id".into(), + rusqlite::types::Type::Null, + ) + })?, + }, "external_grant" => WorkdirRegistrySource::ExternalGrant { grant_id: external_grant_id.ok_or_else(|| { rusqlite::Error::InvalidColumnType( @@ -14903,6 +14987,91 @@ fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<() Ok(()) } +fn read_workspace_config_grant( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + let access: String = row.get(5)?; + Ok(server_api::WorkspaceConfigGrantResponse { + workspace_id: row.get(0)?, + grant_id: row.get(1)?, + runtime_id: row.get(2)?, + worker_id: row.get(3)?, + working_directory_id: row.get(4)?, + access: match access.as_str() { + "read_only" => server_api::WorkspaceConfigAccess::ReadOnly, + "read_write" => server_api::WorkspaceConfigAccess::ReadWrite, + _ => { + return Err(rusqlite::Error::InvalidColumnType( + 5, + "access".into(), + rusqlite::types::Type::Text, + )); + } + }, + revoked: row.get(6)?, + }) +} + +fn migrate_workspace_config_v78_to_v79(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 78 { + return Err(Error::Store( + "Workspace config migration requires schema 78".into(), + )); + } + let foreign_keys = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF;")?; + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let dependents = { + let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='workdir_registry' AND type IN ('index','trigger') AND sql IS NOT NULL")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + rows.collect::>>()? + }; + let schema = include_str!("latest_schema.sql"); + let start = schema + .find("CREATE TABLE \"workdir_registry\"") + .ok_or_else(|| Error::Store("Missing Workdir schema".into()))?; + let end = schema[start..] + .find("CREATE TABLE external_workdir_grants") + .ok_or_else(|| Error::Store("Missing Workdir schema end".into()))? + + start; + if column_exists(&tx, "workdir_registry", "workspace_config_grant_id")? + && table_exists(&tx, "workspace_config_grants")? + { + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?; + tx.commit()?; + return Ok(()); + } + tx.execute_batch(include_str!("workspace_config_grants.sql"))?; + tx.execute_batch(&schema[start..end].replace( + "CREATE TABLE \"workdir_registry\"", + "CREATE TABLE workdir_registry_v79", + ))?; + tx.execute_batch("INSERT INTO workdir_registry_v79 (workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at) SELECT workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at FROM workdir_registry; DROP TABLE workdir_registry; ALTER TABLE workdir_registry_v79 RENAME TO workdir_registry;")?; + for sql in dependents { + tx.execute_batch(&sql)?; + } + let broken: bool = { + let mut stmt = tx.prepare("PRAGMA foreign_key_check")?; + let mut rows = stmt.query([])?; + rows.next()?.is_some() + }; + if broken { + return Err(Error::Store( + "Workspace config migration foreign-key verification failed".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?; + tx.commit()?; + Ok(()) + })(); + if foreign_keys { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + } + result +} + fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> { use rusqlite::TransactionBehavior; let current = current_schema_version(conn)?; @@ -15787,6 +15956,7 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) #[cfg(test)] mod tests { + include!("store_workspace_config_tests.rs"); use super::*; #[test] @@ -17535,6 +17705,10 @@ mod tests { version: 78, name: WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 79, + name: WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string() + }, ] ); @@ -17623,6 +17797,7 @@ mod tests { BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(), ), (78, WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string()), + (79, WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -17974,7 +18149,7 @@ mod tests { .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75, 76, 77, 78 + 73, 74, 75, 76, 77, 78, 79 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -17983,7 +18158,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 29); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 30); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); diff --git a/crates/workspace-server/src/store_workspace_config_tests.rs b/crates/workspace-server/src/store_workspace_config_tests.rs new file mode 100644 index 000000000..7f79fba5a --- /dev/null +++ b/crates/workspace-server/src/store_workspace_config_tests.rs @@ -0,0 +1,145 @@ +// Included in store::tests. Intentionally preserves the v64/v78 physical column +// order, which differs from the latest baseline and must never be copied via '*'. +fn workspace_config_v78_fixture() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(r#" + PRAGMA foreign_keys=ON; + CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL); + INSERT INTO __yoi_schema_migrations VALUES(78,'workspace schema baseline'); + CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); + INSERT INTO workspaces VALUES('ws'); + CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id)); + INSERT INTO repositories VALUES('ws','repo'); + CREATE TABLE external_workdir_grants(workspace_id TEXT,grant_id TEXT,PRIMARY KEY(workspace_id,grant_id)); + INSERT INTO external_workdir_grants VALUES('ws','external-grant'); + CREATE TABLE workdir_registry( + workspace_id TEXT NOT NULL,workdir_id TEXT NOT NULL,display_name TEXT,source_kind TEXT NOT NULL, + runtime_id TEXT,repository_id TEXT,external_grant_id TEXT,creation_selector TEXT,creation_ref TEXT, + creation_tree TEXT,current_selector TEXT,current_ref TEXT,current_tree TEXT,observed_at_epoch_seconds INTEGER, + materialization_status TEXT NOT NULL,cleanliness TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,workdir_id)); + INSERT INTO workdir_registry VALUES('ws','repo-wd','Repository','repository','runtime','repo',NULL,'develop','a','tree-a','work','b','tree-b',123,'present','clean','created','updated'); + INSERT INTO workdir_registry VALUES('ws','external-wd','External','external_grant',NULL,NULL,'external-grant',NULL,NULL,NULL,NULL,NULL,NULL,NULL,'present','unknown','created-e','updated-e'); + CREATE INDEX registry_display_test ON workdir_registry(display_name); + CREATE TABLE registry_updates(value TEXT); + CREATE TRIGGER registry_update_test AFTER UPDATE ON workdir_registry BEGIN INSERT INTO registry_updates VALUES(NEW.display_name); END; + CREATE TABLE dependent_links(workspace_id TEXT,workdir_id TEXT,connection_id TEXT, + FOREIGN KEY(workspace_id,workdir_id) REFERENCES workdir_registry(workspace_id,workdir_id)); + INSERT INTO dependent_links VALUES('ws','repo-wd','durable-connection'); + "#).unwrap(); + conn +} + +#[test] +fn workspace_config_migration_78_to_79_preserves_sources_refs_dependencies_and_constraints() { + let conn = workspace_config_v78_fixture(); + migrate_workspace_config_v78_to_v79(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 79); + assert!( + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0)) + .unwrap() + ); + let row = conn + .query_row( + &workdir_registry_select_sql("WHERE workdir_id='repo-wd'"), + [], + read_workdir_registry_record, + ) + .unwrap(); + assert_eq!(row.creation_tree.as_deref(), Some("tree-a")); + assert_eq!(row.current_tree.as_deref(), Some("tree-b")); + assert_eq!(row.current_selector.as_deref(), Some("work")); + assert_eq!(row.cleanliness, "clean"); + assert_eq!(row.created_at, "created"); + let external = conn + .query_row( + &workdir_registry_select_sql("WHERE workdir_id='external-wd'"), + [], + read_workdir_registry_record, + ) + .unwrap(); + assert!( + matches!(external.source,WorkdirRegistrySource::ExternalGrant{ref grant_id} if grant_id=="external-grant") + ); + assert_eq!( + conn.query_row("SELECT connection_id FROM dependent_links", [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "durable-connection" + ); + assert!( + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='registry_display_test')", + [], + |row| row.get::<_, bool>(0) + ) + .unwrap() + ); + conn.execute( + "UPDATE workdir_registry SET display_name='Updated' WHERE workdir_id='repo-wd'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row("SELECT value FROM registry_updates", [], |row| row + .get::<_, String>(0)) + .unwrap(), + "Updated" + ); + conn.execute("INSERT INTO workspace_config_grants VALUES('ws','config-grant','runtime','worker','config-wd','read_only',0,'owner','now')",[]).unwrap(); + conn.execute("INSERT INTO workdir_registry(workspace_id,workdir_id,source_kind,workspace_config_grant_id,materialization_status,cleanliness,created_at,updated_at) VALUES('ws','config-wd','workspace_config','config-grant','present','clean','now','now')",[]).unwrap(); + let logical = conn + .query_row( + &workdir_registry_select_sql("WHERE workdir_id='config-wd'"), + [], + read_workdir_registry_record, + ) + .unwrap(); + assert!( + matches!(logical.source,WorkdirRegistrySource::WorkspaceConfig{ref grant_id} if grant_id=="config-grant") + ); + assert!(conn.execute("INSERT INTO workspace_config_grants VALUES('ws','invalid-grant','r','w','invalid-wd','read_write_command',0,'owner','now')",[]).is_err()); + assert!(conn.execute("INSERT INTO workspace_config_grants VALUES('ws','duplicate','runtime','worker','other-wd','read_write',0,'owner','now')",[]).is_err()); + assert!(conn.execute("INSERT INTO workdir_registry(workspace_id,workdir_id,source_kind,workspace_config_grant_id,runtime_id,materialization_status,cleanliness,created_at,updated_at) VALUES('ws','invalid-wd','workspace_config','config-grant','os-runtime','present','clean','now','now')",[]).is_err()); + let mut check = conn.prepare("PRAGMA foreign_key_check").unwrap(); + assert!(check.query([]).unwrap().next().unwrap().is_none()); + assert!( + migrate_workspace_config_v78_to_v79(&conn).is_err(), + "wrong-version rerun must not silently mutate data" + ); +} + +#[test] +fn workspace_config_migration_rolls_back_and_restores_foreign_keys_on_invalid_legacy_data() { + let conn = workspace_config_v78_fixture(); + conn.execute( + "UPDATE workdir_registry SET repository_id='missing' WHERE workdir_id='repo-wd'", + [], + ) + .unwrap(); + assert!(migrate_workspace_config_v78_to_v79(&conn).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 78); + assert!(!column_exists(&conn, "workdir_registry", "workspace_config_grant_id").unwrap()); + assert!(!table_exists(&conn, "workspace_config_grants").unwrap()); + assert!( + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0)) + .unwrap() + ); + assert_eq!( + conn.query_row( + "SELECT repository_id FROM workdir_registry WHERE workdir_id='repo-wd'", + [], + |row| row.get::<_, String>(0) + ) + .unwrap(), + "missing" + ); + assert_eq!( + conn.query_row("SELECT connection_id FROM dependent_links", [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "durable-connection" + ); +} diff --git a/crates/workspace-server/src/workspace_config_grants.sql b/crates/workspace-server/src/workspace_config_grants.sql new file mode 100644 index 000000000..dfdb104dc --- /dev/null +++ b/crates/workspace-server/src/workspace_config_grants.sql @@ -0,0 +1,16 @@ +CREATE TABLE workspace_config_grants ( + workspace_id TEXT NOT NULL, + grant_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, grant_id), + UNIQUE(workspace_id, workdir_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX workspace_config_grants_active_worker + ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0; diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index a19f095a4..484a7f048 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -84,6 +84,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "worker_workdir_attachment_reservations", "worker_workdir_links", "workspace_config_entries", + "workspace_config_grants", "workspace_config_tree_revisions", "workspace_config_trees", "workspace_create_operations", @@ -936,6 +937,17 @@ mod tests { ) VALUES (?1, 'runtime-a', 'jti-a', 1, '1')", params![workspace_id], )?; + for (workspace, grant, workdir, revoked) in [ + (workspace_id.as_str(), "config-active", "config-active-wd", 0), + (workspace_id.as_str(), "config-revoked", "config-revoked-wd", 1), + ("workspace-b", "other-config", "other-config-wd", 0), + ] { + conn.execute( + "INSERT INTO workspace_config_grants (workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked,created_by,created_at) + VALUES (?1,?2,'runtime-a','worker-a',?3,'read_only',?4,'owner','1')", + params![workspace, grant, workdir, revoked], + )?; + } Ok(()) }) .expect("non-FK scoped audit fixture"); @@ -1038,6 +1050,23 @@ mod tests { }) .expect("scoped audit read"); assert_eq!(proof_count, 0); + let grants = store + .with_conn(|conn| { + let mut statement = conn.prepare( + "SELECT workspace_id,grant_id FROM workspace_config_grants ORDER BY grant_id", + )?; + statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>() + .map_err(Error::from) + }) + .expect("config grants purge is Workspace-scoped, including revoked history"); + assert_eq!( + grants, + vec![("workspace-b".to_string(), "other-config".to_string())] + ); } #[test] diff --git a/docs/README.md b/docs/README.md index 29bd3ec29..7a836749d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 30. [`design/ticket-wip-projection.md`](design/ticket-wip-projection.md) — Host-assigned Ticket collection/item routes, role-specific operations, route identity, and typed Backend preservation. 31. [`design/merge-request-wip-projection.md`](design/merge-request-wip-projection.md) — Host-assigned Merge Request routes and preserved review/integration authority gates. 32. [`design/feature-chat-invocations.md`](design/feature-chat-invocations.md) — declarative `/` invocation identity, grammar, completion, typed draft, attachment staging, execution, and authority boundaries. +33. [`design/workspace-config-wip-projection.md`](design/workspace-config-wip-projection.md) — Backend-authored configuration as a logical WIP attachment, explicit grants, shared UI conflict domain, and activation boundaries. ## What belongs here diff --git a/docs/design/workspace-config-wip-projection.md b/docs/design/workspace-config-wip-projection.md new file mode 100644 index 000000000..768655b3a --- /dev/null +++ b/docs/design/workspace-config-wip-projection.md @@ -0,0 +1,230 @@ +# Workspace configuration as a WIP logical filesystem + +## Authority and source mapping + +Workspace configuration is not the Backend host's configuration directory. The +canonical authored source is already a SQLite-backed, revisioned virtual tree: +`workspace_config_trees`, `workspace_config_entries`, and append-only +`workspace_config_tree_revisions`. `WorkspaceConfigState` combines its +`ConfigTreeSnapshot`, toolchain/schema contract and projection digest. The +`main.dcdl` entrypoint and its imports are authored files; evaluated profiles, +prompts and other projections are not replacement source files. + +The builtin Workspace schema composes profile, prompts, runtime, +repository-access and Skills settings. Profile sources include model settings. +Do not assume that all imported Profile sources live under `profiles/`: +`VirtualPath` identifies authored entries, including shared imports. Skill +Markdown and text resources are entries in the same virtual tree. + +The WIP provider must resolve this authority at request time, not copy it to an +OS directory, maintain a synchronized config cache/store, or register every +entry as a permanent Object. A logical attachment selects a Workspace-bound +configuration subject; it is not a Bash cwd or a host filesystem grant. + +## Existing save and activation contract + +The browser source-tree editor uses `ConfigCommitRequest`: a base revision and +tree digest, changes with expected entry digests, and the `main.dcdl` +entrypoint. Candidate evaluation applies changes, formats/normalizes Decodal, +and evaluates the composed schema. Commit rechecks revision **and** digest in +an SQLite Immediate transaction before replacing active entries and appending +the revision manifest. Worker operations must share that conflict domain with +UI and other Worker edits; a per-Worker mutex alone would not prevent lost +updates. + +The Backend commit orchestration validates the prompt projection and +repository-access references, then commits and best-effort publishes the prompt +projection to the embedded Runtime. Saving establishes new authoritative +Workspace configuration, **not universal live application to existing Workers**. +New Worker configuration bundles are revision-bound. In-flight consumers can +retain immutable projections. Skill availability and activation are separate +contracts. A transport failure after dispatch is not proof of rollback and is +not authorization to retry a mutation automatically. + +## Separate authorities and excluded objects + +The following are not files of the authored Workspace source tree: + +- Backend host `server.toml`, OS configuration paths and materializer state. +- Workspace identity, signing material, runtime trust/registration, + credential stores, authentication/session/passkey/token data and private keys. +- Repository registrations and Flow records, which use separate typed APIs. +- Workspace display name and Memory language, which have independent CAS + domains; they must not be presented as one atomic source-tree transaction. +- Worker retention/lifecycle policy and destructive administrative operations. + +Repository-access **references** in authored config are not credential private +material. Granting config access does not grant secret-management operations, +filesystem access, command execution, or arbitrary Workspace access. Authored +text can itself contain sensitive information; configuration access must be an +explicit operator grant, not a consequence of possessing Read/Write Tools. +Existing secret stores must never be serialized into observation, operation +results, diagnostic bodies or history through this provider. + +## Enablement and operator grants + +Choose Worker WIP mode and the Feature implementation independently of authority: + +```toml +[worker] +mode = "wip" + +[feature.workspace_config] +enabled = true +``` + +The Feature defaults disabled; enabling it does not add a settings grant. An +operator authenticated as the Workspace owner grants access to a specific +registered Runtime/Worker through `workspace_config_grant_create`: + +```text +POST /api/w//workspace-config-grants +{"runtime_id":"","worker_id":"","access":"read_write"} +``` + +Use `read_only` for observation/read-only access. Grant creation is a separate +owner administration action, not an approval prompt for each edit. To revoke, +use the owner operation `workspace_config_grant_revoke` on the returned grant +ID. Worker requests carry authenticated source identity, never these IDs as a +substitute for authorization. A grant from another Workspace or another Worker +cannot be adopted by knowing a route or possessing a previous Interface. + +The existing connection ledger remains authoritative. Each granted Worker has +an independently identified logical registry target over the **same** authored +configuration tree. This preserves the ordinary Workdir exclusive-owner +invariant without making separately granted Workers maintain separate config +stores. Every attachment has an opaque connection lifetime ID; alias reuse is +not identity reuse. No Runtime filesystem session is created for this target. +Detaching ends that connection's use but deletes neither configuration nor its +registry target. Configuration is not a Workdir-cleanup or command target. + +## User invocation and Worker access + +Select `/workspace-config` using the common Feature completion menu. Complete +its declared parenthesized argument form (close `)` to use the default access), +then send the typed chip with the request, for example: + +```text +/workspace-config() このWorkspaceのモデル設定を変更して +``` + +The composer preserves the selection as a typed invocation chip. Ordinary text +that happens to contain this spelling is not a command. Feature preparation +calls Backend attach before dependent LLM input and displays the selected +logical subject and effective access. Failure/unknown preparation must not be +reported as a successful attachment. Optional declarative `access` values are +`effective` (default), `read_only`, and `read_write`. They restrict existing +rights; explicit `read_write` fails rather than silently downgrading. A Worker +can invoke the native attach operation at `/workspace-config` through the same +Feature/Backend handler. Neither path creates a grant or a second ledger. + +The logical content root is `/workspace-config`, not `/checkouts` or a host +path. Observe exposes requested structure and available Interfaces; contents +are retrieved through Operations. Implicit directories reflect current +`VirtualPath` entries and are not independently stored directories. + +## Backend transport and consistency + +Worker operations use identity-bound routes under +`/api/w//workers/self/workspace-config`: + +| Interaction | Route | Contract | +| --- | --- | --- | +| Resolve connection | GET root | Current grant checked even if authorized but unattached | +| Attach | POST root | Idempotent reuse of the active canonical `workspace-config` alias | +| Observe | POST `observe` | Requested paths/depth; metadata only, no source bodies | +| Read | POST `read` | Connection + path validator; canonical body/type/digest | +| Save | POST `commit` | Connection + root validator + canonical config changes/CAS | + +Observe returns root revision/digest and path validators from the same snapshot. +Opaque validators bind Workspace, Worker, grant, connection lifetime, logical +path and whole-tree revision/digest. Even replacing a file with identical bytes +in a later revision invalidates older observations. Whole-tree CAS is +intentionally conservative: an unrelated UI edit can require fresh observation. +The Client holds validators; the LLM must not manage or fabricate them. + +Authorization, active connection lookup and operation use share the per-Worker +session lock with detach/revoke. The canonical Immediate-transaction CAS remains +the final cross-Worker/UI commit boundary. Failed validation, stale state and +permission rejection do not overwrite configuration. Potential auxiliary +repository-secret effects and uncertain persistence are not described as a +full rollback. Sanitized failures distinguish `not_committed` and `unknown`; +a timeout/disconnect after dispatch never causes automatic mutation retry. + +Structural observation is bounded to depth 0–8 and 256 returned nodes, paths to +512 bytes and each text entry to 256 KiB. Oversized observations fail rather +than presenting a truncated result as complete. Source-tree creation/deletion +is expressed through canonical changes, including atomic multi-file changes +needed to update a source together with its import/registry references. The +`main.dcdl` entrypoint cannot be deleted/renamed. These are configuration +operations, not a generic POSIX filesystem or an OS mount. + +## Native operation mapping + +| WIP target | Operations and model arguments | Canonical action | +| --- | --- | --- | +| Root | `attach(access?)` | Same Backend connection operation as the selected slash invocation | +| Root | `apply_changes(changes)` | One atomic create/update/delete/rename batch; source and import/registry edits can be combined | +| Directory | `create(path, content, content_type?)` | Create an entry below this directory; ancestor directories are implicit | +| File | `read()` | Return canonical text and content type, not a byte-stream or OS descriptor | +| File | `write(content)` | Update this exact entry, preserving its content type | +| File | `edit(old_string, new_string, replace_all?)` | Unique string replacement unless `replace_all` is selected | +| File | `delete()` | Canonical deletion subject to entrypoint/import validation | +| Explicit missing path | `create(content, content_type?)` | Create that exact absence-observed entry, not a substituted path | + +Content type is `decodal` (default for creation) or `text`. File operation +targets come from the resolved WIP route. The root change batch uses canonical +relative source paths; it does **not** accept `expected_digest`, revision, +validator, Workspace, grant or connection overrides as model arguments. The +adapter captures metadata and fills those preconditions automatically, requiring +all paths in a batch to match one observed tree state. Unsupported/invalid +configuration mutations fail instead of performing a partial multi-file save. + +Example `apply_changes` argument: + +```json +{"changes":[{"kind":"create","path":"notes/readme.md","content_type":"text","content":"Configuration notes"}]} +``` + +These are native Interfaces only. There is no configuration-specific +compatibility Tool or fallback under `/tools`, no Bash location, and no +`wip-fs-server`/Linux real-filesystem provider substituted for these operations. + +## Validation scope + +Start with the changed contracts: + +```sh +cargo test -p manifest --lib workspace_config +cargo test -p worker --lib workspace_config +cargo test -p worker --lib manage_workdir::wip +cargo test -p yoi-workspace-server --lib workspace_config_integration +cargo test -p yoi-workspace-server --lib config_source::tests +cargo test -p server-api --lib workspace_config +cargo test -p tui --lib backend_worker_picker +``` + +The Backend integration tests use production Feature completion selection, +structured `Worker::run` input, request preparation and the real Backend router +and SQLite configuration store. At the recording LLM-client boundary they +assert that attach and its durable context happened first, then use the actual +WIP Client/Host to read/edit/write/create/delete and atomically change a source +with its import. The existing Prompt reader confirms the saved projection. +Signed-route, owner-grant, readonly/revoke, detach/lifetime, multi-Worker/UI CAS, +same-content replacement, invalid configuration, storage-failure and dropped +post-save-response cases complement that sequence. + +Web tests exercise the production generic composer component's declaration +completion, selection and typed submission, plus removal/literal-text behavior. +Logical-source parsers and launch/sidebar/TUI tests ensure that a settings +attachment is not treated as a repository revision or process cwd. The two +layers are complementary; the Rust fixture uses a recording LLM client and +in-process transport, not a live browser-to-provider deployment. No live +Backend/Runtime/LLM browser or physical power-loss result is claimed. Bounds +are finite input/tree and lock/request deadlines, not a promise that synchronous +configuration evaluation is forcibly preemptible at a hard wall-clock limit. + +Completion validation also runs the current-source root `cargo check`, complete +changed-crate suites, the server-api TypeScript configuration and generated +contract checks, formatting and diff checks. Updating this code does not update +or restart the running dogfooding environment. diff --git a/docs/report/T-695-subworker-path-coordinates.md b/docs/report/T-695-subworker-path-coordinates.md new file mode 100644 index 000000000..1562f06a3 --- /dev/null +++ b/docs/report/T-695-subworker-path-coordinates.md @@ -0,0 +1,30 @@ +# SubWorker cwd and logical paths during T-695 + +Two writable child sessions initially reported missing Workdir/Ticket access. +The concrete observations did not justify that conclusion: + +- A default-profile child had no `ShowTicket` tool, while the `inherit` child + could read the Ticket. Product requirements therefore had to be forwarded or + the effective child profile chosen explicitly. +- A child spawned with `cwd: crates/workspace-server` received + `file not found: crates/workspace-server/crates/workspace-server/src/config_source.rs` + for a root-relative `Read` path. `Read("src/config_source.rs")` succeeded. +- `Read("../server-api/src/lib.rs")` was rejected as an invalid logical path, + despite both crate directories being in the declared child scope. +- The read-only investigation child successfully inspected repository sources; + successful spawning was not evidence that every child had the expected tools, + but one invalid absolute output-directory lookup was not evidence of an + inaccessible repository either. + +The parent inspected committed child sessions and preserved their changes. +The server child was released at Idle, then continued under an explicitly +`inherit`, root-cwd child with the same per-crate write grants and root read +scope. No host-path or parent-traversal workaround was used to bypass the +rejected path. The original prerequisite diff and test evidence were retained. + +Improvement: tool descriptions and generated child startup context should agree +on whether relative file paths are attachment-root-relative or cwd-relative. +Expose effective attachment aliases, root/cwd coordinates and granted tool +surface directly to the child, so a profile/tool mismatch is not misdiagnosed +as a repository authority failure. Scope validation should distinguish invalid +path syntax, missing file, missing attachment and actual permission denial. diff --git a/openapi/server-api.json b/openapi/server-api.json index 63ffb52df..ed025eea0 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -13404,6 +13404,7 @@ "WorkingDirectoryMaterializerKind": { "description": "Public Workdir materializer classification.\n\nThe value identifies stable materialization provenance without exposing a\nprovider path, Runtime handle, or session identity.", "enum": [ + "logical_workspace_config", "runtime_git_clone", "client_hosted_external" ], @@ -13501,6 +13502,31 @@ "WorkingDirectorySource": { "description": "Public Workdir source identity. Provider routing details and host paths are intentionally absent.", "oneOf": [ + { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/WorkspaceConfigAccess" + }, + "content_path": { + "type": "string" + }, + "kind": { + "const": "workspace_config", + "type": "string" + }, + "purpose": { + "type": "string" + } + }, + "required": [ + "kind", + "access", + "content_path", + "purpose" + ], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -13699,6 +13725,103 @@ }, "type": "array" }, + "WorkspaceConfigAccess": { + "enum": [ + "read_only", + "read_write" + ], + "type": "string" + }, + "WorkspaceConfigApiError": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/components/schemas/WorkspaceConfigFailureClassification" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "code", + "message", + "classification" + ], + "type": "object" + }, + "WorkspaceConfigFailureClassification": { + "enum": [ + "not_committed", + "unknown" + ], + "type": "string" + }, + "WorkspaceConfigGrantCreateRequest": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/WorkspaceConfigAccess" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "worker_id", + "access" + ], + "type": "object" + }, + "WorkspaceConfigGrantResponse": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/WorkspaceConfigAccess" + }, + "grant_id": { + "type": "string" + }, + "revoked": { + "type": "boolean" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "working_directory_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "grant_id", + "workspace_id", + "runtime_id", + "worker_id", + "working_directory_id", + "access", + "revoked" + ], + "type": "object" + }, "WorkspaceConfigSchemaBundle": { "additionalProperties": false, "properties": { @@ -15320,7 +15443,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081" + "x-yoi-source-digest": "sha256:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779" }, "openapi": "3.1.0", "paths": { @@ -35161,6 +35284,220 @@ ] } }, + "/api/w/{workspace_id}/workspace-config-grants": { + "post": { + "operationId": "workspace_config_grant_create", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigGrantCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigGrantResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/workspace-config-grants/{grant_id}": { + "delete": { + "operationId": "workspace_config_grant_revoke", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "grant_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigGrantResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceConfigApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/workers": { "get": { "operationId": "workspace_worker_list_alias", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index e8d1f0a6a..9b9093be6 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) +// Input contract: 0.1.0 (sha256:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index 2dd94fea6..7a34b2e60 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -269,6 +269,107 @@ export type ConfigCommitRequest = { entrypoints: Array; }; +export type WorkspaceConfigAccess = "read_only" | "read_write"; + +export type WorkspaceConfigNodeKind = "directory" | "file" | "missing"; + +export type WorkspaceConfigFailureClassification = "not_committed" | "unknown"; + +export type WorkspaceConfigAttachRequest = { + alias: string | null; + access: WorkspaceConfigAccess | null; +}; + +export type WorkspaceConfigAttachment = { + workspace_id: string; + connection_id: string; + alias: string; + working_directory_id: string; + access: WorkspaceConfigAccess; + name: string; + purpose: string; + content_path: string; + already_attached: boolean; +}; + +export type WorkspaceConfigCurrentResponse = WorkspaceConfigAttachment | null; + +export type WorkspaceConfigObserveRequest = { + connection_id: string; + paths: Array; + depth: number; +}; + +export type WorkspaceConfigNode = { + path: string; + kind: WorkspaceConfigNodeKind; + validator: string; + digest: string | null; + content_type: ConfigContentType | null; + operations: Array; +}; + +export type WorkspaceConfigObserveResponse = { + connection_id: string; + validator: string; + revision: number; + digest: string; + /** + * Canonical commit entrypoints from this exact metadata snapshot. + */ + entrypoints: Array; + nodes: Array; +}; + +export type WorkspaceConfigReadRequest = { + connection_id: string; + path: string; + validator: string; +}; + +export type WorkspaceConfigReadResponse = { + path: string; + content: string; + content_type: ConfigContentType; + digest: string; + validator: string; +}; + +export type WorkspaceConfigCommitRequest = { + connection_id: string; + validator: string; + request: ConfigCommitRequest; +}; + +export type WorkspaceConfigCommitResponse = { + validator: string; + revision: number; + digest: string; +}; + +export type WorkspaceConfigApiError = { + status: number; + code: string; + message: string; + classification: WorkspaceConfigFailureClassification; +}; + +export type WorkspaceConfigGrantCreateRequest = { + runtime_id: string; + worker_id: string; + access: WorkspaceConfigAccess; +}; + +export type WorkspaceConfigGrantResponse = { + grant_id: string; + workspace_id: string; + runtime_id: string; + worker_id: string; + working_directory_id: string; + access: WorkspaceConfigAccess; + revoked: boolean; +}; + export type ProfileSettingsResponse = { workspace_id: string; registry_revision: string; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 829ce3ecc..a259cd8ae 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) +// Input contract: 0.1.0 (sha256:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check @@ -79,12 +79,14 @@ export type WorkerWorkspaceSummary = { identity: string; visibility: string; wor export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; }; -export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; +export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external"; export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; -export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; +export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; + +export type WorkspaceConfigAccess = "read_only" | "read_write"; diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index f887f8350..901b4716e 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) +// Input contract: 0.1.0 (sha256:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check @@ -203,16 +203,18 @@ export type WorkerWorkspaceSummary = { identity: string; visibility: string; wor export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; }; -export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; +export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external"; export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; -export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; +export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; +export type WorkspaceConfigAccess = "read_only" | "read_write"; + export type WorkspaceRuntimeBindingState = "configured" | "verified" | "revoked"; export type WorkspaceRuntimeBindingSummary = { connection_state: RuntimeConnectionDisplayState; revision: number; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_generation?: number | null; workspace_key_id?: string | null; }; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index ac5aec6bb..e6fb0a177 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) +// Input contract: 0.1.0 (sha256:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index f06b36ce5..b4a8b9d21 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) +// Input contract: 0.1.0 (sha256:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check @@ -27,7 +27,7 @@ export type WorkingDirectoryDetailResponse = { diagnostics: Array; i export type WorkingDirectoryListResponse = { diagnostics: Array; items: Array; workspace_id: string; }; -export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; +export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external"; export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; @@ -37,8 +37,10 @@ export type WorkingDirectoryRemovalRequest = { reason: string; }; export type WorkingDirectoryRemovalResponse = { disposition: WorkingDirectoryRemovalDisposition; failure_category?: string | null; retryable: boolean; working_directory_id: string; }; -export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; +export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; + +export type WorkspaceConfigAccess = "read_only" | "read_write"; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index dc4241a3e..f3519c205 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081) +// Input contract: 0.1.0 (sha256:6001e9f7273288195fb5a9859e4a5932a521d2f2e7c3a9ecd8ec660fd6a37779) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check @@ -85,16 +85,18 @@ export type WorkerWorkspaceSummary = { identity: string; visibility: string; wor export type WorkingDirectoryCleanupTarget = { kind: string; repository_key: string; working_directory_id: string; }; -export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; +export type WorkingDirectoryMaterializerKind = "logical_workspace_config" | "runtime_git_clone" | "client_hosted_external"; export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; }; export type WorkingDirectoryRepositoryOption = { default_selector?: string | null; repository_key: string; }; -export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; +export type WorkingDirectorySource = { access: WorkspaceConfigAccess; content_path: string; kind: "workspace_config"; purpose: string; } | { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; }; export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; }; +export type WorkspaceConfigAccess = "read_only" | "read_write"; + export type WorkspaceWorkerFeatureConnectionsRequest = { subjektiv?: SubjektivWorkerConnectionRequest | null; }; diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index 8df77af4a..dbfedcfb7 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -131,6 +131,7 @@ export function parseWorkingDirectorySummary( materializer_kind: enumField(record, "materializer_kind", [ "runtime_git_clone", "client_hosted_external", + "logical_workspace_config", ]), status: enumField(record, "status", [ "active", @@ -177,10 +178,27 @@ export function parseWorkingDirectorySummary( function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { const source = exactRecord( value, - new Set(["kind", "repository_key", "grant_id", "grant_permissions"]), + new Set(["kind", "repository_key", "grant_id", "grant_permissions", "access", "content_path", "purpose"]), "Workdir source", ); const kind = stringField(source, "kind"); + if (kind === "workspace_config") { + if (source.repository_key !== undefined || source.grant_id !== undefined || source.grant_permissions !== undefined) { + throw new Error("Logical config source must not contain repository or External authority"); + } + const access = stringField(source, "access"); + if (access !== "read_only" && access !== "read_write") { + throw new Error("Logical config access has an unsupported value"); + } + const content_path = stringField(source, "content_path"); + if (content_path !== "/workspace-config") { + throw new Error("Logical config must reference its WIP content entrance"); + } + return { kind, access, content_path, purpose: stringField(source, "purpose") }; + } + if (source.access !== undefined || source.content_path !== undefined || source.purpose !== undefined) { + throw new Error("Filesystem Workdir source must not contain logical config metadata"); + } if (kind === "repository") { if ( source.grant_id !== undefined || source.grant_permissions !== undefined diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts index 355b0da6b..ad21c7c6a 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts @@ -363,6 +363,72 @@ test("restored invocation chips fetch declarative descriptors and stay editable expect(snapshot.segments[0].kind).toBe("feature_invoke"); }); +// Fixture of the Workspace config Feature declaration, consumed by the same +// generic composer as other Features. Backend/WIP acceptance is tested in Rust; +// this test proves the real input component's selection and typed-submit edge. +const workspaceConfigDescriptor: FeatureInvocationDescriptor = { + identity: "builtin:workspace-config/attach", name: "workspace-config", aliases: [], + display_name: "Workspace config", description: "Select the logical Workspace config attachment", + syntax: "parenthesized", arguments: [{ name: "access", position: 0, required: false, + value_type: { kind: "enum", values: ["effective", "read_only", "read_write"] }, + completion: { kind: "none" } }], +}; + +test("workspace-config completion prepares a typed invocation without consuming the request prose", async () => { + const onsubmit = vi.fn(); + const resolveFeatureCompletions = vi.fn(async () => [ + { value: workspaceConfigDescriptor.name, invocation: workspaceConfigDescriptor }, + ]); + const ui = render(ComposerInput, { historyScope: "workspace-config-selected", onsubmit, + resolveFeatureCompletions }); + const cm = editor(ui.container); + type(cm, "/workspace-con"); + await ui.findByRole("option"); + await fireEvent.keyDown(cm.contentDOM, { key: "Enter" }); + expect(onsubmit).not.toHaveBeenCalled(); + const suffix = ') このWorkspaceのモデル設定を変更して'; + cm.dispatch({ changes: { from: cm.state.doc.length, insert: suffix }, + selection: { anchor: cm.state.doc.length + suffix.length } }); + await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1)); + const snapshot = ui.component.snapshot(); + expect(snapshot.segments).toEqual([ + { kind: "feature_invoke", invocation: { + invocation_id: expect.any(String), identity: workspaceConfigDescriptor.identity, + name: workspaceConfigDescriptor.name, arguments: [], + } }, + { kind: "text", content: " このWorkspaceのモデル設定を変更して" }, + ]); + await fireEvent.keyDown(cm.contentDOM, { key: "Enter", ctrlKey: true }); + expect(onsubmit).toHaveBeenCalledOnce(); + // Sending is not string scanning: identity and arguments survive intact. + expect(ui.component.snapshot().segments).toEqual(snapshot.segments); +}); + +test("workspace-config text alone is not invoked and a selected intent is removable", async () => { + const ui = render(ComposerInput, { historyScope: "workspace-config-literal", + resolveFeatureCompletions: async () => [{ value: workspaceConfigDescriptor.name, invocation: workspaceConfigDescriptor }] }); + const cm = editor(ui.container); + const prose = "/workspace-config このWorkspaceのモデル設定を変更して"; + ui.component.restoreSegments([{ kind: "text", content: prose }]); + expect(ui.component.snapshot().segments).toEqual([{ kind: "text", content: prose }]); + ui.component.clear(); + type(cm, "/workspace-con"); + await ui.findByRole("option"); + await fireEvent.keyDown(cm.contentDOM, { key: "Enter" }); + cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"read_only")' }, + selection: { anchor: cm.state.doc.length + '"read_only")'.length } }); + await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1)); + expect(ui.component.snapshot().segments[0]).toMatchObject({ kind: "feature_invoke", invocation: { + arguments: [{ name: "access", value: { kind: "string", value: "read_only" } }], + } }); + // happy-dom's focused DOM selection can reenter CodeMirror during range + // replacement. Use the existing component suite's unfocused range fixture. + cm.contentDOM.blur(); + cm.dispatch({ selection: { anchor: 0, head: cm.state.doc.length } }); + await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" }); + expect(ui.component.snapshot().segments).toEqual([]); +}); + test("attachment adapter uses descriptor capability rather than a hardcoded slash name", async () => { const onclientadapter = vi.fn(); const adapter = { ...invocationDescriptor, name: "send-file", arguments: [], client_adapter: "attachment" as const }; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index b4747f7c7..501d66155 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -9,6 +9,18 @@ declare const Deno: { test(name: string, fn: () => Promise | void): void; }; +Deno.test("logical config attachments are never offered as process working directories", () => { + const config = { working_directory_id: "config-1", display_name: "Workspace config", + source: { kind: "workspace_config" as const, access: "read_write" as const, content_path: "/workspace-config", purpose: "Configuration" }, + materializer_kind: "logical_workspace_config" as const, status: "active" as const, cleanliness: "clean" }; + const form = defaultWorkerLaunchForm({ ...options, working_directories: [config] }, emptyForm({ + workdir_attachments: [{ alias: "config", working_directory_id: "config-1", relative_cwd: "" }], + })); + if (form.workdir_attachments.some((attachment) => attachment.working_directory_id === "config-1")) { + throw new Error("logical config was converted to a process cwd attachment"); + } +}); + Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initial Workdir", () => { const form = defaultWorkerLaunchForm( options, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 66db63635..983321b57 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -41,6 +41,7 @@ export function defaultWorkerLaunchForm( const availableWorkingDirectories = options?.working_directories.filter((directory) => directory.status === "active" && + directory.source.kind !== "workspace_config" && (directory.source.kind === "external_grant" || directory.cleanliness === "clean") && directory.occupied_by == null diff --git a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts index 03b237319..d9cea3469 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts @@ -47,6 +47,18 @@ function attachment( }; } +Deno.test("logical config sidebar label is not fabricated as a repository revision", () => { + const meta = sidebarWorkdirMeta([attachment("workspace-config", { + display_name: "Workspace configuration", + source: { kind: "workspace_config", access: "read_write", content_path: "/workspace-config", purpose: "Configuration" }, + materializer_kind: "logical_workspace_config", + })]); + assertEquals(meta.text, "Workspace configuration"); + assert(!meta.text.includes("unknown-repo"), "config must not be labeled as a repository"); + assert(!meta.text.includes("editable"), "catalog grant ceiling must not claim effective connection editability"); + assert(meta.details.includes("workspace-config — Workspace configuration"), "logical alias remains visible"); +}); + Deno.test("Worker sidebar Workdir label prefers the current branch selector", () => { const meta = sidebarWorkdirMeta([ attachment("checkout", { diff --git a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts index 79f497e87..2743dd851 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts @@ -27,6 +27,11 @@ export function sidebarWorkdirMeta( function attachmentLabel(attachment: SidebarWorkdirAttachment): string { const workdir = attachment.working_directory; + if (workdir?.source.kind === "workspace_config") { + // Catalog access is the grant ceiling, not this connection's effective + // restriction. The invocation result reports effective attachment access. + return clean(workdir.display_name) ?? "Workspace config"; + } if (workdir?.source.kind === "external_grant") { return clean(workdir.display_name) ?? "external"; } diff --git a/web/workspace/tests/workdir-api.test.ts b/web/workspace/tests/workdir-api.test.ts index c1d941a44..dadfd0e47 100644 --- a/web/workspace/tests/workdir-api.test.ts +++ b/web/workspace/tests/workdir-api.test.ts @@ -23,6 +23,26 @@ const summary = { }, }; +Deno.test("logical config Workdir source preserves WIP metadata without filesystem authority", () => { + const source = { kind: "workspace_config", access: "read_only", content_path: "/workspace-config", purpose: "Workspace configuration" }; + const response = (source: unknown) => ({ workspace_id: "workspace-a", item: { + working_directory_id: "config-1", source, materializer_kind: "logical_workspace_config", status: "active", + }, diagnostics: [] }); + const detail = parseWorkingDirectoryDetailResponse(response(source)); + if (JSON.stringify(detail.item.source) !== JSON.stringify(source) || detail.runtime_id !== undefined) { + throw new Error("logical config identity was lost or acquired a fake Runtime"); + } + for (const invalid of [ + { ...source, access: "command" }, { ...source, content_path: "/home/config" }, + { ...source, grant_id: "external-grant" }, { ...source, repository_key: "main" }, + { ...source, token: "secret" }, { kind: "repository", repository_key: "main", access: "read_write" }, + ]) { + let rejected = false; + try { parseWorkingDirectoryDetailResponse(response(invalid)); } catch { rejected = true; } + if (!rejected) throw new Error("mixed or unsafe source metadata was accepted"); + } +}); + Deno.test("Workdir REST validation accepts the generated list and create contracts", () => { const list = parseWorkingDirectoryListResponse({ workspace_id: "workspace-a",