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
@@ -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]