diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 5d04e4c6d..0c7177d7a 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -2347,8 +2347,8 @@ impl RuntimeHttpRestError { RuntimeError::WorkerExecutionRejected { result, .. } => startup_transfer(result), _ => None, }; - let message = if startup.is_some() { - "Worker startup failed".to_owned() + let message = if let Some(startup) = &startup { + worker::startup_failure::internal_startup_failure_summary(&startup.primary).to_owned() } else { error.to_string() }; diff --git a/crates/worker/src/startup_failure.rs b/crates/worker/src/startup_failure.rs index b0c4c59fa..9e76a56cf 100644 --- a/crates/worker/src/startup_failure.rs +++ b/crates/worker/src/startup_failure.rs @@ -120,6 +120,20 @@ pub fn reproject_internal_startup_failure( StartupFailureAttachment::from_projected(&projected) } +/// Fixed, identifier-free summary for an already authenticated internal caller. +/// Keep the required operation actionable without exposing the source's Display. +/// This neither projects a Browser cause nor chooses an HTTP status. +pub fn internal_startup_failure_summary(failure: &StartupFailureAttachment) -> &'static str { + if failure + .project_for::(&()) + .is_ok() + { + "Worker startup failed while registering Session (session.record)" + } else { + "Worker startup failed" + } +} + /// Dispatch by the domain attested in process, not by parsing attachment fields. pub(crate) fn persist_binding_startup_failure( store: &S, @@ -249,6 +263,36 @@ mod tests { WorkerStoreError, }; + #[test] + fn internal_summary_retains_only_attested_required_operation() { + use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause}; + let session = protocol::session_failure::session_registration_attachment( + SessionRegistrationCause::denied(SessionPrincipal::Subject), + ) + .unwrap(); + assert_eq!( + internal_startup_failure_summary(&session), + "Worker startup failed while registering Session (session.record)" + ); + let runtime = runtime_startup_attachment(RuntimeStartupCause { + stage: StartupStage::Feature, + reason: RuntimeStartupReason::FeatureFailed, + }) + .unwrap(); + assert_eq!( + internal_startup_failure_summary(&runtime), + "Worker startup failed" + ); + let corrupt = serde_json::from_value(serde_json::json!( + "session.record denied TEST_ONLY_CREDENTIAL /private/path raw_http_body" + )) + .unwrap(); + assert_eq!( + internal_startup_failure_summary(&corrupt), + "Worker startup failed" + ); + } + struct LostSaveResponse(FsWorkerStore); impl WorkerMetadataStore for LostSaveResponse { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 2c1739cba..58e5871de 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -6772,8 +6772,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti ), }; if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error { - if result.startup_failure.is_some() { - projection.message = "Embedded Runtime Worker startup failed".into(); + if let Some(primary) = &result.startup_failure { + projection.message = format!( + "Embedded Runtime: {}", + worker::startup_failure::internal_startup_failure_summary(primary) + ); } projection.startup = result.startup_failure.as_ref().and_then(|primary| { internal_startup_transfer(runtime_api::WorkerStartupFailure { diff --git a/crates/workspace-server/src/plugin_catalog/migration_tests.rs b/crates/workspace-server/src/plugin_catalog/migration_tests.rs index 8b3493178..204034160 100644 --- a/crates/workspace-server/src/plugin_catalog/migration_tests.rs +++ b/crates/workspace-server/src/plugin_catalog/migration_tests.rs @@ -11,7 +11,9 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap(); let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap(); for version in [50, 105] { - let path = dir.path().join(format!("v{version}.db")); + let root = dir.path().join(format!("v{version}")); + std::fs::create_dir(&root).unwrap(); + let path = root.join("workspace.db"); if version == 50 { tests::prepare_schema_v50(&path, Some("workspace-a")); } else { @@ -30,7 +32,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() // Plugin catalog remains migration 106 even when startup applies later steps. assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::>(), vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]); - assert_eq!(after.last().unwrap(), &(113, "Ticket work-decomposition parent links".into())); + assert_eq!(after.last().unwrap(), &(122, crate::config_adoption::MIGRATION_NAME.into())); assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION); assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins"); assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); @@ -83,6 +85,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang )); expected.push((112, "Ticket Service outbox and fenced consumers".into())); expected.push((113, "Ticket work-decomposition parent links".into())); + expected.push((122, crate::config_adoption::MIGRATION_NAME.into())); assert_eq!(workspace_schema_migration_history(c)?, expected); crate::plugin_catalog::verify_with_data(c)?; Ok(()) @@ -101,7 +104,13 @@ fn plugin_catalog_startup_rejects_drifted_reference_fence() { }) .unwrap(); drop(store); - assert!( - matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted")) - ); + match SqliteWorkspaceStore::open_for_test(path) { + Err(Error::Store(message)) => assert!( + message.contains("Server storage format: physical Base mismatch") + && message.contains("plugin_reference_live"), + "{message}" + ), + Err(error) => panic!("unexpected rejection: {error}"), + Ok(_) => panic!("startup accepted a missing reference fence"), + } } diff --git a/crates/workspace-server/src/plugin_plan/migration_tests.rs b/crates/workspace-server/src/plugin_plan/migration_tests.rs index d98ed273d..8a55dbc27 100644 --- a/crates/workspace-server/src/plugin_plan/migration_tests.rs +++ b/crates/workspace-server/src/plugin_plan/migration_tests.rs @@ -41,6 +41,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match )); expected.push((112, "Ticket Service outbox and fenced consumers".into())); expected.push((113, "Ticket work-decomposition parent links".into())); + expected.push((122, crate::config_adoption::MIGRATION_NAME.into())); assert_eq!(workspace_schema_migration_history(conn)?, expected); subject_assignment_migration::verify(conn)?; crate::plugin_plan::verify_plan_schema(conn)?; diff --git a/crates/workspace-server/src/server/tests/value_profiles_tests.rs b/crates/workspace-server/src/server/tests/value_profiles_tests.rs index d6414a8f1..b876c8e7d 100644 --- a/crates/workspace-server/src/server/tests/value_profiles_tests.rs +++ b/crates/workspace-server/src/server/tests/value_profiles_tests.rs @@ -47,33 +47,63 @@ fn source(value: &str) -> String { async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; - let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); - let app = build_router(api.clone()); + let token = seed_test_workspace_owner_token(api.store.as_ref(), "builtin-profile-owner").await; + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); - let commit = format!("{tree}/commit"); - let initial = get_json_authenticated(app.clone(), &tree, &token).await; - let saved = request_json_authenticated( + let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption"); + let commit = format!("{adoption_path}/commit"); + let current = get_json_authenticated(app.clone(), &adoption_path, &token).await; + let initial = current["state"].clone(); + let adoption = |current: &Value, id: &str, source: Value| { + json!({ + "operation_id": id, "expected_head": current["history_head"], + "expected_catalog_digest": current["catalog_digest"], "expected_legacy_export": current["expected_legacy_export"], + "source": source, + }) + }; + let source_request = json!({ + "base_digest": current["state"]["snapshot"]["digest"], + "entrypoints": current["state"]["contract"]["entrypoints"], + "changes": [ + { "kind": "update", "path": "main.dcdl", + "expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"], + "content": source(r#"import "$builtin/profiles/companion.dcdl""#) }, + // A Workspace file at the matching suffix must not rescue an unknown builtin. + { "kind": "create", "path": "profiles/private.dcdl", "content_type": "decodal", "content": PROFILE } + ], + }); + let save = adoption(¤t, "builtin-profile-adopt", source_request); + let preview = request_json_authenticated( + app.clone(), + "POST", + &format!("{adoption_path}/preview"), + Some(save.clone()), + &token, + StatusCode::OK, + ) + .await; + let receipt = request_json_authenticated( app.clone(), "POST", &commit, - Some( - serde_json::to_value(request( - &api, - &source(r#"import "$builtin/profiles/companion.dcdl""#), - vec![config_source::ConfigTreeChange::Create { - // An ordinary Workspace file at the matching suffix must not rescue - // an unknown builtin import. - path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(), - content_type: config_source::ConfigContentType::Decodal, - content: PROFILE.into(), - }], - )) - .unwrap(), - ), + Some(save), &token, - StatusCode::CREATED, + StatusCode::OK, ) .await; + assert_eq!(receipt["state"], preview["state"]); + let saved = receipt["state"].clone(); assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]); let listed = get_json_authenticated(app.clone(), &tree, &token).await; assert_eq!(listed, saved); @@ -107,22 +137,23 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba "base_digest": listed["snapshot"]["digest"], "entrypoints": listed["contract"]["entrypoints"], }); + let current = get_json_authenticated(app.clone(), &adoption_path, &token).await; let reserved = "$builtin/profiles/companion.dcdl"; - for change in [ + for (index, change) in [ json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }), json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }), json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }), json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }), json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }), json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }), - ] { + ].into_iter().enumerate() { let mut body = base.clone(); body["changes"] = json!([change]); let error = request_json_authenticated( app.clone(), "POST", &commit, - Some(body), + Some(adoption(¤t, &format!("reserved-builtin-{index}"), body)), &token, StatusCode::BAD_REQUEST, ) @@ -148,7 +179,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba app.clone(), "POST", &commit, - Some(body), + Some(adoption(¤t, "unknown-builtin-import", body)), &token, StatusCode::BAD_REQUEST, ) diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index 3c54da854..6c5e29a54 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -633,9 +633,12 @@ pub(super) async fn commit( &persistence_api.config.workspace_id, &candidate, ).map_err(|_| unknown())?; + // Repository projection may already have auxiliary secret + // effects. A subsequent save error cannot promise that the + // whole operation had no durable effect, even on SQL rollback. persistence_api.config_store.commit_evaluated_workspace_config( &persistence_api.config.workspace_id, &candidate, - ).map_err(pre_error) + ).map_err(|_| unknown()) })()) }).map_err(|error| match error { subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"), diff --git a/crates/workspace-server/src/server_workspace_config_tests.rs b/crates/workspace-server/src/server_workspace_config_tests.rs index 9bbc39641..9bb15b701 100644 --- a/crates/workspace-server/src/server_workspace_config_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -390,8 +390,10 @@ mod workspace_config_integration { "the granted WIP path remains available" ); - // Real API-token authentication still permits the normal UI editor. - let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + // Real owner API-token authentication still permits the normal editor, + // but only through the head/request-bound adoption boundary. + let token = + seed_test_workspace_owner_token(api.store.as_ref(), "logical-editor-owner").await; for (method, path) in &routes { if *method == "GET" { request_json_authenticated( @@ -409,11 +411,52 @@ mod workspace_config_integration { build_router(api.clone()), "POST", &format!("{tree}/commit"), - Some(serde_json::to_value(change).unwrap()), + Some(serde_json::to_value(&change).unwrap()), &token, - StatusCode::CREATED, + StatusCode::CONFLICT, ) .await; + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let editor = workspace_server_router(catalog); + let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption"); + let current = get_json_authenticated(editor.clone(), &adoption_path, &token).await; + let adoption = json!({ + "operation_id": "logical-editor-owner-adoption", + "expected_head": current["history_head"], + "expected_catalog_digest": current["catalog_digest"], + "expected_legacy_export": current["expected_legacy_export"], + "source": {"base_digest": current["state"]["snapshot"]["digest"], + "entrypoints": current["state"]["contract"]["entrypoints"], "changes": change.changes} + }); + let preview = request_json_authenticated( + editor.clone(), + "POST", + &format!("{adoption_path}/preview"), + Some(adoption.clone()), + &token, + StatusCode::OK, + ) + .await; + let receipt = request_json_authenticated( + editor, + "POST", + &format!("{adoption_path}/commit"), + Some(adoption), + &token, + StatusCode::OK, + ) + .await; + assert_eq!(receipt["state"], preview["state"]); let saved = api .config_store .load_workspace_config(TEST_WORKSPACE_ID)