feat(workspace-config): project grant-bound logical settings through WIP
This commit is contained in:
@@ -98,6 +98,8 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workspace_config: Option<WorkspaceConfigFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||
@@ -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<bool>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceConfigFeatureConfigPartial> for FeatureFlagConfig {
|
||||
fn from(value: WorkspaceConfigFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FeatureFlagConfig> 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<FeatureConfigPartial> 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<FeatureConfig> 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::<FeatureConfig>(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::<FeatureConfig>(&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);
|
||||
|
||||
@@ -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<FeatureFlagConfig, D::Error>
|
||||
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(),
|
||||
|
||||
@@ -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<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
||||
#[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<WorkspaceConfigCurrentResponse, WorkspaceConfigApiError>;
|
||||
#[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<WorkspaceConfigAttachment, WorkspaceConfigApiError>;
|
||||
#[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<WorkspaceConfigObserveResponse, WorkspaceConfigApiError>;
|
||||
#[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<WorkspaceConfigReadResponse, WorkspaceConfigApiError>;
|
||||
#[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<WorkspaceConfigCommitResponse, WorkspaceConfigApiError>;
|
||||
#[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<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
|
||||
#[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<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
|
||||
|
||||
#[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",
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<WorkspaceConfigAccess>,
|
||||
}
|
||||
#[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<WorkspaceConfigAttachment>);
|
||||
|
||||
#[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<String>,
|
||||
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<String>,
|
||||
pub content_type: Option<ConfigContentType>,
|
||||
pub operations: Vec<String>,
|
||||
}
|
||||
#[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<String>,
|
||||
pub nodes: Vec<WorkspaceConfigNode>,
|
||||
}
|
||||
#[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::<WorkspaceConfigObserveResponse>(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::<WorkspaceConfigObserveResponse>(with_body).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceConfigGrantCreateRequest>(
|
||||
serde_json::json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceConfigAttachRequest>(
|
||||
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::<WorkspaceConfigApiError>(
|
||||
serde_json::to_value(&error).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
error
|
||||
);
|
||||
assert_eq!(api_macros::HttpError::status_code(&error), 500);
|
||||
}
|
||||
let error =
|
||||
<WorkspaceConfigApiError as api_macros::HttpRequestError>::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -77,6 +77,7 @@ struct Provider {
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
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<Json>,
|
||||
pub(crate) requests: Vec<WorkspaceRequest>,
|
||||
pub(crate) denied: bool,
|
||||
config_current: Option<Json>,
|
||||
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<CatalogClient>,
|
||||
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 {
|
||||
|
||||
@@ -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<Option<ConfigAccess>, 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<ConfigAccess>) -> Result<(), ConfigAttachError>;
|
||||
async fn attach(
|
||||
&self,
|
||||
idempotency_key: &str,
|
||||
access: Option<ConfigAccess>,
|
||||
) -> Result<ConfigAttachment, ConfigAttachError>;
|
||||
}
|
||||
|
||||
/// Shared slash/self-attach handler. Possession of Workdir tools is not a grant.
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceConfigFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
backend: Arc<dyn WorkspaceConfigAttachmentBackend>,
|
||||
content_backend: Option<Arc<backend::WorkspaceConfigBackend>>,
|
||||
wip_mode: bool,
|
||||
permits: Arc<tokio::sync::Semaphore>,
|
||||
}
|
||||
|
||||
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<dyn WorkspaceClient>,
|
||||
backend: Arc<dyn WorkspaceConfigAttachmentBackend>,
|
||||
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<dyn WorkspaceClient>,
|
||||
enabled: bool,
|
||||
wip_mode: bool,
|
||||
) -> Option<Self> {
|
||||
(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<dyn WorkspaceClient>, 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<ConfigAccess>) -> 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<ConfigAccess>,
|
||||
) -> Result<ConfigAttachment, ConfigAttachError> {
|
||||
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<Option<ConfigAccess>, 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<FeatureInvocationResult, FeatureInvocationHandlerError> {
|
||||
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;
|
||||
@@ -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<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigBackend {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
fn base(&self) -> Result<String, WipOperationError> {
|
||||
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<T: DeserializeOwned>(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
mutation: bool,
|
||||
) -> Result<T, WipOperationError> {
|
||||
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::<WorkspaceConfigApiError>(&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<Option<WorkspaceConfigAttachment>, WipOperationError> {
|
||||
let result: Option<WorkspaceConfigAttachment> =
|
||||
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<T: DeserializeOwned + Send + 'static, R: Serialize>(
|
||||
&self,
|
||||
suffix: &str,
|
||||
request: &R,
|
||||
mutation: bool,
|
||||
) -> Result<T, WipOperationError> {
|
||||
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<Option<WorkspaceConfigAttachment>, 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<ConfigAccess>) -> 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<ConfigAccess>,
|
||||
) -> Result<ConfigAttachment, ConfigAttachError> {
|
||||
// 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())
|
||||
}
|
||||
@@ -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<WorkspaceConfigBackend>,
|
||||
}
|
||||
|
||||
/// Backend tree root is empty; source names are canonical relative config paths.
|
||||
fn source_path(route: &str) -> Result<String, WipOperationError> {
|
||||
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<T>(result: Result<T, WipOperationError>) -> Result<Option<T>, 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<String>,
|
||||
depth: u32,
|
||||
) -> Result<WorkspaceConfigObserveResponse, WipOperationError> {
|
||||
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<Option<WipProjection>, 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<Vec<String>, 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<String> {
|
||||
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<WorkspaceConfigBackend>,
|
||||
attachment: WorkspaceConfigAttachment,
|
||||
snapshot: WorkspaceConfigObserveResponse,
|
||||
node: WorkspaceConfigNode,
|
||||
operations: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<ConfigContentType>,
|
||||
},
|
||||
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<String, Value>, key: &str) -> Result<String, WipOperationError> {
|
||||
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<ConfigContentType, WipOperationError> {
|
||||
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<WorkspaceConfigReadResponse, WipOperationError> {
|
||||
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<Change>,
|
||||
) -> Result<Vec<ConfigTreeChange>, 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<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
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<String, Value>,
|
||||
context: WipCallContext,
|
||||
dispatched: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
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::<Vec<Change>>(
|
||||
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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceConfigAttachment>,
|
||||
next_connection: u64,
|
||||
revision: u64,
|
||||
entries: BTreeMap<String, (ConfigContentType, String)>,
|
||||
commits: Vec<WorkspaceConfigCommitRequest>,
|
||||
requests: Vec<WorkspaceRequest>,
|
||||
fail_commit: bool,
|
||||
fail_current: bool,
|
||||
lose_commit_reply: bool,
|
||||
race_commit: bool,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Router {
|
||||
workspace: &'static str,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
impl Router {
|
||||
fn new() -> Arc<Self> {
|
||||
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<T: Serialize>(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<WorkspaceResponse, WorkspaceClientError> {
|
||||
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<dyn WorkspaceClient>) -> (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<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||
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::<Vec<_>>();
|
||||
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<dyn WorkspaceClient>) -> (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)
|
||||
);
|
||||
}
|
||||
@@ -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<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceResponse, WorkspaceClientError> {
|
||||
panic!("scaffolding must not invent Backend endpoints");
|
||||
}
|
||||
}
|
||||
|
||||
struct Backend {
|
||||
granted: AtomicBool,
|
||||
read_only: AtomicBool,
|
||||
unknown: AtomicBool,
|
||||
calls: AtomicUsize,
|
||||
keys: Mutex<Vec<String>>,
|
||||
active: Mutex<Option<ConfigAttachment>>,
|
||||
}
|
||||
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<ConfigAccess>) -> 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<ConfigAccess>,
|
||||
) -> Result<ConfigAttachment, ConfigAttachError> {
|
||||
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<Backend>,
|
||||
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::<Vec<_>>(),
|
||||
["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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Option<WipProjection>, ProtocolError>;
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError>;
|
||||
}
|
||||
@@ -972,7 +979,7 @@ fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec<u8> {
|
||||
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<dyn std::future::Future<Output = Result<ObjectObservation, ProtocolError>> + 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, String> {
|
||||
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<ToolOutput, ToolError> {
|
||||
pub async fn inspect(&self, interface: String, refresh: bool) -> Result<ToolOutput, ToolError> {
|
||||
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<ToolOutput, ToolError> {
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<workdir::WorkdirSessionCapabilities> {
|
||||
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<workdir::WorkdirSessionCapabilities> {
|
||||
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<server_api::WorkspaceConfigTreeResponse>)> {
|
||||
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<crate::config_source::WorkspaceConfigState> {
|
||||
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<crate::config_source::EvaluatedConfigCandidate> {
|
||||
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<crate::config_source::WorkspaceConfigState> {
|
||||
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<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -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::<Vec<_>>();
|
||||
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<server_api::CurrentWorkerWorkdirCatalogResponse> {
|
||||
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<server_api::CurrentWorkerWorkdirCatalogResponse> {
|
||||
// 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::<Vec<_>>();
|
||||
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<WorkingDirectorySummary> {
|
||||
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;
|
||||
|
||||
@@ -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<T> = std::result::Result<T, Failure>;
|
||||
|
||||
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<ApiError>) -> 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<RuntimeWorkerRef> {
|
||||
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::sync::OwnedMutexGuard<()>> {
|
||||
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<WorkspaceConfigGrantResponse> {
|
||||
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<workdir::WorkdirSessionCapabilities> {
|
||||
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<Option<WorkerWorkdirLinkRecord>> {
|
||||
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<Option<WorkspaceConfigAttachment>> {
|
||||
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<WorkspaceConfigAttachment> {
|
||||
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<crate::config_source::WorkspaceConfigState> {
|
||||
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::<String>()
|
||||
)
|
||||
}
|
||||
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<WorkspaceConfigObserveResponse> {
|
||||
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<WorkspaceConfigReadResponse> {
|
||||
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<WorkspaceConfigCommitResponse> {
|
||||
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<WorkspaceConfigGrantResponse> {
|
||||
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<WorkspaceConfigGrantResponse> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,863 @@
|
||||
#[derive(Clone)]
|
||||
struct ConfigNoopLlmClient {
|
||||
api: WorkspaceApi,
|
||||
worker: RuntimeWorkerRef,
|
||||
calls: Arc<AtomicU64>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl agen::llm_client::LlmClient for ConfigNoopLlmClient {
|
||||
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||
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<RuntimeIdentityMaterial>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
paths: Arc<Mutex<Vec<String>>>,
|
||||
drop_next_commit_response: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
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<worker::WorkspaceResponse, worker::WorkspaceClientError> {
|
||||
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::<String>();
|
||||
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<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||
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::<String>();
|
||||
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<Mutex<Option<Arc<dyn worker::WorkspaceClient>>>>,
|
||||
controller_abort: Arc<Mutex<Option<tokio::task::AbortHandle>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl worker_runtime::worker_backend::RuntimeWorkerFactory for ConfigCaptureFactory {
|
||||
async fn spawn_controller(
|
||||
&self,
|
||||
mut request: worker_runtime::execution::WorkerExecutionSpawnRequest,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
// 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<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
self.inner.restore_controller(request).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_config_production_http_adapter(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
client: Arc<dyn worker::WorkspaceClient>,
|
||||
) {
|
||||
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<Body>, 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();
|
||||
}
|
||||
@@ -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<WorkdirCatalogPage>;
|
||||
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
|
||||
|
||||
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<Option<server_api::WorkspaceConfigGrantResponse>>;
|
||||
fn current_workspace_config_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<Option<server_api::WorkspaceConfigGrantResponse>>;
|
||||
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<bool> {
|
||||
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<Option<server_api::WorkspaceConfigGrantResponse>> {
|
||||
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<Option<server_api::WorkspaceConfigGrantResponse>> {
|
||||
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<String> = 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<server_api::WorkspaceConfigGrantResponse> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
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<_>>(),
|
||||
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());
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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::<std::result::Result<Vec<_>, _>>()
|
||||
.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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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-id>/workspace-config-grants
|
||||
{"runtime_id":"<registered-runtime>","worker_id":"<registered-worker>","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/<workspace-id>/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.
|
||||
@@ -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.
|
||||
+338
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -269,6 +269,107 @@ export type ConfigCommitRequest = {
|
||||
entrypoints: Array<string>;
|
||||
};
|
||||
|
||||
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<string>;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type WorkspaceConfigNode = {
|
||||
path: string;
|
||||
kind: WorkspaceConfigNodeKind;
|
||||
validator: string;
|
||||
digest: string | null;
|
||||
content_type: ConfigContentType | null;
|
||||
operations: Array<string>;
|
||||
};
|
||||
|
||||
export type WorkspaceConfigObserveResponse = {
|
||||
connection_id: string;
|
||||
validator: string;
|
||||
revision: number;
|
||||
digest: string;
|
||||
/**
|
||||
* Canonical commit entrypoints from this exact metadata snapshot.
|
||||
*/
|
||||
entrypoints: Array<string>;
|
||||
nodes: Array<WorkspaceConfigNode>;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Diagnostic>; i
|
||||
|
||||
export type WorkingDirectoryListResponse = { diagnostics: Array<Diagnostic>; items: Array<WorkingDirectorySummary>; 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";
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -9,6 +9,18 @@ declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user