feat(workspace-config): project grant-bound logical settings through WIP
This commit is contained in:
@@ -116,6 +116,52 @@ pub struct EvaluatedConfigCandidate {
|
||||
pub evaluation: EvaluationResult,
|
||||
}
|
||||
|
||||
/// Validate feature semantics before committing a schema-valid candidate.
|
||||
///
|
||||
/// Keep this shared by the UI and logical config operations. Schema evaluation
|
||||
/// alone does not resolve Profile sources or reject blocking Skill diagnostics.
|
||||
/// This function has no publication or repository-secret side effects.
|
||||
pub(crate) fn validate_workspace_config_candidate_projections(
|
||||
workspace_id: &str,
|
||||
candidate: &EvaluatedConfigCandidate,
|
||||
) -> Result<()> {
|
||||
let state = WorkspaceConfigState {
|
||||
snapshot: candidate.snapshot.clone(),
|
||||
contract: candidate.contract.clone(),
|
||||
projection_digest: candidate.evaluation.projection_digest.clone(),
|
||||
};
|
||||
let has_provider = |id: &str| {
|
||||
state
|
||||
.contract
|
||||
.schema_bundle
|
||||
.contributions
|
||||
.iter()
|
||||
.any(|provider| provider.provider_id == id)
|
||||
};
|
||||
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||
if has_provider("builtin:profile") {
|
||||
crate::profile_settings::project_profiles_from_workspace_config(workspace_id, &state)?;
|
||||
}
|
||||
if has_provider("builtin:runtime") {
|
||||
crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?;
|
||||
}
|
||||
if has_provider("builtin:skills") {
|
||||
let catalog = crate::skills::catalog(&state)
|
||||
.map_err(|_| Error::InvalidInput("invalid Workspace Skill projection".to_string()))?;
|
||||
if catalog.entries.iter().any(|entry| {
|
||||
entry
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.severity == server_api::SkillDiagnosticSeverity::Error)
|
||||
}) {
|
||||
return Err(Error::InvalidInput(
|
||||
"Workspace Skills have blocking diagnostics".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||
pub struct ConfigCommitRequest {
|
||||
#[ts(type = "number")]
|
||||
@@ -911,6 +957,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic_candidate(
|
||||
contribution: ConfigSchemaContribution,
|
||||
source: &str,
|
||||
) -> EvaluatedConfigCandidate {
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([
|
||||
crate::prompt_settings::PromptConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
contribution,
|
||||
])
|
||||
.unwrap();
|
||||
let current = initial_state_with_schema(schema.clone()).unwrap();
|
||||
let change = update_main(¤t, source);
|
||||
evaluate_candidate(current, &[change], schema).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_semantics_reject_unknown_default_profile() {
|
||||
let candidate = semantic_candidate(
|
||||
crate::profile_settings::ProfileConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
r#"{ profile = { default_profile = "project:missing"; }; }"#,
|
||||
);
|
||||
let error =
|
||||
validate_workspace_config_candidate_projections("w-config", &candidate).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown_default_profile"));
|
||||
let valid = semantic_candidate(
|
||||
crate::profile_settings::ProfileConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
"{}",
|
||||
);
|
||||
validate_workspace_config_candidate_projections("w-config", &valid).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_semantics_reject_invalid_runtime_identifier() {
|
||||
let candidate = semantic_candidate(
|
||||
crate::runtime_settings::RuntimeConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
r#"{ runtime = { default_runtime_id = "bad\nidentifier"; }; }"#,
|
||||
);
|
||||
assert!(matches!(
|
||||
validate_workspace_config_candidate_projections("w-config", &candidate),
|
||||
Err(Error::InvalidRuntimeIdentifier { .. })
|
||||
));
|
||||
let valid = semantic_candidate(
|
||||
crate::runtime_settings::RuntimeConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
"{}",
|
||||
);
|
||||
validate_workspace_config_candidate_projections("w-config", &valid).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_semantics_reject_skill_without_canonical_source() {
|
||||
let candidate = semantic_candidate(
|
||||
crate::skills::SkillConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
r#"{
|
||||
skills = {
|
||||
debug_rust = {
|
||||
frontmatter = { name = "debug-rust"; description = "Debug Rust"; };
|
||||
content = "inline";
|
||||
};
|
||||
};
|
||||
}"#,
|
||||
);
|
||||
let error =
|
||||
validate_workspace_config_candidate_projections("w-config", &candidate).unwrap_err();
|
||||
assert!(error.to_string().contains("blocking diagnostics"));
|
||||
// Do not copy Skill source bodies or diagnostic paths into this error.
|
||||
assert!(!error.to_string().contains("inline"));
|
||||
assert!(!error.to_string().contains("SKILL.md"));
|
||||
let valid = semantic_candidate(
|
||||
crate::skills::SkillConfigSchemaProvider
|
||||
.contribution()
|
||||
.unwrap(),
|
||||
"{}",
|
||||
);
|
||||
validate_workspace_config_candidate_projections("w-config", &valid).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_registry_applies_normal_decodal_composition() {
|
||||
struct WebSchema;
|
||||
|
||||
@@ -668,7 +668,7 @@ CREATE TABLE "workdir_registry" (
|
||||
workspace_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')),
|
||||
source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant', 'workspace_config')),
|
||||
runtime_id TEXT,
|
||||
repository_id TEXT,
|
||||
external_grant_id TEXT,
|
||||
@@ -680,16 +680,20 @@ CREATE TABLE "workdir_registry" (
|
||||
updated_at TEXT NOT NULL,
|
||||
current_selector TEXT,
|
||||
current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER,
|
||||
workspace_config_grant_id TEXT,
|
||||
PRIMARY KEY (workspace_id, workdir_id),
|
||||
CHECK (
|
||||
(source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL)
|
||||
OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL)
|
||||
(source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NULL)
|
||||
OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL AND workspace_config_grant_id IS NULL)
|
||||
OR (source_kind = 'workspace_config' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NOT NULL)
|
||||
),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, repository_id)
|
||||
REFERENCES "repositories"(workspace_id, repository_id),
|
||||
FOREIGN KEY (workspace_id, external_grant_id)
|
||||
REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED
|
||||
REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED,
|
||||
FOREIGN KEY (workspace_id, workspace_config_grant_id)
|
||||
REFERENCES workspace_config_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE TABLE external_workdir_grants (
|
||||
grant_id TEXT NOT NULL,
|
||||
@@ -1605,3 +1609,20 @@ CREATE TRIGGER ticket_worker_assignments_validate_update
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace');
|
||||
END;
|
||||
|
||||
CREATE TABLE workspace_config_grants (
|
||||
workspace_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
|
||||
revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, grant_id),
|
||||
UNIQUE(workspace_id, workdir_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_config_grants_active_worker
|
||||
ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#[path = "server_workspace_config.rs"]
|
||||
mod workspace_config;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -4985,6 +4987,7 @@ impl WorkspaceApi {
|
||||
.map_err(RuntimeRegistryError::into_error)?;
|
||||
}
|
||||
}
|
||||
WorkdirRegistrySource::WorkspaceConfig { .. } => continue,
|
||||
WorkdirRegistrySource::ExternalGrant { grant_id } => {
|
||||
let grant = self
|
||||
.store
|
||||
@@ -5151,7 +5154,8 @@ impl WorkspaceApi {
|
||||
runtime_id,
|
||||
repository_id,
|
||||
} => (runtime_id, repository_id),
|
||||
WorkdirRegistrySource::ExternalGrant { .. } => {
|
||||
WorkdirRegistrySource::ExternalGrant { .. }
|
||||
| WorkdirRegistrySource::WorkspaceConfig { .. } => {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target",
|
||||
claim.alias
|
||||
@@ -5294,6 +5298,9 @@ fn workdir_source_capabilities(
|
||||
) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
match &workdir.source {
|
||||
WorkdirRegistrySource::Repository { .. } => Ok(workdir::WorkdirSessionCapabilities::ALL),
|
||||
WorkdirRegistrySource::WorkspaceConfig { grant_id } => {
|
||||
workspace_config::grant_capabilities(api, grant_id)
|
||||
}
|
||||
WorkdirRegistrySource::ExternalGrant { grant_id } => api
|
||||
.store
|
||||
.get_external_workdir_grant(&api.config.workspace_id, grant_id)?
|
||||
@@ -5399,6 +5406,14 @@ fn ticket_target_capabilities_for_workdir(
|
||||
workdir: &WorkdirRegistryRecord,
|
||||
) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
let source_capabilities = workdir_source_capabilities(api, workdir)?;
|
||||
if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &workdir.source {
|
||||
if !workspace_config::granted_to(api, worker, grant_id) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Workspace config grant unavailable".into(),
|
||||
));
|
||||
}
|
||||
return Ok(source_capabilities);
|
||||
}
|
||||
let Some(assignment) = api
|
||||
.store
|
||||
.get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)?
|
||||
@@ -5501,12 +5516,23 @@ fn validate_manual_ticket_coder_workdir_binding(
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &workdir.source {
|
||||
if !workspace_config::granted_to(api, worker, grant_id) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Workspace config grant unavailable".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
effective_links.push(link.clone());
|
||||
continue;
|
||||
}
|
||||
let (workdir_runtime_id, repository_id) = match &workdir.source {
|
||||
WorkdirRegistrySource::Repository {
|
||||
runtime_id,
|
||||
repository_id,
|
||||
} => (runtime_id, repository_id),
|
||||
WorkdirRegistrySource::ExternalGrant { .. } => {
|
||||
WorkdirRegistrySource::ExternalGrant { .. }
|
||||
| WorkdirRegistrySource::WorkspaceConfig { .. } => {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"Ticket `{ticket_id}` attachment `{}` is an ExternalGrant and cannot satisfy a persisted Repository target",
|
||||
link.alias
|
||||
@@ -6044,6 +6070,19 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::current_worker_workdir_catalog(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::current_worker_workspace_config_get(service.clone()))
|
||||
.merge(server_api::server_api_axum::current_worker_workspace_config_attach(service.clone()))
|
||||
.merge(
|
||||
server_api::server_api_axum::current_worker_workspace_config_observe(service.clone()),
|
||||
)
|
||||
.merge(server_api::server_api_axum::current_worker_workspace_config_read(service.clone()))
|
||||
.merge(server_api::server_api_axum::current_worker_workspace_config_commit(service.clone()))
|
||||
.merge(server_api::server_api_axum::workspace_config_grant_create(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::workspace_config_grant_revoke(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::current_worker_workdir_attachment_list(service.clone()))
|
||||
.merge(server_api::server_api_axum::current_worker_workdir_attach(
|
||||
service.clone(),
|
||||
@@ -10579,6 +10618,110 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn current_worker_workspace_config_get(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<
|
||||
server_api::WorkspaceConfigCurrentResponse,
|
||||
server_api::WorkspaceConfigApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| {
|
||||
workspace_config::pre_error(Error::Store("Workspace unavailable".into()))
|
||||
})?;
|
||||
let worker = workspace_config::identity(api, &context, &workspace_id)?;
|
||||
workspace_config::get(api, &worker)
|
||||
.await
|
||||
.map(server_api::WorkspaceConfigCurrentResponse)
|
||||
}
|
||||
async fn current_worker_workspace_config_attach(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::WorkspaceConfigAttachRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::WorkspaceConfigAttachment,
|
||||
server_api::WorkspaceConfigApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| {
|
||||
workspace_config::pre_error(Error::Store("Workspace unavailable".into()))
|
||||
})?;
|
||||
let worker = workspace_config::identity(api, &context, &workspace_id)?;
|
||||
workspace_config::attach(api, &worker, request).await
|
||||
}
|
||||
async fn current_worker_workspace_config_observe(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::WorkspaceConfigObserveRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::WorkspaceConfigObserveResponse,
|
||||
server_api::WorkspaceConfigApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| {
|
||||
workspace_config::pre_error(Error::Store("Workspace unavailable".into()))
|
||||
})?;
|
||||
let worker = workspace_config::identity(api, &context, &workspace_id)?;
|
||||
workspace_config::observe(api, &worker, request).await
|
||||
}
|
||||
async fn current_worker_workspace_config_read(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::WorkspaceConfigReadRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::WorkspaceConfigReadResponse,
|
||||
server_api::WorkspaceConfigApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| {
|
||||
workspace_config::pre_error(Error::Store("Workspace unavailable".into()))
|
||||
})?;
|
||||
let worker = workspace_config::identity(api, &context, &workspace_id)?;
|
||||
workspace_config::read(api, &worker, request).await
|
||||
}
|
||||
async fn current_worker_workspace_config_commit(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::WorkspaceConfigCommitRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::WorkspaceConfigCommitResponse,
|
||||
server_api::WorkspaceConfigApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| {
|
||||
workspace_config::pre_error(Error::Store("Workspace unavailable".into()))
|
||||
})?;
|
||||
let worker = workspace_config::identity(api, &context, &workspace_id)?;
|
||||
workspace_config::commit(api, &worker, request).await
|
||||
}
|
||||
async fn workspace_config_grant_create(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
request: server_api::WorkspaceConfigGrantCreateRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::WorkspaceConfigGrantResponse,
|
||||
server_api::WorkspaceConfigApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| {
|
||||
workspace_config::pre_error(Error::Store("Workspace unavailable".into()))
|
||||
})?;
|
||||
workspace_config::create_grant(api, &actor, &workspace_id, request).await
|
||||
}
|
||||
async fn workspace_config_grant_revoke(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
grant_id: String,
|
||||
) -> std::result::Result<
|
||||
server_api::WorkspaceConfigGrantResponse,
|
||||
server_api::WorkspaceConfigApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| {
|
||||
workspace_config::pre_error(Error::Store("Workspace unavailable".into()))
|
||||
})?;
|
||||
workspace_config::revoke_grant(api, &actor, &workspace_id, &grant_id).await
|
||||
}
|
||||
async fn current_worker_workdir_catalog(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -10591,14 +10734,19 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
let api = self.workspace_api()?;
|
||||
validate_workspace_scope(api, &workspace_id)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
current_worker_identity(
|
||||
let worker = current_worker_identity(
|
||||
api,
|
||||
&workspace_id,
|
||||
¤t_worker_contract_headers(&context)?,
|
||||
)
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
list_current_worker_workdir_catalog(api, query).map_err(ApiError::into_repository_api_error)
|
||||
{
|
||||
let session_lock = current_worker_session_lock(api, &worker);
|
||||
let _guard = session_lock.lock().await;
|
||||
list_current_worker_workdir_catalog_for_worker(api, &worker, query)
|
||||
}
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn current_worker_workdir_attachment_list(
|
||||
@@ -12185,37 +12333,70 @@ async fn scoped_commit_workspace_config_tree(
|
||||
) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> {
|
||||
let request = config_commit_request_from_api(request)?;
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let candidate = api
|
||||
.config_store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
&path.workspace_id,
|
||||
&request,
|
||||
api.config_schema_registry.compose()?,
|
||||
)?;
|
||||
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||
project_repository_access_candidate(
|
||||
&*api.store,
|
||||
&api.repository_secrets,
|
||||
&path.workspace_id,
|
||||
&candidate,
|
||||
)?;
|
||||
let state = api
|
||||
.config_store
|
||||
.commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
|
||||
if let Ok(projection) = api
|
||||
.prompt_projection_cache
|
||||
.resolve(&path.workspace_id, &state)
|
||||
{
|
||||
let _diagnostics = api
|
||||
.runtime
|
||||
.observe_workspace_prompt_projection((*projection).clone());
|
||||
}
|
||||
let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(workspace_config_state_to_api(state)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Shared canonical commit and activation boundary for UI and logical config operations.
|
||||
/// Callers must authorize access before entering this boundary.
|
||||
///
|
||||
/// Repository access projection may materialize a default credential before the
|
||||
/// config CAS commits. An error here therefore does not promise that every
|
||||
/// auxiliary side effect was rolled back. Prompt publication is best effort,
|
||||
/// exactly as it is for UI saves, and must not turn a committed save into an error.
|
||||
fn commit_workspace_config_tree(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
request: &ConfigCommitRequest,
|
||||
) -> Result<crate::config_source::WorkspaceConfigState> {
|
||||
let candidate = prepare_workspace_config_tree(api, workspace_id, request)?;
|
||||
persist_workspace_config_candidate(api, workspace_id, &candidate)
|
||||
}
|
||||
|
||||
fn prepare_workspace_config_tree(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
request: &ConfigCommitRequest,
|
||||
) -> Result<crate::config_source::EvaluatedConfigCandidate> {
|
||||
let candidate = api
|
||||
.config_store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
workspace_id,
|
||||
request,
|
||||
api.config_schema_registry.compose()?,
|
||||
)?;
|
||||
crate::config_source::validate_workspace_config_candidate_projections(
|
||||
workspace_id,
|
||||
&candidate,
|
||||
)?;
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
fn persist_workspace_config_candidate(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
candidate: &crate::config_source::EvaluatedConfigCandidate,
|
||||
) -> Result<crate::config_source::WorkspaceConfigState> {
|
||||
project_repository_access_candidate(
|
||||
&*api.store,
|
||||
&api.repository_secrets,
|
||||
workspace_id,
|
||||
candidate,
|
||||
)?;
|
||||
let state = api
|
||||
.config_store
|
||||
.commit_evaluated_workspace_config(workspace_id, &candidate)?;
|
||||
if let Ok(projection) = api.prompt_projection_cache.resolve(workspace_id, &state) {
|
||||
let _diagnostics = api
|
||||
.runtime
|
||||
.observe_workspace_prompt_projection((*projection).clone());
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
async fn scoped_get_profile_settings(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -16184,6 +16365,14 @@ async fn open_current_worker_workdir_session_locked(
|
||||
link.workdir_id
|
||||
),
|
||||
})?;
|
||||
if matches!(
|
||||
workdir.source,
|
||||
WorkdirRegistrySource::WorkspaceConfig { .. }
|
||||
) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Logical config has no filesystem session".into(),
|
||||
));
|
||||
}
|
||||
let effective_capabilities =
|
||||
effective_worker_workdir_capabilities(api, worker, &workdir, link.capabilities)?;
|
||||
if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source {
|
||||
@@ -16263,7 +16452,8 @@ async fn open_current_worker_workdir_session_locked(
|
||||
runtime_id,
|
||||
repository_id,
|
||||
} => (runtime_id.as_str(), repository_id.as_str()),
|
||||
WorkdirRegistrySource::ExternalGrant { .. } => {
|
||||
WorkdirRegistrySource::ExternalGrant { .. }
|
||||
| WorkdirRegistrySource::WorkspaceConfig { .. } => {
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
runtime_id: "workspace-backend".to_string(),
|
||||
code: "external_workdir_provider_unavailable".to_string(),
|
||||
@@ -16389,6 +16579,15 @@ fn sync_runtime_worker_workdir_attachments(
|
||||
.list_worker_workdir_links(&api.config.workspace_id, worker)?
|
||||
.into_iter()
|
||||
.filter(|link| link.unlinked_at.is_none())
|
||||
.filter(|link| {
|
||||
api.store
|
||||
.get_workdir_registry(&api.config.workspace_id, &link.workdir_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none_or(|record| {
|
||||
!matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. })
|
||||
})
|
||||
})
|
||||
.map(|mut link| {
|
||||
let workdir = api
|
||||
.store
|
||||
@@ -16446,6 +16645,15 @@ async fn refresh_current_worker_session_locked(
|
||||
.list_worker_workdir_links(&api.config.workspace_id, worker)?
|
||||
.into_iter()
|
||||
.filter(|link| link.unlinked_at.is_none())
|
||||
.filter(|link| {
|
||||
api.store
|
||||
.get_workdir_registry(&api.config.workspace_id, &link.workdir_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none_or(|record| {
|
||||
!matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. })
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if remaining.len() == 1 {
|
||||
open_current_worker_workdir_session_locked(api, worker, &remaining[0]).await?;
|
||||
@@ -16529,6 +16737,12 @@ async fn scoped_attach_current_worker_workdir(
|
||||
}
|
||||
match &workdir.source {
|
||||
WorkdirRegistrySource::Repository { .. } => {}
|
||||
WorkdirRegistrySource::WorkspaceConfig { .. } => {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Use the grant-bound Workspace config attachment operation".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
WorkdirRegistrySource::ExternalGrant { grant_id } => {
|
||||
let grant = api
|
||||
.store
|
||||
@@ -16633,6 +16847,15 @@ async fn scoped_attach_current_worker_workdir(
|
||||
.list_worker_workdir_links(&api.config.workspace_id, &worker)?
|
||||
.into_iter()
|
||||
.filter(|link| link.unlinked_at.is_none())
|
||||
.filter(|link| {
|
||||
api.store
|
||||
.get_workdir_registry(&api.config.workspace_id, &link.workdir_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none_or(|record| {
|
||||
!matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. })
|
||||
})
|
||||
})
|
||||
.count();
|
||||
if active_count == 1
|
||||
&& let Err(error) = open_current_worker_workdir_session_locked(&api, &worker, &link).await
|
||||
@@ -16670,9 +16893,22 @@ async fn scoped_attach_current_worker_workdir(
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn list_current_worker_workdir_catalog(
|
||||
api: &WorkspaceApi,
|
||||
query: server_api::CurrentWorkerWorkdirCatalogQuery,
|
||||
) -> ApiResult<server_api::CurrentWorkerWorkdirCatalogResponse> {
|
||||
list_current_worker_workdir_catalog_for_worker(
|
||||
api,
|
||||
&RuntimeWorkerRef::new("catalog-test", "ungranted"),
|
||||
query,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_current_worker_workdir_catalog_for_worker(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
query: server_api::CurrentWorkerWorkdirCatalogQuery,
|
||||
) -> ApiResult<server_api::CurrentWorkerWorkdirCatalogResponse> {
|
||||
// No Runtime inventory refresh: both projection inputs and complete digest are authoritative
|
||||
// Backend records captured by the store in one read transaction.
|
||||
@@ -16685,6 +16921,19 @@ fn list_current_worker_workdir_catalog(
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &entry.record.source {
|
||||
if !workspace_config::granted_to(api, worker, grant_id) {
|
||||
return None;
|
||||
}
|
||||
let grant = api
|
||||
.store
|
||||
.get_workspace_config_grant(&api.config.workspace_id, grant_id)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let mut summary = workspace_config::summary(&grant, &entry.record);
|
||||
summary.occupied_by = entry.occupied_by.map(Into::into);
|
||||
return Some(summary);
|
||||
}
|
||||
if matches!(
|
||||
entry.record.source,
|
||||
WorkdirRegistrySource::ExternalGrant { .. }
|
||||
@@ -16818,6 +17067,15 @@ async fn detach_current_worker_workdir(
|
||||
.list_worker_workdir_links(&api.config.workspace_id, worker)?
|
||||
.into_iter()
|
||||
.filter(|link| link.unlinked_at.is_none())
|
||||
.filter(|link| {
|
||||
api.store
|
||||
.get_workdir_registry(&api.config.workspace_id, &link.workdir_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none_or(|record| {
|
||||
!matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. })
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if remaining.len() == 1
|
||||
&& let Err(error) =
|
||||
@@ -24143,6 +24401,20 @@ async fn scoped_working_directory_detail(
|
||||
code: "working_directory_not_found".to_string(),
|
||||
message: format!("Unknown Workdir `{}`", path.working_directory_id),
|
||||
})?;
|
||||
if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &record.source {
|
||||
let grant = api
|
||||
.store
|
||||
.get_workspace_config_grant(&api.config.workspace_id, grant_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied("Workspace config grant unavailable".into())
|
||||
})?;
|
||||
return Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
runtime_id: None,
|
||||
item: workspace_config::summary(&grant, &record),
|
||||
diagnostics: vec![],
|
||||
}));
|
||||
}
|
||||
if let Some(runtime_id) = record.source.runtime_id().map(str::to_string) {
|
||||
working_directory_detail_for_runtime(api, &runtime_id, &path.working_directory_id)
|
||||
} else {
|
||||
@@ -30790,6 +31062,9 @@ fn working_directory_summaries(
|
||||
.list_workdir_registry(&api.config.workspace_id, 200)?;
|
||||
let mut items = Vec::new();
|
||||
for record in records {
|
||||
if matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Some(grant_id) = legacy_external_workdir_grant_id(api, &record)? {
|
||||
diagnostics.push(RuntimeDiagnostic {
|
||||
code: "external_workdir_legacy_permissions".to_string(),
|
||||
@@ -30818,6 +31093,9 @@ fn available_working_directory_summaries(
|
||||
.list_workdir_registry(&api.config.workspace_id, 200)?;
|
||||
let mut available = Vec::new();
|
||||
for record in records {
|
||||
if matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) {
|
||||
continue;
|
||||
}
|
||||
if legacy_external_workdir_grant_id(api, &record)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
@@ -30827,6 +31105,7 @@ fn available_working_directory_summaries(
|
||||
summary.cleanliness.as_deref() == Some("clean")
|
||||
}
|
||||
WorkdirRegistrySource::ExternalGrant { .. } => true,
|
||||
WorkdirRegistrySource::WorkspaceConfig { .. } => false,
|
||||
};
|
||||
if summary.status != WorkingDirectoryStatusKind::Active || !source_is_available {
|
||||
continue;
|
||||
@@ -31057,6 +31336,30 @@ fn project_worker_registry_projection(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &workdir.source {
|
||||
if !workspace_config::granted_to(api, &record.worker, grant_id) {
|
||||
continue;
|
||||
}
|
||||
let grant = api
|
||||
.store
|
||||
.get_workspace_config_grant(&api.config.workspace_id, grant_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied("Workspace config grant unavailable".into())
|
||||
})?;
|
||||
workdir_attachments.push(server_api::WorkerWorkdirAttachmentSummary {
|
||||
alias: link.alias,
|
||||
effective_permissions: workdir_permission_summary(
|
||||
effective_worker_workdir_capabilities(
|
||||
api,
|
||||
&record.worker,
|
||||
workdir,
|
||||
link.capabilities,
|
||||
)?,
|
||||
),
|
||||
working_directory: workspace_config::summary(&grant, workdir),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let effective_permissions = workdir_permission_summary(
|
||||
effective_worker_workdir_capabilities(api, &record.worker, workdir, link.capabilities)?,
|
||||
);
|
||||
@@ -31458,6 +31761,9 @@ fn workdir_summary_from_record(
|
||||
_ => WorkingDirectoryStatusKind::Unknown,
|
||||
};
|
||||
let (source, materializer_kind, cleanup_target) = match &record.source {
|
||||
WorkdirRegistrySource::WorkspaceConfig { .. } => {
|
||||
unreachable!("logical config never enters filesystem summary")
|
||||
}
|
||||
WorkdirRegistrySource::Repository { .. } => {
|
||||
let repository_key =
|
||||
repository_key.expect("Repository Workdir projection requires key");
|
||||
@@ -31537,6 +31843,11 @@ fn projected_workdir_summary_from_record(
|
||||
record: &WorkdirRegistryRecord,
|
||||
) -> Result<WorkingDirectorySummary> {
|
||||
let (repository_key, external_grant_permissions) = match &record.source {
|
||||
WorkdirRegistrySource::WorkspaceConfig { .. } => {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Logical config is not a filesystem Workdir".into(),
|
||||
));
|
||||
}
|
||||
WorkdirRegistrySource::Repository { repository_id, .. } => (
|
||||
Some(
|
||||
api.store
|
||||
@@ -32761,6 +33072,7 @@ impl IntoResponse for ApiError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
include!("server_workspace_config_tests.rs");
|
||||
use super::*;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::Request;
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
//! Logical configuration operations share the durable attachment ledger and canonical CAS.
|
||||
//! This module never opens a Runtime filesystem session or exports a host path.
|
||||
//! Files support read/edit/write and non-entrypoint deletion; directories support
|
||||
//! create, with atomic change sets (including non-entrypoint renames) on the root.
|
||||
//! The canonical main.dcdl entrypoint cannot be deleted or renamed.
|
||||
use super::*;
|
||||
use server_api::{
|
||||
WorkspaceConfigAccess as Access, WorkspaceConfigApiError as Failure,
|
||||
WorkspaceConfigFailureClassification as Classification, WorkspaceConfigNodeKind as NodeKind,
|
||||
};
|
||||
use server_api::{
|
||||
WorkspaceConfigAttachRequest, WorkspaceConfigAttachment, WorkspaceConfigCommitRequest,
|
||||
WorkspaceConfigCommitResponse, WorkspaceConfigGrantCreateRequest, WorkspaceConfigGrantResponse,
|
||||
WorkspaceConfigNode, WorkspaceConfigObserveRequest, WorkspaceConfigObserveResponse,
|
||||
WorkspaceConfigReadRequest, WorkspaceConfigReadResponse,
|
||||
};
|
||||
|
||||
const ALIAS: &str = "workspace-config";
|
||||
const MAX_NODES: usize = 256;
|
||||
const MAX_TEXT: usize = 256 * 1024;
|
||||
const DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
type ConfigResult<T> = std::result::Result<T, Failure>;
|
||||
|
||||
fn fail(status: u16, code: &str) -> Failure {
|
||||
Failure {
|
||||
status,
|
||||
code: code.into(),
|
||||
message: format!("Workspace configuration: {code}"),
|
||||
classification: Classification::NotCommitted,
|
||||
}
|
||||
}
|
||||
pub(super) fn pre_error(error: impl Into<ApiError>) -> Failure {
|
||||
let error = error.into();
|
||||
let status = api_error_status(&error.error).as_u16();
|
||||
fail(
|
||||
status,
|
||||
match status {
|
||||
401 | 403 => "access_denied",
|
||||
404 => "not_found",
|
||||
409 => "conflict",
|
||||
413 => "limit_exceeded",
|
||||
500..=599 => "unavailable",
|
||||
_ => "invalid_request",
|
||||
},
|
||||
)
|
||||
}
|
||||
fn mutation_error(error: Error) -> Failure {
|
||||
// Storage/service failures at a mutation boundary may include a commit
|
||||
// acknowledgement failure. Never describe those as a guaranteed rollback.
|
||||
if matches!(&error, Error::Sqlite(_) | Error::Store(_)) {
|
||||
unknown()
|
||||
} else {
|
||||
pre_error(error)
|
||||
}
|
||||
}
|
||||
fn unknown() -> Failure {
|
||||
Failure {
|
||||
classification: Classification::Unknown,
|
||||
..fail(500, "outcome_unknown")
|
||||
}
|
||||
}
|
||||
pub(super) fn identity(
|
||||
api: &WorkspaceApi,
|
||||
context: &server_api::ServerRequestContext,
|
||||
workspace_id: &str,
|
||||
) -> ConfigResult<RuntimeWorkerRef> {
|
||||
validate_workspace_scope(api, workspace_id).map_err(pre_error)?;
|
||||
let headers =
|
||||
current_worker_contract_headers(context).map_err(|_| fail(403, "access_denied"))?;
|
||||
current_worker_identity(api, workspace_id, &headers).map_err(pre_error)
|
||||
}
|
||||
async fn lock(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> ConfigResult<tokio::sync::OwnedMutexGuard<()>> {
|
||||
tokio::time::timeout(
|
||||
DEADLINE,
|
||||
current_worker_session_lock(api, worker).lock_owned(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| fail(503, "busy"))
|
||||
}
|
||||
fn grant_for_worker(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> ConfigResult<WorkspaceConfigGrantResponse> {
|
||||
api.store
|
||||
.current_workspace_config_grant(&api.config.workspace_id, worker)
|
||||
.map_err(pre_error)?
|
||||
.filter(|grant| !grant.revoked)
|
||||
.ok_or_else(|| fail(403, "access_denied"))
|
||||
}
|
||||
fn access_capabilities(access: Access) -> workdir::WorkdirSessionCapabilities {
|
||||
match access {
|
||||
Access::ReadOnly => workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
Access::ReadWrite => workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
}
|
||||
}
|
||||
pub(super) fn grant_capabilities(
|
||||
api: &WorkspaceApi,
|
||||
grant_id: &str,
|
||||
) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
let grant = api
|
||||
.store
|
||||
.get_workspace_config_grant(&api.config.workspace_id, grant_id)?
|
||||
.filter(|grant| !grant.revoked)
|
||||
.ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied("Workspace config grant unavailable".into())
|
||||
})?;
|
||||
Ok(access_capabilities(grant.access))
|
||||
}
|
||||
pub(super) fn granted_to(api: &WorkspaceApi, worker: &RuntimeWorkerRef, grant_id: &str) -> bool {
|
||||
grant_for_worker(api, worker).is_ok_and(|grant| grant.grant_id == grant_id)
|
||||
}
|
||||
fn active_link(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
grant: &WorkspaceConfigGrantResponse,
|
||||
connection_id: Option<&str>,
|
||||
) -> ConfigResult<Option<WorkerWorkdirLinkRecord>> {
|
||||
if connection_id.is_some_and(|id| id.is_empty() || id.len() > 128) {
|
||||
return Err(fail(400, "invalid_connection"));
|
||||
}
|
||||
let link = api
|
||||
.store
|
||||
// The T697 exclusive-active-Workdir ledger bounds this lookup to one
|
||||
// current lifetime, regardless of accumulated detached history.
|
||||
.list_workdir_worker_links(&api.config.workspace_id, &grant.working_directory_id)
|
||||
.map_err(pre_error)?
|
||||
.into_iter()
|
||||
.find(|link| link.unlinked_at.is_none() && link.worker == *worker && link.alias == ALIAS);
|
||||
if let Some(link) = &link {
|
||||
let record = api
|
||||
.store
|
||||
.get_workdir_registry(&api.config.workspace_id, &link.workdir_id)
|
||||
.map_err(pre_error)?
|
||||
.ok_or_else(|| fail(403, "access_denied"))?;
|
||||
if link.workdir_id != grant.working_directory_id
|
||||
|| !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { ref grant_id } if grant_id == &grant.grant_id)
|
||||
{
|
||||
return Err(fail(403, "access_denied"));
|
||||
}
|
||||
}
|
||||
if let Some(id) = connection_id {
|
||||
if !link.as_ref().is_some_and(|link| link.connection_id == id) {
|
||||
return Err(fail(409, "connection_changed"));
|
||||
}
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
fn effective_access(
|
||||
grant: &WorkspaceConfigGrantResponse,
|
||||
link: &WorkerWorkdirLinkRecord,
|
||||
) -> Access {
|
||||
if grant.access == Access::ReadWrite
|
||||
&& link
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Write)
|
||||
&& link
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Edit)
|
||||
{
|
||||
Access::ReadWrite
|
||||
} else {
|
||||
Access::ReadOnly
|
||||
}
|
||||
}
|
||||
fn attachment(
|
||||
grant: &WorkspaceConfigGrantResponse,
|
||||
link: &WorkerWorkdirLinkRecord,
|
||||
already_attached: bool,
|
||||
) -> WorkspaceConfigAttachment {
|
||||
WorkspaceConfigAttachment {
|
||||
workspace_id: grant.workspace_id.clone(),
|
||||
connection_id: link.connection_id.clone(),
|
||||
alias: ALIAS.into(),
|
||||
working_directory_id: link.workdir_id.clone(),
|
||||
access: effective_access(grant, link),
|
||||
name: "Workspace configuration".into(),
|
||||
purpose: "Explore and edit this Workspace's canonical configuration".into(),
|
||||
content_path: "/workspace-config".into(),
|
||||
already_attached,
|
||||
}
|
||||
}
|
||||
pub(super) async fn get(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> ConfigResult<Option<WorkspaceConfigAttachment>> {
|
||||
let _guard = lock(api, worker).await?;
|
||||
let grant = grant_for_worker(api, worker)?;
|
||||
Ok(active_link(api, worker, &grant, None)?.map(|link| attachment(&grant, &link, true)))
|
||||
}
|
||||
pub(super) async fn attach(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
request: WorkspaceConfigAttachRequest,
|
||||
) -> ConfigResult<WorkspaceConfigAttachment> {
|
||||
if request.alias.as_deref().is_some_and(|alias| alias != ALIAS) {
|
||||
return Err(fail(400, "invalid_alias"));
|
||||
}
|
||||
let _guard = lock(api, worker).await?;
|
||||
let grant = grant_for_worker(api, worker)?;
|
||||
let access = request.access.unwrap_or(grant.access);
|
||||
if access == Access::ReadWrite && grant.access != Access::ReadWrite {
|
||||
return Err(fail(403, "access_denied"));
|
||||
}
|
||||
if let Some(mut link) = active_link(api, worker, &grant, None)? {
|
||||
// Same active alias/identity is idempotent; explicit write never silently downgrades.
|
||||
if request.access == Some(Access::ReadWrite)
|
||||
&& effective_access(&grant, &link) != Access::ReadWrite
|
||||
{
|
||||
return Err(fail(403, "access_denied"));
|
||||
}
|
||||
if access == Access::ReadOnly && effective_access(&grant, &link) != Access::ReadOnly {
|
||||
link.capabilities = access_capabilities(access);
|
||||
link = api
|
||||
.store
|
||||
.attach_worker_workdir(&link)
|
||||
.map_err(mutation_error)?;
|
||||
}
|
||||
return Ok(attachment(&grant, &link, true));
|
||||
}
|
||||
let link = api
|
||||
.store
|
||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||
connection_id: String::new(),
|
||||
workspace_id: grant.workspace_id.clone(),
|
||||
worker: worker.clone(),
|
||||
workdir_id: grant.working_directory_id.clone(),
|
||||
alias: ALIAS.into(),
|
||||
capabilities: access_capabilities(access),
|
||||
linked_at: now_registry_timestamp(),
|
||||
unlinked_at: None,
|
||||
})
|
||||
.map_err(mutation_error)?;
|
||||
// No Runtime filesystem/session synchronization for a logical config target.
|
||||
api.worker_projection
|
||||
.refresh(worker)
|
||||
.map_err(|_| unknown())?;
|
||||
Ok(attachment(&grant, &link, false))
|
||||
}
|
||||
fn resolve(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
connection_id: &str,
|
||||
) -> ConfigResult<(WorkspaceConfigGrantResponse, WorkerWorkdirLinkRecord)> {
|
||||
let grant = grant_for_worker(api, worker)?;
|
||||
let link = active_link(api, worker, &grant, Some(connection_id))?
|
||||
.ok_or_else(|| fail(409, "connection_changed"))?;
|
||||
Ok((grant, link))
|
||||
}
|
||||
fn path_valid(path: &str, root: bool) -> ConfigResult<()> {
|
||||
if root && path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
config_source::VirtualPath::parse(path).map_err(|_| fail(400, "invalid_path"))?;
|
||||
if path.chars().any(char::is_control) {
|
||||
return Err(fail(400, "invalid_path"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn tree(api: &WorkspaceApi) -> ConfigResult<crate::config_source::WorkspaceConfigState> {
|
||||
api.config_store
|
||||
.load_workspace_config(&api.config.workspace_id)
|
||||
.map_err(pre_error)?
|
||||
.ok_or_else(|| fail(404, "not_found"))
|
||||
}
|
||||
fn validator(
|
||||
grant: &WorkspaceConfigGrantResponse,
|
||||
link: &WorkerWorkdirLinkRecord,
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
path: &str,
|
||||
) -> String {
|
||||
let bound = serde_json::to_vec(&(
|
||||
"workspace-config:v1",
|
||||
&grant.workspace_id,
|
||||
&grant.grant_id,
|
||||
&grant.runtime_id,
|
||||
&grant.worker_id,
|
||||
&link.connection_id,
|
||||
effective_access(grant, link),
|
||||
path,
|
||||
snapshot.revision,
|
||||
&snapshot.digest,
|
||||
))
|
||||
.expect("bounded configuration validator tuple");
|
||||
format!(
|
||||
"wc1:{}",
|
||||
Sha256::digest(bound)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
)
|
||||
}
|
||||
fn check_validator(actual: &str, expected: &str) -> ConfigResult<()> {
|
||||
if actual != expected {
|
||||
Err(fail(409, "stale_validator"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(super) async fn observe(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
request: WorkspaceConfigObserveRequest,
|
||||
) -> ConfigResult<WorkspaceConfigObserveResponse> {
|
||||
if request.paths.is_empty() || request.paths.len() > MAX_NODES || request.depth > 8 {
|
||||
return Err(fail(400, "limit_exceeded"));
|
||||
}
|
||||
for path in &request.paths {
|
||||
path_valid(path, true)?;
|
||||
}
|
||||
let _guard = lock(api, worker).await?;
|
||||
let (grant, link) = resolve(api, worker, &request.connection_id)?;
|
||||
let state = tree(api)?;
|
||||
let snapshot = &state.snapshot;
|
||||
let writable = effective_access(&grant, &link) == Access::ReadWrite;
|
||||
// Build only bounded metadata (the canonical tree itself is bounded by config-source).
|
||||
let mut kinds = BTreeMap::from([(String::new(), NodeKind::Directory)]);
|
||||
for path in snapshot.entries.keys() {
|
||||
let text = path.as_str();
|
||||
kinds.insert(text.to_string(), NodeKind::File);
|
||||
let mut cursor = text;
|
||||
while let Some((parent, _)) = cursor.rsplit_once('/') {
|
||||
kinds.entry(parent.into()).or_insert(NodeKind::Directory);
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
let mut selected = BTreeMap::new();
|
||||
for path in request.paths {
|
||||
selected.insert(
|
||||
path.clone(),
|
||||
*kinds.get(&path).unwrap_or(&NodeKind::Missing),
|
||||
);
|
||||
let prefix = if path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{path}/")
|
||||
};
|
||||
for (child, kind) in kinds.range(prefix.clone()..) {
|
||||
if !child.starts_with(&prefix) {
|
||||
break;
|
||||
}
|
||||
if child == &path {
|
||||
continue;
|
||||
}
|
||||
let relative = &child[prefix.len()..];
|
||||
if relative.split('/').count() <= request.depth as usize {
|
||||
selected.insert(child.clone(), *kind);
|
||||
}
|
||||
if selected.len() > MAX_NODES {
|
||||
return Err(fail(413, "limit_exceeded"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected.len() > MAX_NODES {
|
||||
return Err(fail(413, "limit_exceeded"));
|
||||
}
|
||||
let nodes = selected
|
||||
.into_iter()
|
||||
.map(|(path, kind)| {
|
||||
let entry = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.find(|(key, _)| key.as_str() == path)
|
||||
.map(|(_, entry)| entry);
|
||||
let mut operations = match kind {
|
||||
NodeKind::File => vec!["read".to_string()],
|
||||
NodeKind::Directory | NodeKind::Missing => vec![],
|
||||
};
|
||||
if writable {
|
||||
match kind {
|
||||
NodeKind::File => {
|
||||
operations.extend(["edit", "write"].map(str::to_string));
|
||||
if path != crate::config_source::MAIN_CONFIG_ENTRYPOINT {
|
||||
operations.push("delete".into());
|
||||
}
|
||||
}
|
||||
NodeKind::Missing => operations.push("create".into()),
|
||||
NodeKind::Directory => {
|
||||
operations.push("create".into());
|
||||
if path.is_empty() {
|
||||
operations.push("apply_changes".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WorkspaceConfigNode {
|
||||
validator: validator(&grant, &link, snapshot, &path),
|
||||
path,
|
||||
kind,
|
||||
digest: entry.map(|entry| entry.content_digest.clone()),
|
||||
content_type: entry.map(|entry| match entry.content_type {
|
||||
config_source::ConfigContentType::Decodal => {
|
||||
server_api::ConfigContentType::Decodal
|
||||
}
|
||||
config_source::ConfigContentType::Text => server_api::ConfigContentType::Text,
|
||||
}),
|
||||
operations,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(WorkspaceConfigObserveResponse {
|
||||
connection_id: link.connection_id.clone(),
|
||||
validator: validator(&grant, &link, snapshot, ""),
|
||||
revision: snapshot.revision,
|
||||
digest: snapshot.digest.clone(),
|
||||
entrypoints: state
|
||||
.contract
|
||||
.entrypoints
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
nodes,
|
||||
})
|
||||
}
|
||||
pub(super) async fn read(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
request: WorkspaceConfigReadRequest,
|
||||
) -> ConfigResult<WorkspaceConfigReadResponse> {
|
||||
path_valid(&request.path, false)?;
|
||||
let _guard = lock(api, worker).await?;
|
||||
let (grant, link) = resolve(api, worker, &request.connection_id)?;
|
||||
let state = tree(api)?;
|
||||
let expected = validator(&grant, &link, &state.snapshot, &request.path);
|
||||
check_validator(&request.validator, &expected)?;
|
||||
let path =
|
||||
config_source::VirtualPath::parse(&request.path).map_err(|_| fail(400, "invalid_path"))?;
|
||||
let entry = state
|
||||
.snapshot
|
||||
.entries
|
||||
.get(&path)
|
||||
.ok_or_else(|| fail(404, "not_found"))?;
|
||||
if entry.content.len() > MAX_TEXT {
|
||||
return Err(fail(413, "limit_exceeded"));
|
||||
}
|
||||
Ok(WorkspaceConfigReadResponse {
|
||||
path: request.path,
|
||||
content: entry.content.clone(),
|
||||
content_type: match entry.content_type {
|
||||
config_source::ConfigContentType::Decodal => server_api::ConfigContentType::Decodal,
|
||||
config_source::ConfigContentType::Text => server_api::ConfigContentType::Text,
|
||||
},
|
||||
digest: entry.content_digest.clone(),
|
||||
validator: expected,
|
||||
})
|
||||
}
|
||||
pub(super) async fn commit(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
request: WorkspaceConfigCommitRequest,
|
||||
) -> ConfigResult<WorkspaceConfigCommitResponse> {
|
||||
if request.request.changes.len() > MAX_NODES {
|
||||
return Err(fail(413, "limit_exceeded"));
|
||||
}
|
||||
for change in &request.request.changes {
|
||||
match change {
|
||||
server_api::ConfigTreeChange::Create { path, content, .. }
|
||||
| server_api::ConfigTreeChange::Update { path, content, .. } => {
|
||||
path_valid(path, false)?;
|
||||
if content.len() > MAX_TEXT {
|
||||
return Err(fail(413, "limit_exceeded"));
|
||||
}
|
||||
}
|
||||
server_api::ConfigTreeChange::Rename { from, to, .. } => {
|
||||
path_valid(from, false)?;
|
||||
path_valid(to, false)?;
|
||||
}
|
||||
server_api::ConfigTreeChange::Delete { path, .. } => path_valid(path, false)?,
|
||||
}
|
||||
}
|
||||
let _guard = lock(api, worker).await?;
|
||||
let (grant, link) = resolve(api, worker, &request.connection_id)?;
|
||||
if effective_access(&grant, &link) != Access::ReadWrite {
|
||||
return Err(fail(403, "access_denied"));
|
||||
}
|
||||
let state = tree(api)?;
|
||||
check_validator(
|
||||
&request.validator,
|
||||
&validator(&grant, &link, &state.snapshot, ""),
|
||||
)?;
|
||||
let canonical = config_commit_request_from_api(request.request).map_err(pre_error)?;
|
||||
// Evaluation/semantic validation are pure preparation. Bound this stage
|
||||
// without ever dispatching persistence in a timed-out background task.
|
||||
let preparation_api = api.clone();
|
||||
let preparation = tokio::task::spawn_blocking(move || {
|
||||
prepare_workspace_config_tree(
|
||||
&preparation_api,
|
||||
&preparation_api.config.workspace_id,
|
||||
&canonical,
|
||||
)
|
||||
});
|
||||
let candidate = tokio::time::timeout(DEADLINE, preparation)
|
||||
.await
|
||||
.map_err(|_| fail(503, "evaluation_timeout"))?
|
||||
.map_err(|_| fail(500, "evaluation_unavailable"))?
|
||||
.map_err(pre_error)?;
|
||||
// Recheck durable authority before the first auxiliary/canonical effects.
|
||||
let (current_grant, current_link) = resolve(api, worker, &request.connection_id)?;
|
||||
if current_grant != grant || current_link.capabilities != link.capabilities {
|
||||
return Err(fail(409, "authority_changed"));
|
||||
}
|
||||
// From this boundary repository credential projection may have side effects.
|
||||
// Never falsely claim rollback or automatically replay a failed/uncertain save.
|
||||
let state = persist_workspace_config_candidate(api, &api.config.workspace_id, &candidate)
|
||||
.map_err(|_| unknown())?;
|
||||
Ok(WorkspaceConfigCommitResponse {
|
||||
validator: validator(&grant, &link, &state.snapshot, ""),
|
||||
revision: state.snapshot.revision,
|
||||
digest: state.snapshot.digest,
|
||||
})
|
||||
}
|
||||
pub(super) async fn create_grant(
|
||||
api: &WorkspaceApi,
|
||||
actor: &RequestActor,
|
||||
workspace_id: &str,
|
||||
request: WorkspaceConfigGrantCreateRequest,
|
||||
) -> ConfigResult<WorkspaceConfigGrantResponse> {
|
||||
require_workspace_owner(api, workspace_id, actor, "Workspace config grants")
|
||||
.await
|
||||
.map_err(pre_error)?;
|
||||
let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id);
|
||||
let _guard = lock(api, &worker).await?;
|
||||
let summary = api
|
||||
.runtime
|
||||
.worker(&worker)
|
||||
.map_err(|_| fail(404, "worker_not_found"))?;
|
||||
if summary.workspace.workspace_id.as_deref() != Some(workspace_id) {
|
||||
return Err(fail(403, "access_denied"));
|
||||
}
|
||||
if let Some(grant) = api
|
||||
.store
|
||||
.current_workspace_config_grant(workspace_id, &worker)
|
||||
.map_err(pre_error)?
|
||||
{
|
||||
if grant.access != request.access {
|
||||
return Err(fail(409, "grant_exists_revoke_first"));
|
||||
}
|
||||
return Ok(grant);
|
||||
}
|
||||
let grant = WorkspaceConfigGrantResponse {
|
||||
grant_id: Uuid::now_v7().to_string(),
|
||||
workspace_id: workspace_id.into(),
|
||||
runtime_id: worker.runtime_id,
|
||||
worker_id: worker.worker_id,
|
||||
working_directory_id: Uuid::now_v7().to_string(),
|
||||
access: request.access,
|
||||
revoked: false,
|
||||
};
|
||||
api.store
|
||||
.create_workspace_config_grant(&grant, &actor.account_id)
|
||||
.map_err(mutation_error)?;
|
||||
Ok(grant)
|
||||
}
|
||||
pub(super) async fn revoke_grant(
|
||||
api: &WorkspaceApi,
|
||||
actor: &RequestActor,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> ConfigResult<WorkspaceConfigGrantResponse> {
|
||||
require_workspace_owner(api, workspace_id, actor, "Workspace config grants")
|
||||
.await
|
||||
.map_err(pre_error)?;
|
||||
let mut grant = api
|
||||
.store
|
||||
.get_workspace_config_grant(workspace_id, grant_id)
|
||||
.map_err(pre_error)?
|
||||
.ok_or_else(|| fail(404, "not_found"))?;
|
||||
let worker = RuntimeWorkerRef::new(&grant.runtime_id, &grant.worker_id);
|
||||
let _guard = lock(api, &worker).await?;
|
||||
api.store
|
||||
.revoke_workspace_config_grant(workspace_id, grant_id)
|
||||
.map_err(mutation_error)?;
|
||||
grant.revoked = true;
|
||||
// Revocation is durable before unlink; a failed cleanup cannot restore access.
|
||||
if let Some(link) = api
|
||||
.store
|
||||
.list_workdir_worker_links(workspace_id, &grant.working_directory_id)
|
||||
.map_err(|_| unknown())?
|
||||
.into_iter()
|
||||
.find(|link| link.unlinked_at.is_none() && link.worker == worker)
|
||||
{
|
||||
api.store
|
||||
.detach_worker_workdir_connection(
|
||||
workspace_id,
|
||||
&worker,
|
||||
&link.alias,
|
||||
&link.connection_id,
|
||||
&now_registry_timestamp(),
|
||||
)
|
||||
.map_err(|_| unknown())?;
|
||||
}
|
||||
api.worker_projection
|
||||
.refresh(&worker)
|
||||
.map_err(|_| unknown())?;
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
pub(super) fn summary(
|
||||
grant: &WorkspaceConfigGrantResponse,
|
||||
record: &WorkdirRegistryRecord,
|
||||
) -> server_api::WorkingDirectorySummary {
|
||||
server_api::WorkingDirectorySummary {
|
||||
working_directory_id: record.workdir_id.clone(),
|
||||
display_name: Some("Workspace configuration".into()),
|
||||
source: server_api::WorkingDirectorySource::WorkspaceConfig {
|
||||
access: grant.access,
|
||||
content_path: "/workspace-config".into(),
|
||||
purpose: "Workspace configuration".into(),
|
||||
},
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
current_selector: None,
|
||||
current_ref: None,
|
||||
current_tree: None,
|
||||
observed_at_epoch_seconds: None,
|
||||
materializer_kind: server_api::WorkingDirectoryMaterializerKind::LogicalWorkspaceConfig,
|
||||
cleanup_target: None,
|
||||
status: if grant.revoked {
|
||||
server_api::WorkingDirectoryStatusKind::NotFound
|
||||
} else {
|
||||
server_api::WorkingDirectoryStatusKind::Active
|
||||
},
|
||||
cleanliness: None,
|
||||
occupied_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn workspace_config_mutation_storage_failures_are_conservatively_unknown() {
|
||||
assert_eq!(
|
||||
mutation_error(Error::Sqlite(rusqlite::Error::InvalidQuery)).classification,
|
||||
Classification::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
mutation_error(Error::Store("private /host/storage acknowledgement".into()))
|
||||
.classification,
|
||||
Classification::Unknown
|
||||
);
|
||||
let denied = mutation_error(Error::WorkspacePermissionDenied(
|
||||
"secret permission context".into(),
|
||||
));
|
||||
assert_eq!(denied.classification, Classification::NotCommitted);
|
||||
assert_eq!(denied.status, 403);
|
||||
assert!(!denied.message.contains("secret"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,863 @@
|
||||
#[derive(Clone)]
|
||||
struct ConfigNoopLlmClient {
|
||||
api: WorkspaceApi,
|
||||
worker: RuntimeWorkerRef,
|
||||
calls: Arc<AtomicU64>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl agen::llm_client::LlmClient for ConfigNoopLlmClient {
|
||||
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
request: agen::llm_client::Request,
|
||||
) -> std::result::Result<
|
||||
std::pin::Pin<
|
||||
Box<
|
||||
dyn futures::Stream<
|
||||
Item = std::result::Result<
|
||||
agen::llm_client::event::Event,
|
||||
agen::llm_client::ClientError,
|
||||
>,
|
||||
> + Send,
|
||||
>,
|
||||
>,
|
||||
agen::llm_client::ClientError,
|
||||
> {
|
||||
let grant = self
|
||||
.api
|
||||
.store
|
||||
.current_workspace_config_grant(TEST_WORKSPACE_ID, &self.worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
self.api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &self.worker)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.unlinked_at.is_none()
|
||||
&& link.alias == "workspace-config"
|
||||
&& link.workdir_id == grant.working_directory_id),
|
||||
"real Backend attachment must precede the dependent LLM request"
|
||||
);
|
||||
assert!(
|
||||
format!("{:?}", request.items).contains("Workspace configuration is available"),
|
||||
"selected handler's preparation context must reach the actual LLM request"
|
||||
);
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
}
|
||||
// Real production Feature/WIP adapters over a signed in-process Backend HTTP router.
|
||||
// No fake config provider, copied tree, or fabricated attachment ledger is used.
|
||||
#[derive(Clone)]
|
||||
struct ConfigRouterClient {
|
||||
api: WorkspaceApi,
|
||||
worker: RuntimeWorkerRef,
|
||||
identity: Arc<RuntimeIdentityMaterial>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
paths: Arc<Mutex<Vec<String>>>,
|
||||
drop_next_commit_response: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
impl std::fmt::Debug for ConfigRouterClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Authenticated ConfigRouterClient")
|
||||
}
|
||||
}
|
||||
impl worker::WorkspaceClient for ConfigRouterClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some(TEST_WORKSPACE_ID)
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"authenticated-backend-router"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: worker::WorkspaceRequest,
|
||||
) -> std::result::Result<worker::WorkspaceResponse, worker::WorkspaceClientError> {
|
||||
use worker::{
|
||||
WorkspaceClientError as Failure, WorkspaceRequestMethod as Method, WorkspaceResponse,
|
||||
};
|
||||
let method = match request.method {
|
||||
Method::Get => "GET",
|
||||
Method::Post => "POST",
|
||||
Method::Put => "PUT",
|
||||
Method::Patch => "PATCH",
|
||||
Method::Delete => "DELETE",
|
||||
};
|
||||
self.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{method} {}", request.path));
|
||||
let is_commit = request.path.ends_with("/commit");
|
||||
let api = self.api.clone();
|
||||
let identity = self.identity.clone();
|
||||
let worker = self.worker.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
// Feature validation is synchronous, including on a Tokio thread. A separate
|
||||
// thread enters the existing runtime instead of nesting a Tokio runtime.
|
||||
let response = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
let body = request.body.unwrap_or_default().into_bytes();
|
||||
let signed = runtime_source_request(
|
||||
&identity,
|
||||
Some(&worker.worker_id),
|
||||
method,
|
||||
&request.path,
|
||||
body,
|
||||
);
|
||||
let response = build_router(api)
|
||||
.oneshot(signed)
|
||||
.await
|
||||
.map_err(|_| Failure::Request("router dispatch failed".into()))?;
|
||||
let status = response.status().as_u16();
|
||||
let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|_| Failure::Request("response unavailable after dispatch".into()))?;
|
||||
let body = String::from_utf8(bytes.to_vec())
|
||||
.map_err(|_| Failure::Request("invalid response".into()))?;
|
||||
Ok(WorkspaceResponse { status, body })
|
||||
})
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| Failure::Request("dispatch completion unavailable".into()))??;
|
||||
if is_commit
|
||||
&& response.is_success()
|
||||
&& self
|
||||
.drop_next_commit_response
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
return Err(Failure::Request(
|
||||
"simulated lost response AFTER committed dispatch".into(),
|
||||
));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
fn config_interface(path: &str) -> String {
|
||||
// Use the published contextual interface identity, not a model-managed validator.
|
||||
let hex = path
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
format!("yoi.workspace-config/node/v1/@/{hex}")
|
||||
}
|
||||
async fn config_discover(runtime: &worker::wip::WipRuntime, path: &str) {
|
||||
let observed = runtime
|
||||
.discover(path.into(), 0, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(
|
||||
observed.contains(&config_interface(path)),
|
||||
"interface must come from live discovery: {observed}"
|
||||
);
|
||||
runtime.inspect(config_interface(path), true).await.unwrap();
|
||||
}
|
||||
async fn config_call(
|
||||
runtime: &worker::wip::WipRuntime,
|
||||
path: &str,
|
||||
operation: &str,
|
||||
args: Value,
|
||||
) -> std::result::Result<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||
runtime
|
||||
.call(
|
||||
path.into(),
|
||||
config_interface(path),
|
||||
operation.into(),
|
||||
args,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn catalog_call(
|
||||
runtime: &worker::wip::WipRuntime,
|
||||
path: &str,
|
||||
base: &str,
|
||||
operation: &str,
|
||||
) -> Value {
|
||||
let hex = path
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
let interface = format!("{base}/@/{hex}");
|
||||
let discovered = runtime
|
||||
.discover(path.into(), 0, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(discovered.contains(&interface), "{discovered}");
|
||||
runtime.inspect(interface.clone(), true).await.unwrap();
|
||||
let output = runtime
|
||||
.call(
|
||||
path.into(),
|
||||
interface,
|
||||
operation.into(),
|
||||
json!({}),
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
async fn assert_config_catalog(runtime: &worker::wip::WipRuntime, attached: &Attachment) {
|
||||
let list = catalog_call(
|
||||
runtime,
|
||||
"/workdir-attachments",
|
||||
"yoi.workdir-attachment/collection/v1",
|
||||
"list",
|
||||
)
|
||||
.await;
|
||||
let listed = list["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["connection_id"] == attached.connection_id)
|
||||
.unwrap();
|
||||
let read = catalog_call(
|
||||
runtime,
|
||||
&format!("/workdir-attachments/{}", attached.connection_id),
|
||||
"yoi.workdir-attachment/item/v1",
|
||||
"read",
|
||||
)
|
||||
.await;
|
||||
for item in [listed, &read] {
|
||||
assert_eq!(item["connection_id"], attached.connection_id);
|
||||
assert_eq!(item["working_directory_id"], attached.working_directory_id);
|
||||
assert_eq!(item["alias"], attached.alias);
|
||||
assert_eq!(item["content_path"], "/workspace-config");
|
||||
assert_eq!(
|
||||
item["access"],
|
||||
serde_json::to_value(attached.access).unwrap()
|
||||
);
|
||||
assert_eq!(item["name"], attached.name);
|
||||
assert_eq!(item["purpose"], attached.purpose);
|
||||
assert!(item.get("checkout_path").is_none() && item.get("checkout_slug").is_none());
|
||||
}
|
||||
let workdir = catalog_call(
|
||||
runtime,
|
||||
&format!("/workdirs/{}", attached.working_directory_id),
|
||||
"yoi.workdir/item/v1",
|
||||
"read",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(workdir["source"]["kind"], "workspace_config");
|
||||
assert_eq!(workdir["source"]["content_path"], "/workspace-config");
|
||||
assert!(workdir["source"].get("grant_id").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_import_and_unknown() {
|
||||
use worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip;
|
||||
use worker::feature::builtin::workspace_config::{CONTENT_ROOT, WorkspaceConfigFeature};
|
||||
let mut fixture = manual_coder_assignment_fixture().await;
|
||||
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||
let granted = grant(&fixture.api, &fixture.worker, Access::ReadWrite).await;
|
||||
let client = Arc::new(ConfigRouterClient {
|
||||
api: fixture.api.clone(),
|
||||
worker: fixture.worker.clone(),
|
||||
identity: Arc::new(identity),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
paths: Default::default(),
|
||||
drop_next_commit_response: Default::default(),
|
||||
});
|
||||
let feature = WorkspaceConfigFeature::for_workspace(client.clone(), true);
|
||||
let mut mounts = worker::wip::WipMountRegistry::new();
|
||||
mount_workspace_config_wip(&mut mounts, &feature).unwrap();
|
||||
worker::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||
&mut mounts,
|
||||
&worker::feature::builtin::manage_workdir::ManageWorkdirFeature::new(client.clone()),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let wip = worker::wip::WipRuntime::from_mounts(
|
||||
mounts,
|
||||
format!(
|
||||
"{}:{}@{TEST_WORKSPACE_ID}",
|
||||
fixture.worker.runtime_id, fixture.worker.worker_id
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let manifest=worker::WorkerManifest::from_toml("[worker]\nname=\"config-e2e\"\n[model]\nscheme=\"anthropic\"\nmodel_id=\"unused\"\n[engine]\n[scope]\nallow=[]\n").unwrap();
|
||||
let calls = Arc::new(AtomicU64::new(0));
|
||||
let engine =
|
||||
agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
|
||||
ConfigNoopLlmClient {
|
||||
api: fixture.api.clone(),
|
||||
worker: fixture.worker.clone(),
|
||||
calls: calls.clone(),
|
||||
},
|
||||
);
|
||||
let store =
|
||||
session_store::FsStore::new(fixture._workspace.path().join("config-e2e-sessions")).unwrap();
|
||||
let mut host = worker::Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
store,
|
||||
worker::WorkerWorkspaceContext::with_client(
|
||||
Some(worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap()),
|
||||
client.clone(),
|
||||
),
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
manifest::Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let report =
|
||||
host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module(feature));
|
||||
assert!(
|
||||
report.installed_tool_names().is_empty() && !report.has_errors(),
|
||||
"WIP-only installation cannot expose a compatibility config Tool"
|
||||
);
|
||||
let selected = report
|
||||
.chat_invocations
|
||||
.feature_completions("workspace-c")
|
||||
.remove(0)
|
||||
.invocation
|
||||
.unwrap();
|
||||
let invocation = protocol::FeatureInvocation {
|
||||
invocation_id: "selected-config-invoke".into(),
|
||||
identity: selected.identity,
|
||||
name: selected.name,
|
||||
arguments: vec![],
|
||||
};
|
||||
assert!(
|
||||
workspace_config::get(&fixture.api, &fixture.worker)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
host.run(vec![
|
||||
protocol::Segment::FeatureInvoke {
|
||||
invocation: invocation.clone(),
|
||||
},
|
||||
protocol::Segment::text("Edit this Workspace configuration"),
|
||||
])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"production Worker run must issue its dependent LLM request only after preparation"
|
||||
);
|
||||
let attached = workspace_config::get(&fixture.api, &fixture.worker)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(attached.working_directory_id, granted.working_directory_id);
|
||||
assert_config_catalog(&wip, &attached).await;
|
||||
assert!(
|
||||
report
|
||||
.chat_invocations
|
||||
.invoke(&protocol::FeatureInvocation {
|
||||
invocation_id: "repeat-config-invoke".into(),
|
||||
..invocation.clone()
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.message
|
||||
.starts_with("Already attached")
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_config::get(&fixture.api, &fixture.worker)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.connection_id,
|
||||
attached.connection_id
|
||||
);
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
config_call(
|
||||
&wip,
|
||||
CONTENT_ROOT,
|
||||
"create",
|
||||
json!({"path":"notes/wip.md","content_type":"text","content":"original note"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let path = "/workspace-config/notes/wip.md";
|
||||
config_discover(&wip, path).await;
|
||||
assert!(
|
||||
config_call(&wip, path, "read", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap()
|
||||
.contains("original note")
|
||||
);
|
||||
config_call(
|
||||
&wip,
|
||||
path,
|
||||
"edit",
|
||||
json!({"old_string":"original","new_string":"edited"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = fixture
|
||||
.api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("notes/wip.md").unwrap())
|
||||
.unwrap()
|
||||
.content,
|
||||
"edited note"
|
||||
);
|
||||
config_call(&wip, path, "write", json!({"content":"replacement note"}))
|
||||
.await
|
||||
.unwrap();
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[
|
||||
{"kind":"create","path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"},
|
||||
{"kind":"update","path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"}
|
||||
]})).await.unwrap();
|
||||
// Confirm through ORIGINAL config reader/projection, not a copied WIP store.
|
||||
let state = fixture
|
||||
.api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap();
|
||||
assert_eq!(prompts.templates["common.language"], "CONFIG_WIP_E2E");
|
||||
config_discover(&wip, path).await;
|
||||
config_call(&wip, path, "delete", json!({})).await.unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("notes/wip.md").unwrap())
|
||||
.is_none()
|
||||
);
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
let before = fixture
|
||||
.api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let dispatched = client
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|path| path.ends_with("/commit"))
|
||||
.count();
|
||||
client
|
||||
.drop_next_commit_response
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let unknown=config_call(&wip,CONTENT_ROOT,"create",json!({"path":"notes/lost-response.md","content_type":"text","content":"committed exactly once"})).await.unwrap_err();
|
||||
assert!(unknown.to_string().contains("unknown"), "{unknown}");
|
||||
let after = fixture
|
||||
.api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(after.snapshot.revision, before.snapshot.revision + 1);
|
||||
assert!(
|
||||
after
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("notes/lost-response.md").unwrap())
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|path| path.ends_with("/commit"))
|
||||
.count(),
|
||||
dispatched + 1,
|
||||
"a lost response cannot trigger automatic retry"
|
||||
);
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
let readonly = workspace_config::attach(
|
||||
&fixture.api,
|
||||
&fixture.worker,
|
||||
Attach {
|
||||
alias: None,
|
||||
access: Some(Access::ReadOnly),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(readonly.connection_id, attached.connection_id);
|
||||
assert_config_catalog(&wip, &readonly).await;
|
||||
workspace_config::revoke_grant(
|
||||
&fixture.api,
|
||||
&test_owner_actor(),
|
||||
TEST_WORKSPACE_ID,
|
||||
&granted.grant_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
config_call(
|
||||
&wip,
|
||||
CONTENT_ROOT,
|
||||
"create",
|
||||
json!({"path":"notes/revoked.md","content":"denied"})
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"cached interface reference never grants access after revoke"
|
||||
);
|
||||
let after_revoke = catalog_call(
|
||||
&wip,
|
||||
"/workdir-attachments",
|
||||
"yoi.workdir-attachment/collection/v1",
|
||||
"list",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
after_revoke["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|item| item["connection_id"] != attached.connection_id)
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|path| !path.contains("workdir-session") && !path.contains("bash"))
|
||||
);
|
||||
}
|
||||
|
||||
// Capture the client from an actual production embedded controller factory,
|
||||
// rather than fabricating an "embedded" WorkspaceClient in the test.
|
||||
struct ConfigCaptureFactory {
|
||||
inner: worker_runtime::worker_backend::ProfileRuntimeWorkerFactory,
|
||||
client: Arc<Mutex<Option<Arc<dyn worker::WorkspaceClient>>>>,
|
||||
controller_abort: Arc<Mutex<Option<tokio::task::AbortHandle>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl worker_runtime::worker_backend::RuntimeWorkerFactory for ConfigCaptureFactory {
|
||||
async fn spawn_controller(
|
||||
&self,
|
||||
mut request: worker_runtime::execution::WorkerExecutionSpawnRequest,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
// No LLM request is issued; explicit unauthenticated test model avoids
|
||||
// depending on developer credentials during production bootstrap.
|
||||
let archive = worker_runtime::profile_archive::ProfileSourceArchive::build(worker_runtime::profile_archive::ProfileSourceArchiveInput {
|
||||
id: "profile-source-archive:config-adapter-e2e".into(),
|
||||
entrypoints: BTreeMap::from([("builtin:companion".into(), "profiles/default.dcdl".into())]),
|
||||
imports: BTreeMap::new(),
|
||||
sources: BTreeMap::from([("profiles/default.dcdl".into(), r#"{
|
||||
slug = "default"; description = "Config adapter test"; scope = "workspace_read";
|
||||
model = { scheme = "anthropic"; model_id = "test-model"; auth = { kind = "none"; }; };
|
||||
engine = { max_tokens = 100; };
|
||||
}"#.into())]),
|
||||
}).map_err(|error| error.to_string())?;
|
||||
request.request.profile_source =
|
||||
worker_runtime::catalog::ProfileSourceArchiveSource::Embedded { archive };
|
||||
let controller = self.inner.spawn_controller(request).await?;
|
||||
*self.client.lock().unwrap() = Some(controller.workspace_client.clone());
|
||||
*self.controller_abort.lock().unwrap() = Some(controller.controller_task.abort_handle());
|
||||
Ok(controller)
|
||||
}
|
||||
async fn restore_controller(
|
||||
&self,
|
||||
request: worker_runtime::execution::WorkerExecutionRestoreRequest,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
self.inner.restore_controller(request).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_config_production_http_adapter(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
client: Arc<dyn worker::WorkspaceClient>,
|
||||
) {
|
||||
use worker::feature::builtin::workspace_config::backend::WorkspaceConfigBackend;
|
||||
use worker::feature::builtin::workspace_config::{
|
||||
WorkspaceConfigAttachmentBackend, WorkspaceConfigFeature,
|
||||
};
|
||||
assert_eq!(client.kind(), "runtime-owned-workspace-client");
|
||||
let backend = WorkspaceConfigBackend::new(client.clone());
|
||||
assert!(
|
||||
backend.current().is_err(),
|
||||
"valid source proof cannot manufacture a config grant"
|
||||
);
|
||||
grant(api, worker, Access::ReadWrite).await;
|
||||
backend
|
||||
.attach("production-adapter-test", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let attached = workspace_config::get(api, worker).await.unwrap().unwrap();
|
||||
let feature = WorkspaceConfigFeature::for_workspace(client.clone(), true);
|
||||
let mut mounts = worker::wip::WipMountRegistry::new();
|
||||
worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip(
|
||||
&mut mounts,
|
||||
&feature,
|
||||
)
|
||||
.unwrap();
|
||||
worker::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||
&mut mounts,
|
||||
&worker::feature::builtin::manage_workdir::ManageWorkdirFeature::new(client),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let wip = worker::wip::WipRuntime::from_mounts(
|
||||
mounts,
|
||||
format!(
|
||||
"{}:{}@{TEST_WORKSPACE_ID}",
|
||||
worker.runtime_id, worker.worker_id
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_config_catalog(&wip, &attached).await;
|
||||
config_discover(&wip, "/workspace-config/main.dcdl").await;
|
||||
let main_interface = wip
|
||||
.inspect(config_interface("/workspace-config/main.dcdl"), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(
|
||||
!main_interface.contains("\"delete\""),
|
||||
"required entrypoint cannot advertise delete: {main_interface}"
|
||||
);
|
||||
assert!(
|
||||
config_call(&wip, "/workspace-config/main.dcdl", "read", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap()
|
||||
.contains("WorkspaceConfigSchema")
|
||||
);
|
||||
config_discover(&wip, "/workspace-config").await;
|
||||
config_call(
|
||||
&wip,
|
||||
"/workspace-config",
|
||||
"create",
|
||||
json!({"path":"notes/production-adapter.md","content_type":"text","content":"production source proof"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
api.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.snapshot
|
||||
.get(&config_source::VirtualPath::parse("notes/production-adapter.md").unwrap())
|
||||
.is_some()
|
||||
);
|
||||
workspace_config::revoke_grant(
|
||||
api,
|
||||
&test_owner_actor(),
|
||||
TEST_WORKSPACE_ID,
|
||||
&attached_grant_id(api, worker),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(backend.current().is_err());
|
||||
assert!(
|
||||
config_call(
|
||||
&wip,
|
||||
"/workspace-config",
|
||||
"create",
|
||||
json!({"path":"notes/revoked-adapter.md","content":"denied"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
fn attached_grant_id(api: &WorkspaceApi, worker: &RuntimeWorkerRef) -> String {
|
||||
api.store
|
||||
.current_workspace_config_grant(TEST_WORKSPACE_ID, worker)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.grant_id
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_workspace_request_scope()
|
||||
{
|
||||
use worker_runtime::auth::{
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WORKSPACE_REQUEST_PERMISSION,
|
||||
decode_runtime_request_source_claims,
|
||||
};
|
||||
// Remote Runtime-owned HTTP adapter, including its real request signer.
|
||||
let mut fixture = manual_coder_assignment_fixture().await;
|
||||
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
|
||||
let claims = Arc::new(Mutex::new(Vec::new()));
|
||||
let capture = claims.clone();
|
||||
let app = build_router(fixture.api.clone()).layer(axum::middleware::from_fn(
|
||||
move |request: Request<Body>, next: axum::middleware::Next| {
|
||||
let capture = capture.clone();
|
||||
async move {
|
||||
let proof = request
|
||||
.headers()
|
||||
.get(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
capture
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(decode_runtime_request_source_claims(proof).unwrap());
|
||||
next.run(request).await
|
||||
}
|
||||
},
|
||||
));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let client = worker_runtime::worker_source::RuntimeOwnedWorkspaceClient::new(
|
||||
TEST_WORKSPACE_ID,
|
||||
format!("http://{addr}"),
|
||||
&fixture.worker.runtime_id,
|
||||
&fixture.worker.worker_id,
|
||||
)
|
||||
.with_runtime_request_source(&identity, "server-test");
|
||||
assert_config_production_http_adapter(&fixture.api, &fixture.worker, Arc::new(client)).await;
|
||||
let observed = claims.lock().unwrap();
|
||||
assert!(observed.iter().any(|claim| claim.path.ends_with("/commit")));
|
||||
assert!(
|
||||
observed
|
||||
.iter()
|
||||
.all(|claim| claim.permission == WORKSPACE_REQUEST_PERMISSION
|
||||
&& claim.iss == fixture.worker.runtime_id
|
||||
&& claim.worker_id.as_deref() == Some(fixture.worker.worker_id.as_str())
|
||||
&& claim.workspace_id == TEST_WORKSPACE_ID)
|
||||
);
|
||||
drop(observed);
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workspace-config");
|
||||
let wrong_scope = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
|
||||
.issue(
|
||||
"server-test",
|
||||
TEST_WORKSPACE_ID,
|
||||
Some(&fixture.worker.worker_id),
|
||||
worker_runtime::auth::BACKEND_RESOURCE_FETCH_PERMISSION,
|
||||
"GET",
|
||||
&path,
|
||||
&[],
|
||||
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(&path)
|
||||
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, wrong_scope)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
build_router(fixture.api.clone())
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"valid signature under a different permission is not WorkspaceRequest authority"
|
||||
);
|
||||
server.abort();
|
||||
|
||||
// Actual embedded Runtime factory -> real Worker controller -> captured
|
||||
// production WorkspaceClient -> signed HTTP -> production Backend middleware.
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(root.path());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base = format!("http://{addr}");
|
||||
let mut config = test_server_config(root.path());
|
||||
config.backend_base_url = Some("server-test".into());
|
||||
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||
let captured = Arc::new(Mutex::new(None));
|
||||
let abort = Arc::new(Mutex::new(None));
|
||||
let factory = ConfigCaptureFactory {
|
||||
inner: worker_runtime::worker_backend::ProfileRuntimeWorkerFactory::new(root.path())
|
||||
.with_embedded_worker_mutation_dispatcher(
|
||||
EMBEDDED_RUNTIME_ID,
|
||||
Arc::new(
|
||||
crate::worker_source::EmbeddedServerWorkerMutationDispatcher::new(
|
||||
config.clone(),
|
||||
store.clone(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.with_workspace_request_client(
|
||||
worker_runtime::workspace_request::RuntimeWorkspaceRequestClient::new(
|
||||
TEST_WORKSPACE_ID,
|
||||
&base,
|
||||
EMBEDDED_RUNTIME_ID,
|
||||
)
|
||||
.with_runtime_request_source(&EMBEDDED_RUNTIME_REQUEST_IDENTITY, "server-test"),
|
||||
)
|
||||
.with_runtime_store_dir(root.path().join("production-controller-store")),
|
||||
client: captured.clone(),
|
||||
controller_abort: abort.clone(),
|
||||
};
|
||||
let execution = Arc::new(
|
||||
worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new(factory).unwrap(),
|
||||
);
|
||||
let mut api = WorkspaceApi::new_with_execution_backend(config, store, execution)
|
||||
.await
|
||||
.unwrap();
|
||||
configure_runtime_request_auth(
|
||||
&mut api,
|
||||
&EMBEDDED_RUNTIME_REQUEST_IDENTITY,
|
||||
EMBEDDED_RUNTIME_ID,
|
||||
);
|
||||
let server_api = api.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, build_router(server_api))
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_RUNTIME_ID.into(),
|
||||
display_name: "Production config adapter".into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:companion".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
|
||||
let client = captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("production embedded factory must inject a WorkspaceClient");
|
||||
assert_config_production_http_adapter(&api, &worker, client).await;
|
||||
abort.lock().unwrap().take().unwrap().abort();
|
||||
server.abort();
|
||||
}
|
||||
@@ -28,7 +28,8 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 78;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 79;
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
@@ -265,6 +266,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME,
|
||||
apply: migrate_workdir_connection_id_v77_to_v78,
|
||||
},
|
||||
Migration {
|
||||
version: 79,
|
||||
name: WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME,
|
||||
apply: migrate_workspace_config_v78_to_v79,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -900,6 +906,9 @@ pub struct WorkdirCreateOperationRecord {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum WorkdirRegistrySource {
|
||||
WorkspaceConfig {
|
||||
grant_id: String,
|
||||
},
|
||||
Repository {
|
||||
runtime_id: String,
|
||||
repository_id: String,
|
||||
@@ -913,20 +922,20 @@ impl WorkdirRegistrySource {
|
||||
pub fn runtime_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Repository { runtime_id, .. } => Some(runtime_id),
|
||||
Self::ExternalGrant { .. } => None,
|
||||
Self::ExternalGrant { .. } | Self::WorkspaceConfig { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repository_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Repository { repository_id, .. } => Some(repository_id),
|
||||
Self::ExternalGrant { .. } => None,
|
||||
Self::ExternalGrant { .. } | Self::WorkspaceConfig { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn external_grant_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Repository { .. } => None,
|
||||
Self::Repository { .. } | Self::WorkspaceConfig { .. } => None,
|
||||
Self::ExternalGrant { grant_id } => Some(grant_id),
|
||||
}
|
||||
}
|
||||
@@ -1921,6 +1930,23 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
) -> Result<WorkdirCatalogPage>;
|
||||
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
|
||||
|
||||
fn create_workspace_config_grant(
|
||||
&self,
|
||||
grant: &server_api::WorkspaceConfigGrantResponse,
|
||||
actor: &str,
|
||||
) -> Result<()>;
|
||||
fn get_workspace_config_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<Option<server_api::WorkspaceConfigGrantResponse>>;
|
||||
fn current_workspace_config_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<Option<server_api::WorkspaceConfigGrantResponse>>;
|
||||
fn revoke_workspace_config_grant(&self, workspace_id: &str, grant_id: &str) -> Result<()>;
|
||||
|
||||
fn create_external_workdir_grant(
|
||||
&self,
|
||||
grant: &ExternalWorkdirGrantRecord,
|
||||
@@ -8849,13 +8875,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
|
||||
fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
let changed = conn.execute(
|
||||
r#"INSERT INTO workdir_registry (
|
||||
workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id,
|
||||
external_grant_id, creation_selector, creation_ref, creation_tree,
|
||||
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
|
||||
materialization_status, cleanliness, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
materialization_status, cleanliness, created_at, updated_at, workspace_config_grant_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
source_kind = excluded.source_kind,
|
||||
@@ -8871,12 +8897,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
observed_at_epoch_seconds = excluded.observed_at_epoch_seconds,
|
||||
materialization_status = excluded.materialization_status,
|
||||
cleanliness = excluded.cleanliness,
|
||||
updated_at = excluded.updated_at"#,
|
||||
updated_at = excluded.updated_at, workspace_config_grant_id = excluded.workspace_config_grant_id
|
||||
WHERE workdir_registry.source_kind != 'workspace_config' OR (excluded.source_kind = 'workspace_config' AND workdir_registry.workspace_config_grant_id = excluded.workspace_config_grant_id)"#,
|
||||
params![
|
||||
record.workspace_id,
|
||||
record.workdir_id,
|
||||
record.display_name,
|
||||
match &record.source { WorkdirRegistrySource::Repository { .. } => "repository", WorkdirRegistrySource::ExternalGrant { .. } => "external_grant" },
|
||||
match &record.source { WorkdirRegistrySource::Repository { .. } => "repository", WorkdirRegistrySource::ExternalGrant { .. } => "external_grant", WorkdirRegistrySource::WorkspaceConfig { .. } => "workspace_config" },
|
||||
record.source.runtime_id(),
|
||||
record.source.repository_id(),
|
||||
record.source.external_grant_id(),
|
||||
@@ -8891,8 +8918,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
record.cleanliness,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
match &record.source { WorkdirRegistrySource::WorkspaceConfig { grant_id } => Some(grant_id.as_str()), _ => None },
|
||||
],
|
||||
)?;
|
||||
if changed == 0 { return Err(Error::WorkspacePermissionDenied("Logical config source identity cannot be replaced by a Runtime observation".into())); }
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -8999,6 +9028,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
|
||||
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
|
||||
if self
|
||||
.get_workdir_registry(workspace_id, workdir_id)?
|
||||
.is_some_and(|record| {
|
||||
matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. })
|
||||
})
|
||||
{
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Logical config Workdir cleanup is unsupported; revoke its grant".into(),
|
||||
));
|
||||
}
|
||||
self.with_conn(|conn| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(
|
||||
conn,
|
||||
@@ -9029,6 +9068,41 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_workspace_config_grant(
|
||||
&self,
|
||||
grant: &server_api::WorkspaceConfigGrantResponse,
|
||||
actor: &str,
|
||||
) -> Result<()> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let active: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')", params![grant.workspace_id, actor], |row|row.get(0))?;
|
||||
if !active { return Err(Error::WorkspacePermissionDenied("Workspace no longer accepts new grants".into())); }
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let access = match grant.access { server_api::WorkspaceConfigAccess::ReadOnly => "read_only", server_api::WorkspaceConfigAccess::ReadWrite => "read_write" };
|
||||
tx.execute("INSERT INTO workspace_config_grants (workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked,created_by,created_at) VALUES (?1,?2,?3,?4,?5,?6,0,?7,?8)", params![grant.workspace_id,grant.grant_id,grant.runtime_id,grant.worker_id,grant.working_directory_id,access,actor,now])?;
|
||||
tx.execute("INSERT INTO workdir_registry (workspace_id,workdir_id,display_name,source_kind,workspace_config_grant_id,materialization_status,cleanliness,created_at,updated_at) VALUES (?1,?2,'Workspace configuration','workspace_config',?3,'present','clean',?4,?4)", params![grant.workspace_id,grant.working_directory_id,grant.grant_id,now])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
fn get_workspace_config_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<Option<server_api::WorkspaceConfigGrantResponse>> {
|
||||
self.with_conn(|conn| conn.query_row("SELECT workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked FROM workspace_config_grants WHERE workspace_id=?1 AND grant_id=?2", params![workspace_id,grant_id], read_workspace_config_grant).optional().map_err(Error::from))
|
||||
}
|
||||
fn current_workspace_config_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<Option<server_api::WorkspaceConfigGrantResponse>> {
|
||||
self.with_conn(|conn| conn.query_row("SELECT workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked FROM workspace_config_grants WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0", params![workspace_id,worker.runtime_id,worker.worker_id], read_workspace_config_grant).optional().map_err(Error::from))
|
||||
}
|
||||
fn revoke_workspace_config_grant(&self, workspace_id: &str, grant_id: &str) -> Result<()> {
|
||||
self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; tx.execute("UPDATE workspace_config_grants SET revoked=1 WHERE workspace_id=?1 AND grant_id=?2", params![workspace_id,grant_id])?; tx.execute("UPDATE workdir_registry SET materialization_status='not_found', updated_at=?3 WHERE workspace_id=?1 AND workspace_config_grant_id=?2", params![workspace_id,grant_id,chrono::Utc::now().to_rfc3339()])?; tx.commit()?; Ok(()) })
|
||||
}
|
||||
|
||||
fn create_external_workdir_grant(
|
||||
&self,
|
||||
grant: &ExternalWorkdirGrantRecord,
|
||||
@@ -11809,6 +11883,7 @@ fn read_workdir_catalog_entry(
|
||||
}
|
||||
None
|
||||
}
|
||||
WorkdirRegistrySource::WorkspaceConfig { .. } => None,
|
||||
WorkdirRegistrySource::ExternalGrant { .. } => {
|
||||
let permissions: Option<String> = row.get(19)?;
|
||||
match permissions.as_deref() {
|
||||
@@ -11861,7 +11936,7 @@ fn read_workdir_catalog_entry(
|
||||
|
||||
fn workdir_registry_select_sql(where_clause: &str) -> String {
|
||||
format!(
|
||||
"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \
|
||||
"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, COALESCE(external_grant_id, workspace_config_grant_id) AS external_grant_id, \
|
||||
creation_selector, creation_ref, creation_tree, \
|
||||
current_selector, current_ref, current_tree, observed_at_epoch_seconds, \
|
||||
materialization_status, cleanliness, created_at, updated_at \
|
||||
@@ -11893,6 +11968,15 @@ fn read_workdir_registry_record(
|
||||
)
|
||||
})?,
|
||||
},
|
||||
"workspace_config" => WorkdirRegistrySource::WorkspaceConfig {
|
||||
grant_id: external_grant_id.clone().ok_or_else(|| {
|
||||
rusqlite::Error::InvalidColumnType(
|
||||
6,
|
||||
"workspace_config_grant_id".into(),
|
||||
rusqlite::types::Type::Null,
|
||||
)
|
||||
})?,
|
||||
},
|
||||
"external_grant" => WorkdirRegistrySource::ExternalGrant {
|
||||
grant_id: external_grant_id.ok_or_else(|| {
|
||||
rusqlite::Error::InvalidColumnType(
|
||||
@@ -14903,6 +14987,91 @@ fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_workspace_config_grant(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<server_api::WorkspaceConfigGrantResponse> {
|
||||
let access: String = row.get(5)?;
|
||||
Ok(server_api::WorkspaceConfigGrantResponse {
|
||||
workspace_id: row.get(0)?,
|
||||
grant_id: row.get(1)?,
|
||||
runtime_id: row.get(2)?,
|
||||
worker_id: row.get(3)?,
|
||||
working_directory_id: row.get(4)?,
|
||||
access: match access.as_str() {
|
||||
"read_only" => server_api::WorkspaceConfigAccess::ReadOnly,
|
||||
"read_write" => server_api::WorkspaceConfigAccess::ReadWrite,
|
||||
_ => {
|
||||
return Err(rusqlite::Error::InvalidColumnType(
|
||||
5,
|
||||
"access".into(),
|
||||
rusqlite::types::Type::Text,
|
||||
));
|
||||
}
|
||||
},
|
||||
revoked: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn migrate_workspace_config_v78_to_v79(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 78 {
|
||||
return Err(Error::Store(
|
||||
"Workspace config migration requires schema 78".into(),
|
||||
));
|
||||
}
|
||||
let foreign_keys =
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
|
||||
let result = (|| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
let dependents = {
|
||||
let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='workdir_registry' AND type IN ('index','trigger') AND sql IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
let schema = include_str!("latest_schema.sql");
|
||||
let start = schema
|
||||
.find("CREATE TABLE \"workdir_registry\"")
|
||||
.ok_or_else(|| Error::Store("Missing Workdir schema".into()))?;
|
||||
let end = schema[start..]
|
||||
.find("CREATE TABLE external_workdir_grants")
|
||||
.ok_or_else(|| Error::Store("Missing Workdir schema end".into()))?
|
||||
+ start;
|
||||
if column_exists(&tx, "workdir_registry", "workspace_config_grant_id")?
|
||||
&& table_exists(&tx, "workspace_config_grants")?
|
||||
{
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?;
|
||||
tx.commit()?;
|
||||
return Ok(());
|
||||
}
|
||||
tx.execute_batch(include_str!("workspace_config_grants.sql"))?;
|
||||
tx.execute_batch(&schema[start..end].replace(
|
||||
"CREATE TABLE \"workdir_registry\"",
|
||||
"CREATE TABLE workdir_registry_v79",
|
||||
))?;
|
||||
tx.execute_batch("INSERT INTO workdir_registry_v79 (workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at) SELECT workspace_id,workdir_id,display_name,source_kind,runtime_id,repository_id,external_grant_id,creation_selector,creation_ref,creation_tree,current_selector,current_ref,current_tree,observed_at_epoch_seconds,materialization_status,cleanliness,created_at,updated_at FROM workdir_registry; DROP TABLE workdir_registry; ALTER TABLE workdir_registry_v79 RENAME TO workdir_registry;")?;
|
||||
for sql in dependents {
|
||||
tx.execute_batch(&sql)?;
|
||||
}
|
||||
let broken: bool = {
|
||||
let mut stmt = tx.prepare("PRAGMA foreign_key_check")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
rows.next()?.is_some()
|
||||
};
|
||||
if broken {
|
||||
return Err(Error::Store(
|
||||
"Workspace config migration foreign-key verification failed".into(),
|
||||
));
|
||||
}
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (79,'Workspace config grants and logical Workdirs')", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
if foreign_keys {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> {
|
||||
use rusqlite::TransactionBehavior;
|
||||
let current = current_schema_version(conn)?;
|
||||
@@ -15787,6 +15956,7 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
include!("store_workspace_config_tests.rs");
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -17535,6 +17705,10 @@ mod tests {
|
||||
version: 78,
|
||||
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 79,
|
||||
name: WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string()
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -17623,6 +17797,7 @@ mod tests {
|
||||
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(78, WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string()),
|
||||
(79, WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -17974,7 +18149,7 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||
73, 74, 75, 76, 77, 78
|
||||
73, 74, 75, 76, 77, 78, 79
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -17983,7 +18158,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 29);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 30);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Included in store::tests. Intentionally preserves the v64/v78 physical column
|
||||
// order, which differs from the latest baseline and must never be copied via '*'.
|
||||
fn workspace_config_v78_fixture() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(r#"
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE __yoi_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL);
|
||||
INSERT INTO __yoi_schema_migrations VALUES(78,'workspace schema baseline');
|
||||
CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);
|
||||
INSERT INTO workspaces VALUES('ws');
|
||||
CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));
|
||||
INSERT INTO repositories VALUES('ws','repo');
|
||||
CREATE TABLE external_workdir_grants(workspace_id TEXT,grant_id TEXT,PRIMARY KEY(workspace_id,grant_id));
|
||||
INSERT INTO external_workdir_grants VALUES('ws','external-grant');
|
||||
CREATE TABLE workdir_registry(
|
||||
workspace_id TEXT NOT NULL,workdir_id TEXT NOT NULL,display_name TEXT,source_kind TEXT NOT NULL,
|
||||
runtime_id TEXT,repository_id TEXT,external_grant_id TEXT,creation_selector TEXT,creation_ref TEXT,
|
||||
creation_tree TEXT,current_selector TEXT,current_ref TEXT,current_tree TEXT,observed_at_epoch_seconds INTEGER,
|
||||
materialization_status TEXT NOT NULL,cleanliness TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,workdir_id));
|
||||
INSERT INTO workdir_registry VALUES('ws','repo-wd','Repository','repository','runtime','repo',NULL,'develop','a','tree-a','work','b','tree-b',123,'present','clean','created','updated');
|
||||
INSERT INTO workdir_registry VALUES('ws','external-wd','External','external_grant',NULL,NULL,'external-grant',NULL,NULL,NULL,NULL,NULL,NULL,NULL,'present','unknown','created-e','updated-e');
|
||||
CREATE INDEX registry_display_test ON workdir_registry(display_name);
|
||||
CREATE TABLE registry_updates(value TEXT);
|
||||
CREATE TRIGGER registry_update_test AFTER UPDATE ON workdir_registry BEGIN INSERT INTO registry_updates VALUES(NEW.display_name); END;
|
||||
CREATE TABLE dependent_links(workspace_id TEXT,workdir_id TEXT,connection_id TEXT,
|
||||
FOREIGN KEY(workspace_id,workdir_id) REFERENCES workdir_registry(workspace_id,workdir_id));
|
||||
INSERT INTO dependent_links VALUES('ws','repo-wd','durable-connection');
|
||||
"#).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_migration_78_to_79_preserves_sources_refs_dependencies_and_constraints() {
|
||||
let conn = workspace_config_v78_fixture();
|
||||
migrate_workspace_config_v78_to_v79(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 79);
|
||||
assert!(
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
let row = conn
|
||||
.query_row(
|
||||
&workdir_registry_select_sql("WHERE workdir_id='repo-wd'"),
|
||||
[],
|
||||
read_workdir_registry_record,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(row.creation_tree.as_deref(), Some("tree-a"));
|
||||
assert_eq!(row.current_tree.as_deref(), Some("tree-b"));
|
||||
assert_eq!(row.current_selector.as_deref(), Some("work"));
|
||||
assert_eq!(row.cleanliness, "clean");
|
||||
assert_eq!(row.created_at, "created");
|
||||
let external = conn
|
||||
.query_row(
|
||||
&workdir_registry_select_sql("WHERE workdir_id='external-wd'"),
|
||||
[],
|
||||
read_workdir_registry_record,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(external.source,WorkdirRegistrySource::ExternalGrant{ref grant_id} if grant_id=="external-grant")
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT connection_id FROM dependent_links", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
"durable-connection"
|
||||
);
|
||||
assert!(
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='registry_display_test')",
|
||||
[],
|
||||
|row| row.get::<_, bool>(0)
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
conn.execute(
|
||||
"UPDATE workdir_registry SET display_name='Updated' WHERE workdir_id='repo-wd'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT value FROM registry_updates", [], |row| row
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"Updated"
|
||||
);
|
||||
conn.execute("INSERT INTO workspace_config_grants VALUES('ws','config-grant','runtime','worker','config-wd','read_only',0,'owner','now')",[]).unwrap();
|
||||
conn.execute("INSERT INTO workdir_registry(workspace_id,workdir_id,source_kind,workspace_config_grant_id,materialization_status,cleanliness,created_at,updated_at) VALUES('ws','config-wd','workspace_config','config-grant','present','clean','now','now')",[]).unwrap();
|
||||
let logical = conn
|
||||
.query_row(
|
||||
&workdir_registry_select_sql("WHERE workdir_id='config-wd'"),
|
||||
[],
|
||||
read_workdir_registry_record,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(logical.source,WorkdirRegistrySource::WorkspaceConfig{ref grant_id} if grant_id=="config-grant")
|
||||
);
|
||||
assert!(conn.execute("INSERT INTO workspace_config_grants VALUES('ws','invalid-grant','r','w','invalid-wd','read_write_command',0,'owner','now')",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO workspace_config_grants VALUES('ws','duplicate','runtime','worker','other-wd','read_write',0,'owner','now')",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO workdir_registry(workspace_id,workdir_id,source_kind,workspace_config_grant_id,runtime_id,materialization_status,cleanliness,created_at,updated_at) VALUES('ws','invalid-wd','workspace_config','config-grant','os-runtime','present','clean','now','now')",[]).is_err());
|
||||
let mut check = conn.prepare("PRAGMA foreign_key_check").unwrap();
|
||||
assert!(check.query([]).unwrap().next().unwrap().is_none());
|
||||
assert!(
|
||||
migrate_workspace_config_v78_to_v79(&conn).is_err(),
|
||||
"wrong-version rerun must not silently mutate data"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_migration_rolls_back_and_restores_foreign_keys_on_invalid_legacy_data() {
|
||||
let conn = workspace_config_v78_fixture();
|
||||
conn.execute(
|
||||
"UPDATE workdir_registry SET repository_id='missing' WHERE workdir_id='repo-wd'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(migrate_workspace_config_v78_to_v79(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 78);
|
||||
assert!(!column_exists(&conn, "workdir_registry", "workspace_config_grant_id").unwrap());
|
||||
assert!(!table_exists(&conn, "workspace_config_grants").unwrap());
|
||||
assert!(
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT repository_id FROM workdir_registry WHERE workdir_id='repo-wd'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"missing"
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT connection_id FROM dependent_links", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
"durable-connection"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE workspace_config_grants (
|
||||
workspace_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
|
||||
revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id, grant_id),
|
||||
UNIQUE(workspace_id, workdir_id),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_config_grants_active_worker
|
||||
ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0;
|
||||
@@ -84,6 +84,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"worker_workdir_attachment_reservations",
|
||||
"worker_workdir_links",
|
||||
"workspace_config_entries",
|
||||
"workspace_config_grants",
|
||||
"workspace_config_tree_revisions",
|
||||
"workspace_config_trees",
|
||||
"workspace_create_operations",
|
||||
@@ -936,6 +937,17 @@ mod tests {
|
||||
) VALUES (?1, 'runtime-a', 'jti-a', 1, '1')",
|
||||
params![workspace_id],
|
||||
)?;
|
||||
for (workspace, grant, workdir, revoked) in [
|
||||
(workspace_id.as_str(), "config-active", "config-active-wd", 0),
|
||||
(workspace_id.as_str(), "config-revoked", "config-revoked-wd", 1),
|
||||
("workspace-b", "other-config", "other-config-wd", 0),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_config_grants (workspace_id,grant_id,runtime_id,worker_id,workdir_id,access,revoked,created_by,created_at)
|
||||
VALUES (?1,?2,'runtime-a','worker-a',?3,'read_only',?4,'owner','1')",
|
||||
params![workspace, grant, workdir, revoked],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect("non-FK scoped audit fixture");
|
||||
@@ -1038,6 +1050,23 @@ mod tests {
|
||||
})
|
||||
.expect("scoped audit read");
|
||||
assert_eq!(proof_count, 0);
|
||||
let grants = store
|
||||
.with_conn(|conn| {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT workspace_id,grant_id FROM workspace_config_grants ORDER BY grant_id",
|
||||
)?;
|
||||
statement
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.expect("config grants purge is Workspace-scoped, including revoked history");
|
||||
assert_eq!(
|
||||
grants,
|
||||
vec![("workspace-b".to_string(), "other-config".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user