feat(workspace-config): project grant-bound logical settings through WIP

This commit is contained in:
2026-10-06 06:56:51 +09:00
parent 7a96c42130
commit 0a471242cb
44 changed files with 8159 additions and 72 deletions
+151
View File
@@ -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);
+12
View File
@@ -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(),
+94
View File
@@ -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",
+284
View File
@@ -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);
}
}
}
+43
View File
@@ -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);
+74
View File
@@ -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)
+21
View File
@@ -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,
+1
View File
@@ -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
);
}
}
}
}
}
+57 -9
View File
@@ -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(&current, 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;
+25 -4
View File
@@ -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;
+342 -30
View File
@@ -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,
&current_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();
}
+187 -12
View File
@@ -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]
+1
View File
@@ -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
View File
@@ -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";
}
+20
View File
@@ -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",