fix: append Subject migration after retained Plugin plan schema

This commit is contained in:
2026-10-30 01:54:50 +09:00
45 changed files with 5274 additions and 51 deletions
+49
View File
@@ -4093,3 +4093,52 @@ mod tests {
assert_eq!(diagnostics[0].kind, "cycle"); assert_eq!(diagnostics[0].kind, "cycle");
} }
} }
#[cfg(test)]
mod plugin_preflight_tests {
use super::*;
#[test]
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../resources/config-schema/fixtures/plugin-preflight.json"
))
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"plugin-preflight",
"plugins",
"1",
fixture["schema"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let path = VirtualPath::parse("main.dcdl").unwrap();
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
for case in fixture["cases"].as_array().unwrap() {
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
case["source"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
match case.get("diagnostic") {
Some(kind) => {
let errors = result.unwrap_err();
assert_eq!(errors[0].kind, kind.as_str().unwrap());
assert_eq!(errors[0].path.as_str(), "main.dcdl");
}
None => {
let evaluated = result.unwrap();
assert_eq!(
evaluated.projections[0]
.data_json
.pointer("/plugins/enabled/echo/config/mode"),
Some(&case["mode"])
);
}
}
}
}
}
+7 -5
View File
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
/// never starts a local MCP subprocess. /// never starts a local MCP subprocess.
#[serde(default)] #[serde(default)]
pub mcp: McpConfig, pub mcp: McpConfig,
/// Omission retains the lower layer; an explicit section replaces the entire
/// selection list, including an empty list. Never unions Plugin grants.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<crate::PluginProfileConfig>,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfigPartial>, pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`]. /// First-class web tool opt-in. See [`WebConfig`].
@@ -795,11 +799,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
"unknown field in manifest: memory (removed; configure feature.memory)", "unknown field in manifest: memory (removed; configure feature.memory)",
)); ));
} }
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value if value
.get("feature") .get("feature")
.and_then(toml::Value::as_table) .and_then(toml::Value::as_table)
@@ -930,6 +929,7 @@ impl WorkerManifestConfig {
), ),
feature: self.feature.merge(upper.feature), feature: self.feature.merge(upper.feature),
mcp: merge_mcp_config(self.mcp, upper.mcp), mcp: merge_mcp_config(self.mcp, upper.mcp),
plugins: upper.plugins.or(self.plugins),
compaction: merge_option( compaction: merge_option(
self.compaction, self.compaction,
upper.compaction, upper.compaction,
@@ -1445,6 +1445,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
permissions, permissions,
feature: FeatureConfig::from(cfg.feature), feature: FeatureConfig::from(cfg.feature),
mcp: cfg.mcp, mcp: cfg.mcp,
plugins: cfg.plugins.unwrap_or_default(),
compaction, compaction,
web: cfg.web, web: cfg.web,
skills: cfg.skills, skills: cfg.skills,
@@ -1492,6 +1493,7 @@ mod tests {
permissions: None, permissions: None,
feature: FeatureConfigPartial::default(), feature: FeatureConfigPartial::default(),
mcp: McpConfig::default(), mcp: McpConfig::default(),
plugins: None,
session: None, session: None,
compaction: None, compaction: None,
web: None, web: None,
+10 -4
View File
@@ -7,6 +7,7 @@ mod model;
pub mod model_catalog; pub mod model_catalog;
pub mod paths; pub mod paths;
pub mod plugin; pub mod plugin;
mod plugin_profile;
mod profile; mod profile;
mod scope; mod scope;
@@ -24,6 +25,7 @@ pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
}; };
pub use paths::user_profiles_path; pub use paths::user_profiles_path;
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection};
pub use profile::{ pub use profile::{
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot, ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
/// consumer boundary. /// consumer boundary.
#[serde(default)] #[serde(default)]
pub mcp: McpConfig, pub mcp: McpConfig,
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
/// their serialized fingerprint inputs.
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
pub plugins: PluginProfileConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfig>, pub compaction: Option<CompactionConfig>,
/// First-class web tools configuration. Network access remains fail-closed /// First-class web tools configuration. Network access remains fail-closed
@@ -1116,6 +1123,7 @@ impl Default for CompactionConfig {
impl WorkerManifest { impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool { pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some() self.profile.is_some()
|| !self.plugins.is_empty()
|| self.feature.memory.workspace_settings.is_some() || self.feature.memory.workspace_settings.is_some()
|| self.feature.subjektiv.workspace_settings.is_some() || self.feature.subjektiv.workspace_settings.is_some()
} }
@@ -1595,9 +1603,7 @@ model_id = "claude-sonnet-4-20250514"
); );
let error = WorkerManifest::from_toml(&toml).unwrap_err(); let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!( assert!(
error error.to_string().contains("unknown field `enabled`"),
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}" "unexpected error: {error}"
); );
} }
@@ -1617,7 +1623,7 @@ model_id = "claude-sonnet-4-20250514"
}), }),
); );
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err(); let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`")); assert!(error.to_string().contains("unknown field `resolved`"));
let mut nested = base; let mut nested = base;
nested nested
+269
View File
@@ -0,0 +1,269 @@
//! Narrowing requests for Workspace-managed Plugin installations.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Profile input only, not Workspace enablement or a validated execution plan.
///
/// The Workspace owner must resolve each installation against its enabled exact
/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither
/// these names nor config values grant authority, select package paths, or cause
/// discovery, dynamic registration, or guest execution.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileConfig {
#[serde(default)]
pub selections: Vec<PluginProfileSelection>,
}
impl PluginProfileConfig {
pub fn is_empty(&self) -> bool {
self.selections.is_empty()
}
}
/// One installation's explicit subset; omitted surfaces and capabilities are off.
///
/// `tools` and `https_slots` are package-declared names, not public Tool names,
/// URLs, host paths, or connection bindings. `config` contains non-secret override
/// candidates only: the Workspace schema validator must reject them unless the
/// package's closed typed schema permits those overrides. Deserialization alone
/// cannot certify that arbitrary package-specific values are non-secret or valid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileSelection {
pub installation_id: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub logging: bool,
#[serde(default)]
pub https_slots: Vec<String>,
#[serde(default)]
pub config: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ProfileSource, WorkerManifest, WorkerManifestConfig,
read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value,
write_persisted_worker_manifest_snapshot,
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
fn partial(plugins: Value) -> WorkerManifestConfig {
serde_json::from_value(json!({
"worker": { "name": "plugin-worker" },
"plugins": plugins,
}))
.unwrap()
}
fn resolve_profile(plugins: Value) -> crate::ResolvedProfile {
let dir = tempfile::tempdir().unwrap();
resolve_profile_artifact_value(
json!({ "plugins": plugins }),
ProfileSource::Archive {
archive_id: "profile-input".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap()
}
#[test]
fn omitted_plugin_selections_and_grants_default_off() {
let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap();
assert!(
serde_json::to_value(&omitted)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(omitted).unwrap();
assert!(manifest.plugins.selections.is_empty());
assert!(!manifest.requires_persisted_execution_snapshot());
assert!(
serde_json::to_value(manifest)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(partial(json!({
"selections": [{ "installation_id": "installation-a" }]
})))
.unwrap();
let selection = &manifest.plugins.selections[0];
assert!(selection.tools.is_empty());
assert!(!selection.logging);
assert!(selection.https_slots.is_empty());
assert!(selection.config.is_empty());
assert!(manifest.requires_persisted_execution_snapshot());
}
#[test]
fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() {
let lower = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read", "write"],
"logging": true, "https_slots": ["remote"], "config": {"limit": 10}
}] }));
let omitted = lower.clone().merge(WorkerManifestConfig::default());
assert_eq!(omitted.plugins, lower.plugins);
let upper = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"]
}] }));
let merged = lower.clone().merge(upper.clone());
assert_eq!(merged.plugins, upper.plugins);
let round_trip: WorkerManifestConfig =
serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap();
assert_eq!(round_trip.plugins, merged.plugins);
for empty in [json!({}), json!({ "selections": [] })] {
let cleared = lower.clone().merge(partial(empty));
assert_eq!(cleared.plugins, Some(PluginProfileConfig::default()));
let manifest = WorkerManifest::try_from(cleared).unwrap();
assert!(manifest.plugins.selections.is_empty());
}
}
#[test]
fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() {
let dir = tempfile::tempdir().unwrap();
let source = r#"
[[plugins.selections]]
installation_id = "installation-a"
tools = ["lookup"]
logging = true
https_slots = ["api"]
[plugins.selections.config]
limit = 5
labels = ["one", "two"]
[plugins.selections.config.nested]
strict = true
"#;
let parsed = WorkerManifestConfig::from_toml(source).unwrap();
let authored: toml::Value = toml::from_str(source).unwrap();
let resolved = resolve_profile_artifact_value(
serde_json::to_value(authored).unwrap(),
ProfileSource::Archive {
archive_id: "typed-profile".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap();
assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref());
assert_eq!(
resolved.manifest.plugins.selections[0].config["nested"],
json!({"strict": true})
);
let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap();
let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.plugins, resolved.manifest.plugins);
assert_eq!(
write_persisted_worker_manifest_snapshot(&restored).unwrap(),
snapshot
);
assert_eq!(
serde_json::to_value(restored).unwrap(),
resolved.manifest_snapshot
);
let toml_round_trip = toml::to_string(&resolved.manifest).unwrap();
assert_eq!(
WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins,
resolved.manifest.plugins
);
}
#[test]
fn plugin_fields_change_serialized_profile_fingerprint_input() {
let baseline = json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"],
"logging": false, "https_slots": [], "config": {"limit": 5}
}] });
let fingerprint = |plugins| {
let resolved = resolve_profile(plugins);
Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap())
};
let original = fingerprint(baseline.clone());
assert_eq!(fingerprint(baseline.clone()), original);
for (field, value) in [
("installation_id", json!("installation-b")),
("tools", json!(["write"])),
("logging", json!(true)),
("https_slots", json!(["api"])),
("config", json!({"limit": 6})),
] {
let mut changed = baseline.clone();
changed["selections"][0][field] = value;
assert_ne!(fingerprint(changed), original, "{field} was lost");
}
assert_eq!(
fingerprint(json!({})),
fingerprint(json!({"selections": []}))
);
}
#[test]
fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() {
let dir = tempfile::tempdir().unwrap();
let mut invalid = vec![
json!({ "enabled": [{ "id": "project:legacy" }] }),
json!({ "packages": ["/tmp/legacy.yoi-plugin"] }),
json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }),
json!({ "enable": true }),
];
for (field, value) in [
("package_path", json!("/tmp/legacy.yoi-plugin")),
("path", json!("./plugin")),
("url", json!("https://example.invalid/plugin")),
("artifact_digest", json!("sha256:uninstalled")),
("secret", json!("not-a-secret-reference")),
("data", json!(true)),
("events", json!(["changed"])),
("websocket", json!(true)),
("workdir", json!(true)),
("unknown", json!(true)),
] {
let mut selection = json!({ "installation_id": "installation-a" });
selection[field] = value;
invalid.push(json!({ "selections": [selection] }));
}
invalid.extend([
json!({ "selections": [{ "tools": ["read"] }] }),
json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }),
]);
for plugins in invalid {
let partial_input = json!({ "plugins": plugins.clone() });
assert!(
serde_json::from_value::<WorkerManifestConfig>(partial_input.clone()).is_err(),
"{plugins}"
);
let toml = toml::to_string(&partial_input).unwrap();
assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}");
let error = resolve_profile_artifact_value(
partial_input,
ProfileSource::Archive {
archive_id: "invalid".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap_err();
assert!(
error.to_string().contains("failed to decode Profile"),
"{error}"
);
}
}
}
+3
View File
@@ -652,6 +652,7 @@ fn resolve_profile_value(
permissions: profile.permissions, permissions: profile.permissions,
feature: profile.feature, feature: profile.feature,
mcp: profile.mcp, mcp: profile.mcp,
plugins: profile.plugins,
compaction, compaction,
web: profile.web, web: profile.web,
skills: profile.skills, skills: profile.skills,
@@ -700,6 +701,8 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
mcp: McpConfig, mcp: McpConfig,
#[serde(default)] #[serde(default)]
plugins: Option<crate::PluginProfileConfig>,
#[serde(default)]
compaction: Option<serde_json::Value>, compaction: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
web: Option<WebConfig>, web: Option<WebConfig>,
+19
View File
@@ -1213,6 +1213,20 @@ pub trait ServerApi {
#[path] workspace_id: String, #[path] workspace_id: String,
) -> Result<WorkspacePromptProjection, RepositoryApiError>; ) -> Result<WorkspacePromptProjection, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/config/source-tree/preview",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true
)]
async fn workspace_config_tree_preview(
&self,
#[path] workspace_id: String,
#[body] request: ConfigCommitRequest,
) -> Result<WorkspaceConfigTreeResponse, RepositoryApiError>;
#[post( #[post(
"/api/w/{workspace_id}/config/source-tree/commit", "/api/w/{workspace_id}/config/source-tree/commit",
status = 201, status = 201,
@@ -12824,6 +12838,11 @@ mod tests {
HttpMethod::Get, HttpMethod::Get,
"/api/w/{workspace_id}/config/projections/prompts", "/api/w/{workspace_id}/config/projections/prompts",
), ),
(
"workspace_config_tree_preview",
HttpMethod::Post,
"/api/w/{workspace_id}/config/source-tree/preview",
),
( (
"workspace_config_tree_commit", "workspace_config_tree_commit",
HttpMethod::Post, HttpMethod::Post,
+3
View File
@@ -1006,6 +1006,9 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
}), }),
feature: manifest.feature.clone().into(), feature: manifest.feature.clone().into(),
mcp: manifest.mcp.clone(), mcp: manifest.mcp.clone(),
// Reusable selection is not a plan or delegation authority. Backend launch
// must resolve it again within the child's own Workspace/delegation bound.
plugins: Some(manifest.plugins.clone()),
compaction: manifest compaction: manifest
.compaction .compaction
.as_ref() .as_ref()
@@ -510,6 +510,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(106, crate::plugin_catalog::MIGRATION_NAME.into()), (106, crate::plugin_catalog::MIGRATION_NAME.into()),
( (
107, 107,
"Immutable Worker Plugin plans and retained pins".into()
),
(
108,
"Subject Ticket responsibility and current-body notification routing" "Subject Ticket responsibility and current-body notification routing"
.into() .into()
) )
+96 -12
View File
@@ -206,7 +206,18 @@ impl SqliteWorkspaceStore {
let state = match load_state(&tx, workspace_id)? { let state = match load_state(&tx, workspace_id)? {
Some(state) => state, Some(state) => state,
None => { None => {
let state = initial_state_with_schema(schema_bundle.clone())?; let mut state = initial_state_with_schema(schema_bundle.clone())?;
if has_plugin_provider(&schema_bundle) {
let (contract, evaluation, _) =
crate::plugin_settings::evaluate_catalog_config(
&tx,
workspace_id,
&state.snapshot,
schema_bundle.clone(),
)?;
state.contract = contract;
state.projection_digest = evaluation.projection_digest;
}
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
state state
} }
@@ -215,6 +226,10 @@ impl SqliteWorkspaceStore {
tx.commit()?; tx.commit()?;
Ok((state, requires_toolchain_refresh, base_identity)) Ok((state, requires_toolchain_refresh, base_identity))
})?; })?;
let desired_schema = crate::plugin_settings::materialization_schema(
desired_schema,
&state.contract.schema_bundle,
)?;
let main_needs_normalization = state let main_needs_normalization = state
.snapshot .snapshot
.get(&main_config_path()) .get(&main_config_path())
@@ -223,7 +238,17 @@ impl SqliteWorkspaceStore {
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint || state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|| main_needs_normalization || main_needs_normalization
{ {
let candidate = evaluate_candidate(state, base_identity, &[], desired_schema)?; let candidate = self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
evaluate_candidate_in_transaction(
&tx,
workspace_id,
state,
base_identity,
&[],
desired_schema,
)
})?;
return self.commit_evaluated_workspace_config(workspace_id, &candidate); return self.commit_evaluated_workspace_config(workspace_id, &candidate);
} }
Ok(state) Ok(state)
@@ -277,18 +302,23 @@ impl SqliteWorkspaceStore {
request: &ConfigCommitRequest, request: &ConfigCommitRequest,
schema_bundle: WorkspaceConfigSchemaBundle, schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
let (current, base_identity) = self.with_conn(|conn| { self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?; let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?;
let identity = load_evaluation_identity(&tx, workspace_id)?; let base_identity = load_evaluation_identity(&tx, workspace_id)?;
tx.commit()?; validate_entrypoint_request(&request.entrypoints)?;
Ok((current, identity)) if current.snapshot.digest != request.base_digest {
})?; return Err(config_conflict("base content digest mismatch"));
validate_entrypoint_request(&request.entrypoints)?; }
if current.snapshot.digest != request.base_digest { evaluate_candidate_in_transaction(
return Err(config_conflict("base content digest mismatch")); &tx,
} workspace_id,
evaluate_candidate(current, base_identity, &request.changes, schema_bundle) current,
base_identity,
&request.changes,
schema_bundle,
)
})
} }
pub fn evaluate_workspace_config_candidate( pub fn evaluate_workspace_config_candidate(
@@ -328,8 +358,28 @@ impl SqliteWorkspaceStore {
{ {
return Err(config_conflict("base evaluation identity mismatch")); return Err(config_conflict("base evaluation identity mismatch"));
} }
if has_plugin_provider(&current.contract.schema_bundle)
&& !has_plugin_provider(&candidate.contract.schema_bundle)
{
return Err(config_conflict("Plugin schema authority cannot be removed"));
}
let snapshot = candidate.snapshot.clone(); let snapshot = candidate.snapshot.clone();
snapshot.validate().map_err(config_error)?; snapshot.validate().map_err(config_error)?;
if has_plugin_provider(&candidate.contract.schema_bundle) {
let (contract, evaluation, resolved) =
crate::plugin_settings::evaluate_catalog_config(
&tx,
workspace_id,
&snapshot,
candidate.contract.schema_bundle.clone(),
)?;
if contract != candidate.contract || evaluation != candidate.evaluation {
return Err(config_conflict(
"Plugin catalog/schema evaluation changed since Preview",
));
}
crate::plugin_settings::sync_config_references(&tx, workspace_id, &resolved)?;
}
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
tx.execute( tx.execute(
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1", "DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
@@ -452,6 +502,40 @@ fn load_evaluation_identity(
.map_err(Error::from) .map_err(Error::from)
} }
fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
bundle
.contributions
.iter()
.any(|provider| provider.provider_id == "builtin:plugins")
}
fn evaluate_candidate_in_transaction(
tx: &rusqlite::Transaction<'_>,
workspace_id: &str,
current: WorkspaceConfigState,
base_identity: (String, String),
changes: &[ConfigTreeChange],
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> {
if !has_plugin_provider(&schema_bundle) {
return evaluate_candidate(current, base_identity, changes, schema_bundle);
}
let mut providers = schema_bundle.contributions.clone();
providers.retain(|provider| provider.provider_id != "builtin:plugins");
providers.push(crate::plugin_settings::PluginConfigSchemaProvider.contribution()?);
let bootstrap = WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?;
let mut candidate = evaluate_candidate(current, base_identity, changes, bootstrap)?;
let (contract, evaluation, _) = crate::plugin_settings::evaluate_catalog_config(
tx,
workspace_id,
&candidate.snapshot,
schema_bundle,
)?;
candidate.contract = contract;
candidate.evaluation = evaluation;
Ok(candidate)
}
fn evaluate_candidate( fn evaluate_candidate(
current: WorkspaceConfigState, current: WorkspaceConfigState,
base_identity: (String, String), base_identity: (String, String),
+3
View File
@@ -12,7 +12,10 @@ pub mod hosts;
pub mod memory_backend; pub mod memory_backend;
pub mod memory_staging; pub mod memory_staging;
pub mod observation; pub mod observation;
pub mod plugin_binding;
pub mod plugin_catalog; pub mod plugin_catalog;
pub mod plugin_plan;
pub mod plugin_settings;
pub mod profile_settings; pub mod profile_settings;
pub mod prompt_settings; pub mod prompt_settings;
pub mod records; pub mod records;
@@ -0,0 +1,371 @@
//! Static Workspace HTTPS policy, not network admission. Runtime must recheck live
//! binding state, resolve/check every DNS address, pin the connection, verify TLS,
//! inject credentials, and enforce these bounds (including decompressed bytes).
//! Plans retain the binding identity and digest, never this destination policy.
use std::collections::{BTreeMap, BTreeSet};
use std::net::{Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
use server_api::PluginHttpMethod;
use sha2::{Digest, Sha256};
use url::{Host, Url};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PluginNetworkPolicy {
/// DNS names are provisional until all resolved addresses pass live checks.
/// Private-network exceptions are deliberately not supported by this policy.
#[default]
PublicInternet,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionLimits {
pub timeout_ms: u32,
pub max_request_bytes: u32,
pub max_response_bytes: u32,
pub max_header_bytes: u32,
}
/// Trusted server operational ceiling, supplied separately from Workspace input.
/// Deserializing a binding cannot set or expand this ceiling.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginConnectionPolicyCeiling {
pub limits: PluginConnectionLimits,
}
impl Default for PluginConnectionPolicyCeiling {
fn default() -> Self {
Self {
limits: PluginConnectionLimits {
timeout_ms: 15_000,
max_request_bytes: 1024 * 1024,
max_response_bytes: 1024 * 1024,
max_header_bytes: 16 * 1024,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionBinding {
/// Canonical origin only: no trailing slash, path, query, credentials or fragment.
pub origin: String,
#[serde(default)]
pub network: PluginNetworkPolicy,
pub methods: Vec<PluginHttpMethod>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub request_headers: Vec<String>,
#[serde(default)]
pub response_headers: Vec<String>,
/// Empty means no credential. Otherwise an opaque Workspace-local secret ID;
/// never a value, token, provider URL, or filesystem reference.
#[serde(default)]
pub secret_ref: String,
pub limits: PluginConnectionLimits,
}
/// Safe diagnostic categories contain no caller-supplied URLs, headers or secrets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PluginBindingError {
#[error("invalid plugin binding identity")]
InvalidIdentity,
#[error("plugin connection requires an exact canonical HTTPS origin")]
InvalidOrigin,
#[error("plugin connection destination is not public internet")]
NonPublicDestination,
#[error("plugin connection methods must be nonempty and unique")]
InvalidMethods,
#[error("plugin connection paths must be nonempty, unique canonical segment prefixes")]
InvalidPaths,
#[error("plugin connection header allowlist is invalid or contains a reserved header")]
InvalidHeaders,
#[error("plugin connection secret must be an opaque reference")]
InvalidSecretReference,
#[error("plugin connection limits must be positive and within the server ceiling")]
InvalidLimits,
#[error("too many plugin bindings")]
TooManyBindings,
}
impl PluginConnectionBinding {
pub fn validate(
&self,
ceiling: &PluginConnectionPolicyCeiling,
) -> Result<(), PluginBindingError> {
validate_origin(&self.origin)?;
if self.methods.is_empty() || self.methods.len() > 7 {
return Err(PluginBindingError::InvalidMethods);
}
let mut methods = BTreeSet::new();
if self
.methods
.iter()
.any(|method| !methods.insert(method_name(method)))
{
return Err(PluginBindingError::InvalidMethods);
}
if self.path_prefixes.is_empty()
|| self.path_prefixes.len() > 64
|| !unique_strings(&self.path_prefixes)
|| self.path_prefixes.iter().any(|path| !canonical_path(path))
{
return Err(PluginBindingError::InvalidPaths);
}
for headers in [&self.request_headers, &self.response_headers] {
if headers.len() > 64
|| !unique_strings(headers)
|| headers.iter().any(|header| !allowed_header(header))
{
return Err(PluginBindingError::InvalidHeaders);
}
}
if !self.secret_ref.is_empty() && !opaque_reference(&self.secret_ref) {
return Err(PluginBindingError::InvalidSecretReference);
}
for (value, maximum) in [
(self.limits.timeout_ms, ceiling.limits.timeout_ms),
(
self.limits.max_request_bytes,
ceiling.limits.max_request_bytes,
),
(
self.limits.max_response_bytes,
ceiling.limits.max_response_bytes,
),
(
self.limits.max_header_bytes,
ceiling.limits.max_header_bytes,
),
] {
if value == 0 || maximum == 0 || value > maximum {
return Err(PluginBindingError::InvalidLimits);
}
}
Ok(())
}
/// Versioned SHA-256 of policy content. Allowlists have set semantics, so their
/// order does not affect identity. Call `validate` before accepting a digest.
/// Binding map identity is separate and must also be pinned by the caller.
pub fn digest(&self) -> String {
let mut canonical = self.clone();
canonical.methods.sort_by_key(method_name);
canonical.path_prefixes.sort();
canonical.request_headers.sort();
canonical.response_headers.sort();
let bytes = serde_json::to_vec(&canonical).expect("binding contains only JSON primitives");
let mut hash = Sha256::new();
hash.update(b"yoi-plugin-connection-policy-v1\0");
hash.update(bytes);
let hex: String = hash
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{hex}")
}
/// Static segment-boundary matching only; this is not runtime authorization.
/// Conservative paths exclude percent encodings to avoid decoder disagreement.
pub fn allows_path(&self, path: &str) -> bool {
self.path_prefixes
.iter()
.any(|prefix| path_within(prefix, path))
}
}
/// Validates the Workspace-owned map without copying any destination into a plan.
/// The map key is the binding identity; pin that key and `binding.digest()` only.
pub fn validate_bindings(
bindings: &BTreeMap<String, PluginConnectionBinding>,
ceiling: &PluginConnectionPolicyCeiling,
) -> Result<(), PluginBindingError> {
if bindings.len() > 256 {
return Err(PluginBindingError::TooManyBindings);
}
for (identity, binding) in bindings {
if !opaque_reference(identity) {
return Err(PluginBindingError::InvalidIdentity);
}
binding.validate(ceiling)?;
}
Ok(())
}
/// Conservative canonical path containment on segment boundaries, not a raw
/// string prefix. Runtime must use the same canonical path for checking/sending.
pub fn path_within(prefix: &str, path: &str) -> bool {
canonical_path(prefix)
&& canonical_path(path)
&& (prefix == "/"
|| path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/')))
}
fn method_name(method: &PluginHttpMethod) -> &'static str {
match method {
PluginHttpMethod::Get => "GET",
PluginHttpMethod::Head => "HEAD",
PluginHttpMethod::Post => "POST",
PluginHttpMethod::Put => "PUT",
PluginHttpMethod::Patch => "PATCH",
PluginHttpMethod::Delete => "DELETE",
PluginHttpMethod::Options => "OPTIONS",
}
}
fn opaque_reference(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
}
fn unique_strings(values: &[String]) -> bool {
values.iter().collect::<BTreeSet<_>>().len() == values.len()
}
fn canonical_path(path: &str) -> bool {
if path == "/" {
return true;
}
path.len() <= 2048
&& path.starts_with('/')
&& path.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'/' | b'-'
| b'_'
| b'.'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b':'
| b'@'
)
})
&& path[1..]
.split('/')
.all(|part| !matches!(part, "" | "." | ".."))
}
fn allowed_header(header: &str) -> bool {
!header.is_empty()
&& header.len() <= 128
&& header
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !header.starts_with("proxy-")
&& !header.starts_with("sec-")
&& !matches!(
header,
"authorization"
| "www-authenticate"
| "cookie"
| "set-cookie"
| "host"
| "connection"
| "keep-alive"
| "transfer-encoding"
| "te"
| "trailer"
| "upgrade"
| "content-length"
| "expect"
| "forwarded"
| "via"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-api-key"
| "api-key"
)
}
fn validate_origin(origin: &str) -> Result<(), PluginBindingError> {
if origin.len() > 512 || !origin.is_ascii() {
return Err(PluginBindingError::InvalidOrigin);
}
let url = Url::parse(origin).map_err(|_| PluginBindingError::InvalidOrigin)?;
if url.scheme() != "https" || url.origin().ascii_serialization() != origin {
return Err(PluginBindingError::InvalidOrigin);
}
let public = match url.host().ok_or(PluginBindingError::InvalidOrigin)? {
Host::Domain(host) => {
let labels: Vec<_> = host.split('.').collect();
labels.len() >= 2
&& host.len() <= 253
&& labels.iter().all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.as_bytes()[0].is_ascii_alphanumeric()
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
})
&& ![
"localhost",
"local",
"internal",
"home",
"lan",
"test",
"invalid",
"onion",
"arpa",
]
.iter()
.any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}")))
}
Host::Ipv4(ip) => public_ipv4(ip),
Host::Ipv6(ip) => public_ipv6(ip),
};
if !public {
return Err(PluginBindingError::NonPublicDestination);
}
Ok(())
}
fn public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
// Conservative special-purpose exclusion; no Workspace-configurable bypass.
!matches!(a, 0 | 10 | 127 | 224..=255)
&& !(a == 100 && (64..=127).contains(&b))
&& !(a == 169 && b == 254)
&& !(a == 172 && (16..=31).contains(&b))
&& !(a == 192 && (b == 0 || b == 168 || (b == 88 && c == 99)))
&& !(a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100)))
&& !(a == 203 && b == 0 && c == 113)
}
fn public_ipv6(ip: Ipv6Addr) -> bool {
let [a, b, ..] = ip.segments();
// Only current global-unicast space; reject transition/special-purpose and
// documentation ranges as well as mapped IPv4, local, multicast and ULA.
(0x2000..=0x3fff).contains(&a)
&& !(a == 0x2001 && (b <= 0x01ff || b == 0x0db8))
&& a != 0x2002
&& !(a == 0x3fff && b <= 0x0fff)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,529 @@
use super::*;
use serde_json::{Value, json};
fn input() -> Value {
json!({
"origin": "https://api.example.com",
"methods": ["GET", "POST"],
"path_prefixes": ["/api", "/v2"],
"request_headers": ["accept", "content-type"],
"response_headers": ["content-type", "etag"],
"secret_ref": "secret-123",
"limits": {
"timeout_ms": 1000,
"max_request_bytes": 1024,
"max_response_bytes": 4096,
"max_header_bytes": 512
}
})
}
fn binding(value: Value) -> PluginConnectionBinding {
serde_json::from_value(value).unwrap()
}
#[test]
fn origin_input_table_requires_canonical_exact_https_and_public_destination() {
for (origin, expected) in [
("https://api.example.com", Ok(())),
("https://api.example.com:8443", Ok(())),
("https://8.8.8.8", Ok(())),
("https://[2606:4700:4700::1111]", Ok(())),
(
"http://api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com/",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com/api",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com?token=value",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com#fragment",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://user:password@api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://@api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com:443",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://API.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com\\evil",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com\n",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://*.example.com",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://localhost",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://metadata.google.internal",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://service.local",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://example.com.",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://127.0.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://10.0.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://172.16.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://192.168.1.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://169.254.169.254",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://100.64.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://192.0.2.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://198.51.100.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://203.0.113.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://224.1.2.3",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://0.0.0.0",
Err(PluginBindingError::NonPublicDestination),
),
("https://2130706433", Err(PluginBindingError::InvalidOrigin)),
("https://0177.0.0.1", Err(PluginBindingError::InvalidOrigin)),
(
"https://[::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[::ffff:7f00:1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[fd00::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[fe80::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[2001:db8::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[2002:7f00:1::]",
Err(PluginBindingError::NonPublicDestination),
),
] {
let mut value = input();
value["origin"] = json!(origin);
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
expected,
"{origin}"
);
}
}
#[test]
fn path_input_table_rejects_traversal_and_preserves_segment_boundaries() {
for (path, accepted) in [
("/", true),
("/api", true),
("/api/v1", true),
("api", false),
("//evil.example", false),
("/api/", false),
("/api//v1", false),
("/api/../admin", false),
("/./api", false),
("/api\\admin", false),
("/api?x=1", false),
("/api#x", false),
("/api/%2e%2e/admin", false),
("/api%2fadmin", false),
("/api/%252e%252e", false),
("/api/%5Cadmin", false),
("https://evil.example/api", false),
("/api/\n", false),
] {
let mut value = input();
value["path_prefixes"] = json!([path]);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{path}"
);
}
let value = binding(input());
for (path, allowed) in [
("/api", true),
("/api/v1", true),
("/apix", false),
("/admin", false),
("/api/../admin", false),
] {
assert_eq!(value.allows_path(path), allowed, "{path}");
}
for paths in [json!([]), json!(["/api", "/api"])] {
let mut value = input();
value["path_prefixes"] = paths;
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidPaths)
);
}
}
#[test]
fn header_input_table_rejects_guest_credentials_and_transport_controls() {
for field in ["request_headers", "response_headers"] {
for (name, accepted) in [
("accept", true),
("content-type", true),
("x-request-id", true),
("authorization", false),
("cookie", false),
("set-cookie", false),
("host", false),
("proxy-authorization", false),
("connection", false),
("content-length", false),
("transfer-encoding", false),
("upgrade", false),
("te", false),
("trailer", false),
("keep-alive", false),
("sec-websocket-key", false),
("x-api-key", false),
("Content-Type", false),
("x-a\r\n", false),
("x-a: value", false),
("", false),
] {
let mut value = input();
value[field] = json!([name]);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{field}:{name}"
);
}
let mut value = input();
value[field] = json!(["accept", "accept"]);
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidHeaders)
);
}
}
#[test]
fn limits_input_table_requires_positive_finite_values_within_each_server_bound() {
let ceiling = PluginConnectionPolicyCeiling::default();
for (field, max) in [
("timeout_ms", ceiling.limits.timeout_ms),
("max_request_bytes", ceiling.limits.max_request_bytes),
("max_response_bytes", ceiling.limits.max_response_bytes),
("max_header_bytes", ceiling.limits.max_header_bytes),
] {
for (amount, accepted) in [(0, false), (1, true), (max, true), (max + 1, false)] {
let mut value = input();
value["limits"][field] = json!(amount);
assert_eq!(
binding(value).validate(&ceiling).is_ok(),
accepted,
"{field}:{amount}"
);
}
for invalid in [
json!(-1),
json!(1.5),
json!(4294967296u64),
json!("unlimited"),
Value::Null,
] {
let mut value = input();
value["limits"][field] = invalid;
assert!(
serde_json::from_value::<PluginConnectionBinding>(value).is_err(),
"{field}"
);
}
}
let mut zero_ceiling = ceiling;
zero_ceiling.limits.timeout_ms = 0;
assert_eq!(
binding(input()).validate(&zero_ceiling),
Err(PluginBindingError::InvalidLimits)
);
}
#[test]
fn secret_and_identity_inputs_accept_only_opaque_workspace_references() {
for (reference, accepted) in [
("", true),
("secret_123-a", true),
("/etc/secret", false),
("file:///etc/secret", false),
("https://secret.example/token", false),
("Bearer token", false),
("env:API_KEY", false),
("../secret", false),
] {
let mut value = input();
value["secret_ref"] = json!(reference);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{reference}"
);
}
for (identity, accepted) in [
("api-binding", true),
("", false),
("../binding", false),
("https://example.com", false),
] {
let bindings = BTreeMap::from([(identity.to_string(), binding(input()))]);
assert_eq!(
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).is_ok(),
accepted
);
}
}
#[test]
fn json_boundary_is_closed_and_methods_network_are_not_ambient_authority() {
for (field, value) in [
("secret_value", json!("password")),
("token", json!("token")),
("proxy", json!("https://proxy.example")),
("host_path", json!("/tmp")),
("tls_verify", json!(false)),
("allow_private", json!(true)),
("network", json!("private")),
("methods", json!(["CONNECT"])),
] {
let mut source = input();
source[field] = value;
assert!(
serde_json::from_value::<PluginConnectionBinding>(source).is_err(),
"{field}"
);
}
for methods in [json!([]), json!(["GET", "GET"])] {
let mut source = input();
source["methods"] = methods;
assert_eq!(
binding(source).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidMethods)
);
}
let mut source = input();
source["limits"]["unlimited"] = json!(true);
assert!(serde_json::from_value::<PluginConnectionBinding>(source).is_err());
}
#[test]
fn policy_digest_is_order_independent_and_covers_every_policy_field() {
let original = binding(input());
let mut reordered = original.clone();
reordered.methods.reverse();
reordered.path_prefixes.reverse();
reordered.request_headers.reverse();
reordered.response_headers.reverse();
assert_eq!(original.digest(), reordered.digest());
assert_eq!(
original.digest(),
"sha256:df6bd126eb6e825349a3c2e40f48f9d18732969a0775fe6f1b7f11b1667869c9"
);
assert_eq!(original.digest().len(), 71);
assert!(original.digest().starts_with("sha256:"));
for (field, value) in [
("origin", json!("https://other.example.com")),
("methods", json!(["GET"])),
("path_prefixes", json!(["/v2"])),
("request_headers", json!([])),
("response_headers", json!([])),
("secret_ref", json!("other-secret")),
] {
let mut source = input();
source[field] = value;
assert_ne!(original.digest(), binding(source).digest(), "{field}");
}
for field in [
"timeout_ms",
"max_request_bytes",
"max_response_bytes",
"max_header_bytes",
] {
let mut source = input();
source["limits"][field] = json!(2);
assert_ne!(original.digest(), binding(source).digest(), "{field}");
}
}
#[test]
fn policy_collections_and_references_have_finite_size_bounds() {
let ceiling = PluginConnectionPolicyCeiling::default();
for (count, accepted) in [(64, true), (65, false)] {
for field in ["path_prefixes", "request_headers", "response_headers"] {
let mut source = input();
source[field] = json!(
(0..count)
.map(|n| {
if field == "path_prefixes" {
format!("/path-{n}")
} else {
format!("x-header-{n}")
}
})
.collect::<Vec<_>>()
);
assert_eq!(
binding(source).validate(&ceiling).is_ok(),
accepted,
"{field}:{count}"
);
}
}
for (count, accepted) in [(256, true), (257, false)] {
let bindings = (0..count)
.map(|n| (format!("binding-{n}"), binding(input())))
.collect();
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
}
for (size, accepted) in [(128, true), (129, false)] {
let mut source = input();
source["secret_ref"] = json!("a".repeat(size));
assert_eq!(binding(source).validate(&ceiling).is_ok(), accepted);
let bindings = BTreeMap::from([("a".repeat(size), binding(input()))]);
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
}
}
fn evaluate_schema(source: &str) -> Result<Value, String> {
use config_source::{
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeSnapshot,
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
};
let path = VirtualPath::parse("main.dcdl").unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
source,
)
.unwrap()])
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:plugins",
"plugins",
"1",
include_str!("../../../../resources/config-schema/plugins.dcdl"),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(
&ToolchainContract::with_schema_bundle(1, vec![path], 1, bundle),
);
result
.map(|evaluated| evaluated.projections[0].data_json.clone())
.map_err(|error| format!("{error:?}"))
}
#[test]
fn bootstrap_schema_defaults_maps_and_projects_typed_binding_to_rust() {
assert_eq!(
evaluate_schema("{}").unwrap(),
json!({"plugins": {"enabled": {}, "bindings": {}}})
);
let projected = evaluate_schema(r#"{
plugins = {
enabled = { demo = {
installation_id = "plugin-1"; expected_last_operation = "op-1";
package_digest = "package"; artifact_digest = "artifact"; tools = ["query"];
}; };
bindings = { api = {
origin = "https://api.example.com"; methods = ["GET"]; path_prefixes = ["/api"];
limits = { timeout_ms = 1000; max_request_bytes = 1024; max_response_bytes = 4096; max_header_bytes = 512; };
}; };
};
}"#).unwrap();
assert_eq!(projected["plugins"]["enabled"]["demo"]["logging"], false);
assert_eq!(
projected["plugins"]["enabled"]["demo"]["connections"],
json!({})
);
assert_eq!(projected["plugins"]["enabled"]["demo"]["config"], json!({}));
let bindings: BTreeMap<String, PluginConnectionBinding> =
serde_json::from_value(projected["plugins"]["bindings"].clone()).unwrap();
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).unwrap();
}
#[test]
fn bootstrap_schema_rejects_unknown_fields_and_wrong_binding_shapes() {
for source in [
"{ plugins = { token = \"value\"; }; }",
"{ plugins = { bindings = { api = { origin = 123; }; }; }; }",
"{ plugins = { enabled = { demo = { arbitrary = true; }; }; }; }",
"{ plugins = { bindings = { api = \"https://api.example.com\"; }; }; }",
] {
assert!(evaluate_schema(source).is_err(), "{source}");
}
}
@@ -30,7 +30,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
// Plugin catalog remains migration 106 even when startup applies later steps. // Plugin catalog remains migration 106 even when startup applies later steps.
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(), assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]); vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
assert_eq!(after.last().unwrap(), &(107, "Subject Ticket responsibility and current-body notification routing".into())); assert_eq!(after.last().unwrap(), &(108, "Subject Ticket responsibility and current-body notification routing".into()));
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION); 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 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); assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
@@ -66,6 +66,10 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into())); expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
expected.push(( expected.push((
107, 107,
"Immutable Worker Plugin plans and retained pins".into(),
));
expected.push((
108,
"Subject Ticket responsibility and current-body notification routing".into(), "Subject Ticket responsibility and current-body notification routing".into(),
)); ));
assert_eq!(workspace_schema_migration_history(c)?, expected); assert_eq!(workspace_schema_migration_history(c)?, expected);
+419
View File
@@ -0,0 +1,419 @@
//! Immutable, Server-owned Plugin execution snapshots and same-database pins.
//!
//! The config owner supplies an already validated, non-secret projection; this
//! module never reads config, selects a Profile, fetches packages or resolves
//! credentials. Host-bound execution identities are trusted inputs, not caller
//! authentication. Service entrypoints must authenticate/authorize every call,
//! including replay and reads. A saved plan is an upper bound, not live authority.
//!
//! Owner transaction functions compose with Worker creation in the catalog DB.
//! Their local savepoints also roll back partial pin acquisition if an owner
//! catches an error and commits other work. There is no cross-store compensation.
use crate::{Error, Result, plugin_catalog::PluginCatalogError};
use manifest::plugin;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use server_api::{PluginArtifact, PluginGrantMetadata};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
mod store;
pub use store::*;
/// Included by the config owner's ordered database migration, never at runtime.
pub const SCHEMA_SQL: &str = include_str!("plugin_plan/schema.sql");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigProvenance {
pub content_digest: String,
pub toolchain_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileProvenance {
pub identity: String,
pub projection_digest: String,
}
/// Supplied by the authenticated Host launch/delegation boundary, never by guest
/// or model input. These are identities only, not credentials or provider handles.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostExecutionIdentity {
pub runtime_id: String,
pub worker_id: String,
pub principal_id: String,
pub delegation_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BindingPolicyReference {
pub slot: String,
pub binding_id: String,
pub policy_digest: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginSurface {
Tool,
}
/// Only capabilities implemented by the initial Tool Host are representable.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginGrant {
Logging,
Https { slot: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginSchemaIdentity {
pub identity: String,
pub version: String,
pub digest: String,
pub namespace: String,
}
/// The upstream config/schema authority must validate config values as non-secret
/// and intersect grants/surfaces with Workspace, Profile and delegation bounds.
/// Binding policies remain with their owner: only identities and digests travel.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginPinProjection {
pub config: Value,
pub surfaces: Vec<PluginSurface>,
pub tools: Vec<String>,
pub grants: Vec<PluginGrant>,
pub bindings: Vec<BindingPolicyReference>,
}
/// All fields are private: construct from admitted artifact metadata, not a
/// hand-built wire snapshot. Deserialization alone does not establish admission.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPin {
installation_id: String,
expected_last_operation: String,
package_id: String,
package_version: String,
package_digest: String,
artifact_digest: String,
artifact_size: u32,
package_schema_version: u32,
component_world: String,
required_imports: Vec<String>,
config_schema: PluginSchemaIdentity,
config_digest: String,
projection: PluginPinProjection,
}
impl WorkerPluginPin {
pub fn from_artifact(
installation_id: String,
expected_last_operation: String,
artifact: &PluginArtifact,
mut projection: PluginPinProjection,
) -> Result<Self> {
if artifact.revoked {
return conflict();
}
if !valid_identity(&installation_id)
|| !valid_identity(&expected_last_operation)
|| !valid_digest(&artifact.package_digest)
|| !valid_digest(&artifact.artifact_digest)
|| artifact.artifact_size == 0
|| artifact.schema_version != plugin::SUPPORTED_PLUGIN_API_VERSION
|| !matches!(
artifact.world.as_str(),
plugin::PLUGIN_COMPONENT_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
)
|| !valid_digest(&artifact.config_schema.digest)
|| !valid_identity(&artifact.package_id)
|| !valid_identity(&artifact.version)
|| !valid_identity(&artifact.config_schema.identity)
|| !valid_identity(&artifact.config_schema.version)
|| !valid_identity(&artifact.config_schema.namespace)
|| !projection.config.is_object()
{
return invalid();
}
projection.surfaces.sort();
projection.surfaces.dedup();
projection.tools.sort();
projection.tools.dedup();
projection.grants.sort();
projection.grants.dedup();
projection.bindings.sort();
projection.config = canonical_value(&projection.config);
if (!projection.tools.is_empty() && !projection.surfaces.contains(&PluginSurface::Tool))
|| projection.tools.iter().any(|name| {
!valid_identity(name) || !artifact.tools.iter().any(|tool| tool.name == *name)
})
|| projection
.bindings
.windows(2)
.any(|b| b[0].slot == b[1].slot)
|| projection.bindings.iter().any(|binding| {
!valid_identity(&binding.slot)
|| !valid_identity(&binding.binding_id)
|| !valid_digest(&binding.policy_digest)
|| !artifact
.connections
.iter()
.any(|slot| slot.name == binding.slot)
})
|| projection.grants.iter().any(|grant| match grant {
PluginGrant::Logging => !artifact
.requested_grants
.contains(&PluginGrantMetadata::Logging),
PluginGrant::Https { slot } => {
!valid_identity(slot)
|| !artifact
.requested_grants
.contains(&PluginGrantMetadata::Https { slot: slot.clone() })
|| !projection
.bindings
.iter()
.any(|binding| binding.slot == *slot)
}
})
|| projection.bindings.iter().any(|binding| {
!projection.grants.contains(&PluginGrant::Https {
slot: binding.slot.clone(),
})
})
{
return invalid();
}
let mut required_imports = artifact.required_imports.clone();
required_imports.sort();
required_imports.dedup();
Ok(Self {
installation_id,
expected_last_operation,
package_id: artifact.package_id.clone(),
package_version: artifact.version.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size,
package_schema_version: artifact.schema_version,
component_world: artifact.world.clone(),
required_imports,
config_schema: PluginSchemaIdentity {
identity: artifact.config_schema.identity.clone(),
version: artifact.config_schema.version.clone(),
digest: artifact.config_schema.digest.clone(),
namespace: artifact.config_schema.namespace.clone(),
},
config_digest: digest_json(&projection.config)?,
projection,
})
}
pub fn installation_id(&self) -> &str {
&self.installation_id
}
pub fn expected_last_operation(&self) -> &str {
&self.expected_last_operation
}
pub fn package_id(&self) -> &str {
&self.package_id
}
pub fn package_version(&self) -> &str {
&self.package_version
}
pub fn package_schema_version(&self) -> u32 {
self.package_schema_version
}
pub fn component_world(&self) -> &str {
&self.component_world
}
pub fn required_imports(&self) -> &[String] {
&self.required_imports
}
pub fn package_digest(&self) -> &str {
&self.package_digest
}
pub fn artifact_digest(&self) -> &str {
&self.artifact_digest
}
pub fn artifact_size(&self) -> u32 {
self.artifact_size
}
pub fn config_schema(&self) -> &PluginSchemaIdentity {
&self.config_schema
}
pub fn config_digest(&self) -> &str {
&self.config_digest
}
pub fn projection(&self) -> &PluginPinProjection {
&self.projection
}
pub(super) fn matches_artifact(&self, artifact: &PluginArtifact) -> Result<bool> {
Ok(Self::from_artifact(
self.installation_id.clone(),
self.expected_last_operation.clone(),
artifact,
self.projection.clone(),
)? == *self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPlanInput {
pub workspace_id: String,
pub config: ConfigProvenance,
pub profile: ProfileProvenance,
pub execution: HostExecutionIdentity,
pub plugins: Vec<WorkerPluginPin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginExecutionPlan {
format_version: u32,
input: WorkerPluginPlanInput,
}
impl WorkerPluginExecutionPlan {
pub fn canonicalize(mut input: WorkerPluginPlanInput) -> Result<Self> {
if !valid_identity(&input.workspace_id)
|| !valid_digest(&input.config.content_digest)
|| !valid_identity(&input.config.toolchain_fingerprint)
|| !valid_identity(&input.profile.identity)
|| !valid_digest(&input.profile.projection_digest)
|| !valid_identity(&input.execution.runtime_id)
|| !valid_identity(&input.execution.worker_id)
|| !valid_identity(&input.execution.principal_id)
|| input
.execution
.delegation_id
.as_ref()
.is_some_and(|id| !valid_identity(id))
|| input.plugins.len() > 128
{
return invalid();
}
input
.plugins
.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
// One selection per installation; two aliases of one package instead use
// distinct installation identities, as required by the catalog contract.
if input
.plugins
.windows(2)
.any(|pins| pins[0].installation_id == pins[1].installation_id)
{
return invalid();
}
// Public Tool names use immutable installation identities, never aliases.
let mut names = BTreeSet::new();
for pin in &input.plugins {
for tool in &pin.projection.tools {
let name = format!("{}__{}", pin.installation_id, tool);
if name.len() > 128 || !names.insert(name) {
return invalid();
}
}
}
Ok(Self {
format_version: 1,
input,
})
}
pub fn workspace_id(&self) -> &str {
&self.input.workspace_id
}
pub fn config(&self) -> &ConfigProvenance {
&self.input.config
}
pub fn profile(&self) -> &ProfileProvenance {
&self.input.profile
}
pub fn execution(&self) -> &HostExecutionIdentity {
&self.input.execution
}
pub fn plugins(&self) -> &[WorkerPluginPin] {
&self.input.plugins
}
pub fn fingerprint(&self) -> Result<String> {
digest_json(self)
}
pub fn canonical_json(&self) -> Result<String> {
encode_canonical(self)
}
pub(super) fn validate_canonical(&self) -> Result<()> {
if self.format_version != 1 || Self::canonicalize(self.input.clone())? != *self {
return invalid();
}
Ok(())
}
}
fn invalid<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::Invalid))
}
fn conflict<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::Conflict))
}
fn not_found<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::NotFound))
}
fn valid_identity(value: &str) -> bool {
!value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
}
fn valid_digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
fn canonical_value(value: &Value) -> Value {
match value {
Value::Object(fields) => {
let mut keys: Vec<_> = fields.keys().collect();
keys.sort();
Value::Object(
keys.into_iter()
.map(|key| (key.clone(), canonical_value(&fields[key])))
.collect(),
)
}
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
_ => value.clone(),
}
}
fn encode_canonical(value: &impl Serialize) -> Result<String> {
let value = serde_json::to_value(value)
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))?;
serde_json::to_string(&canonical_value(&value))
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))
}
fn digest_json(value: &impl Serialize) -> Result<String> {
let bytes = encode_canonical(value)?;
Ok(hash_bytes(bytes.as_bytes()))
}
fn hash_bytes(bytes: &[u8]) -> String {
let hex: String = Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{hex}")
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,51 @@
//! Ordered production migration 106 -> 107. A marker failure rolls back new DDL
//! without changing the already applied catalog migration or its saved rows.
use super::*;
#[test]
fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_matches_fresh() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("retained.db");
tests::prepare_retained_schema(&path, 106);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
let before = workspace_schema_migration_history(&conn).unwrap();
conn.execute_batch("CREATE TRIGGER interrupt_plan_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=107 BEGIN SELECT RAISE(ABORT,'plan marker failure'); END;").unwrap();
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
assert_eq!(workspace_schema_migration_history(&conn).unwrap(), before);
assert!(!table_exists(&conn, "plugin_execution_plans").unwrap());
crate::plugin_catalog::verify(&conn).unwrap();
conn.execute_batch("DROP TRIGGER interrupt_plan_migration")
.unwrap();
drop(conn);
let updated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
updated
.with_conn(|conn| {
let mut expected = before.clone();
expected.push((
107,
"Immutable Worker Plugin plans and retained pins".into(),
));
expected.push((
108,
"Subject Ticket responsibility and current-body notification routing".into(),
));
assert_eq!(workspace_schema_migration_history(conn)?, expected);
subject_assignment_migration::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
assert_eq!(
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))?,
0
);
conn.execute_batch("DROP TRIGGER plugin_plan_snapshot_immutable")?;
Ok(())
})
.unwrap();
drop(updated);
assert!(
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin plan storage schema has drifted"))
);
}
@@ -0,0 +1,27 @@
CREATE TABLE plugin_execution_plans (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
plan_id TEXT NOT NULL,
actor TEXT NOT NULL CHECK(length(actor) BETWEEN 1 AND 512),
operation_key TEXT NOT NULL CHECK(length(operation_key) BETWEEN 1 AND 128),
fingerprint TEXT NOT NULL CHECK(length(fingerprint)=71 AND substr(fingerprint,1,7)='sha256:' AND substr(fingerprint,8) NOT GLOB '*[^0-9a-f]*'),
plan_json TEXT NOT NULL CHECK(json_valid(plan_json)),
pin_state TEXT NOT NULL CHECK(pin_state IN ('pending','active','restorable','released')),
PRIMARY KEY(workspace_id, plan_id),
UNIQUE(workspace_id, actor, operation_key)
);
CREATE TRIGGER plugin_plan_snapshot_immutable
BEFORE UPDATE OF workspace_id,plan_id,actor,operation_key,fingerprint,plan_json ON plugin_execution_plans
BEGIN SELECT RAISE(ABORT,'Plugin execution plan is immutable'); END;
CREATE TRIGGER plugin_plan_retention_terminal BEFORE UPDATE OF pin_state ON plugin_execution_plans
WHEN NOT (
OLD.pin_state=NEW.pin_state OR
(OLD.pin_state='pending' AND NEW.pin_state IN ('active','released')) OR
(OLD.pin_state='active' AND NEW.pin_state IN ('restorable','released')) OR
(OLD.pin_state='restorable' AND NEW.pin_state IN ('active','released'))
)
BEGIN SELECT RAISE(ABORT,'invalid Plugin execution plan pin transition'); END;
-- Preserve the immutable create replay after retention release. Workspace deletion
-- may cascade; deleting an individual receipt must not make its key reusable.
CREATE TRIGGER plugin_plan_replay_retained BEFORE DELETE ON plugin_execution_plans
WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id)
BEGIN SELECT RAISE(ABORT,'Plugin execution plan replay must be retained'); END;
@@ -0,0 +1,331 @@
use super::*;
use crate::{
plugin_catalog::{self, PluginReference, PluginReferenceKind},
store::SqliteWorkspaceStore,
};
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPinState {
Pending,
Active,
Restorable,
Released,
}
impl PlanPinState {
fn name(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Active => "active",
Self::Restorable => "restorable",
Self::Released => "released",
}
}
fn reference_kind(self) -> Option<PluginReferenceKind> {
match self {
Self::Pending => Some(PluginReferenceKind::Pending),
Self::Active => Some(PluginReferenceKind::Active),
Self::Restorable => Some(PluginReferenceKind::Restorable),
Self::Released => None,
}
}
fn can_transition(self, target: Self) -> bool {
self == target
|| matches!(
(self, target),
(Self::Pending, Self::Active | Self::Released)
| (Self::Active, Self::Restorable | Self::Released)
| (Self::Restorable, Self::Active | Self::Released)
)
}
}
/// Immutable creation result. `initial_pin_state` describes creation, never the
/// current retention state. Replay after release must not resurrect any pins.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginPlanCreateReceipt {
pub plan_id: String,
pub fingerprint: String,
pub initial_pin_state: PlanPinState,
pub plan: WorkerPluginExecutionPlan,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoredPluginPlan {
pub plan_id: String,
pub fingerprint: String,
pub pin_state: PlanPinState,
pub plan: WorkerPluginExecutionPlan,
}
// A local savepoint keeps owner functions atomic even if a caller handles their
// error without aborting its surrounding Worker-create transaction.
fn atomic<T>(tx: &Transaction<'_>, work: impl FnOnce() -> Result<T>) -> Result<T> {
tx.execute_batch("SAVEPOINT plugin_plan_owner")?;
match work() {
Ok(value) => {
tx.execute_batch("RELEASE plugin_plan_owner")?;
Ok(value)
}
Err(error) => {
tx.execute_batch("ROLLBACK TO plugin_plan_owner; RELEASE plugin_plan_owner")?;
Err(error)
}
}
}
/// The caller owns the authenticated create key and an immediate transaction in
/// the *same database* as catalog and Worker creation. Include the returned plan
/// fingerprint in the Worker launch/create fingerprint. New creation rechecks
/// current config/Profile/binding provenance and admitted metadata in this same
/// transaction. Owner-supplied narrowed snapshots cannot exceed that authority.
///
/// Replay precedes catalog admission: an immutable historical result is returned
/// even after revocation, uninstall or release. It is not fresh execution authority.
pub fn plugin_plan_create(
tx: &Transaction<'_>,
actor: &str,
operation_key: &str,
plan: &WorkerPluginExecutionPlan,
) -> Result<PluginPlanCreateReceipt> {
if !valid_identity(actor) || !valid_identity(operation_key) || operation_key.len() > 128 {
return invalid();
}
plan.validate_canonical()?;
let fingerprint = plan.fingerprint()?;
atomic(tx, || {
let saved = tx.query_row(
"SELECT plan_id,fingerprint FROM plugin_execution_plans WHERE workspace_id=?1 AND actor=?2 AND operation_key=?3",
params![plan.workspace_id(), actor, operation_key],
|row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?)),
).optional()?;
if let Some((plan_id, saved_fingerprint)) = saved {
if saved_fingerprint != fingerprint {
return conflict();
}
return receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?);
}
for pin in plan.plugins() {
plugin_catalog::plugin_artifact_admit(
tx,
plan.workspace_id(),
&pin.installation_id,
&pin.expected_last_operation,
&pin.package_digest,
&pin.artifact_digest,
)?;
}
crate::plugin_settings::validate_plan_source(tx, plan)?;
let plan_id = format!("pep-{}", uuid::Uuid::now_v7());
tx.execute(
"INSERT INTO plugin_execution_plans(workspace_id,plan_id,actor,operation_key,fingerprint,plan_json,pin_state) VALUES(?1,?2,?3,?4,?5,?6,'pending')",
params![plan.workspace_id(), plan_id, actor, operation_key, fingerprint, plan.canonical_json()?],
)?;
for pin in plan.plugins() {
let artifact = plugin_catalog::plugin_artifact_admit(
tx,
plan.workspace_id(),
&pin.installation_id,
&pin.expected_last_operation,
&pin.package_digest,
&pin.artifact_digest,
)?;
if !pin.matches_artifact(&artifact)? {
return conflict();
}
plugin_catalog::plugin_reference_put(
tx,
plan.workspace_id(),
&PluginReference {
kind: PluginReferenceKind::Pending,
owner: plan_id.clone(),
installation_id: pin.installation_id.clone(),
expected_last_operation: pin.expected_last_operation.clone(),
package_digest: pin.package_digest.clone(),
artifact_digest: pin.artifact_digest.clone(),
},
)?;
}
receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?)
})
}
fn receipt(saved: StoredPluginPlan) -> Result<PluginPlanCreateReceipt> {
Ok(PluginPlanCreateReceipt {
plan_id: saved.plan_id,
fingerprint: saved.fingerprint,
initial_pin_state: PlanPinState::Pending,
plan: saved.plan,
})
}
/// Reads only persisted immutable input, never current config or a latest artifact.
/// `Released` snapshots remain readable for audit/replay but cannot be restored.
pub fn plugin_plan_load(
conn: &Connection,
workspace: &str,
plan_id: &str,
) -> Result<StoredPluginPlan> {
let row = conn.query_row(
"SELECT fingerprint,plan_json,pin_state FROM plugin_execution_plans WHERE workspace_id=?1 AND plan_id=?2",
params![workspace, plan_id],
|row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?, row.get::<_,String>(2)?)),
).optional()?;
let Some((fingerprint, json, state)) = row else {
return not_found();
};
let plan: WorkerPluginExecutionPlan = serde_json::from_str(&json)
.map_err(|_| Error::Store("invalid persisted Plugin execution plan".into()))?;
plan.validate_canonical()?;
if plan.workspace_id() != workspace || plan.fingerprint()? != fingerprint {
return Err(Error::Store(
"Plugin execution plan integrity mismatch".into(),
));
}
let pin_state = match state.as_str() {
"pending" => PlanPinState::Pending,
"active" => PlanPinState::Active,
"restorable" => PlanPinState::Restorable,
"released" => PlanPinState::Released,
_ => {
return Err(Error::Store(
"invalid Plugin execution plan pin state".into(),
));
}
};
Ok(StoredPluginPlan {
plan_id: plan_id.into(),
fingerprint,
pin_state,
plan,
})
}
/// Moves pins atomically with the caller's Worker lifecycle update. State is CAS
/// compared; a stale transition is not silently replayed. Releasing is always
/// possible after revoke and never re-admits an artifact. Releasing twice with
/// expected=Released is a harmless no-op. No transition out of Released is allowed.
///
/// Acquiring a different retained kind rechecks live catalog admission. It uses
/// the installation's current operation (read inside the write transaction), not
/// the creation-time observation: unrelated artifact additions do not invalidate
/// immutable snapshots. Revoked targets cannot acquire new pins; an unsuccessful
/// transition retains all old refs. A revoked Worker may release its pins, but
/// cannot become restorable/active. T-793 must still enforce live operation checks.
pub fn plugin_plan_transition(
tx: &Transaction<'_>,
workspace: &str,
plan_id: &str,
expected: PlanPinState,
target: PlanPinState,
) -> Result<StoredPluginPlan> {
atomic(tx, || {
let mut saved = plugin_plan_load(tx, workspace, plan_id)?;
if saved.pin_state != expected || !expected.can_transition(target) {
return conflict();
}
if expected == target {
return Ok(saved);
}
if let Some(kind) = target.reference_kind() {
for pin in saved.plan.plugins() {
let operation: String = tx.query_row(
"SELECT last_operation FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2",
params![workspace, pin.installation_id], |row| row.get(0),
)?;
plugin_catalog::plugin_reference_put(
tx,
workspace,
&PluginReference {
kind,
owner: plan_id.into(),
installation_id: pin.installation_id.clone(),
expected_last_operation: operation,
package_digest: pin.package_digest.clone(),
artifact_digest: pin.artifact_digest.clone(),
},
)?;
}
}
if let Some(kind) = expected.reference_kind() {
for pin in saved.plan.plugins() {
plugin_catalog::plugin_reference_remove(
tx,
workspace,
kind,
plan_id,
&pin.installation_id,
&pin.artifact_digest,
)?;
}
}
let changed = tx.execute(
"UPDATE plugin_execution_plans SET pin_state=?1 WHERE workspace_id=?2 AND plan_id=?3 AND pin_state=?4",
params![target.name(),workspace,plan_id,expected.name()],
)?;
if changed != 1 {
return conflict();
}
saved.pin_state = target;
Ok(saved)
})
}
impl SqliteWorkspaceStore {
pub fn plugin_plan_create(
&self,
actor: &str,
operation_key: &str,
plan: &WorkerPluginExecutionPlan,
) -> Result<PluginPlanCreateReceipt> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let receipt = plugin_plan_create(&tx, actor, operation_key, plan)?;
tx.commit()?;
Ok(receipt)
})
}
pub fn plugin_plan_load(&self, workspace: &str, plan_id: &str) -> Result<StoredPluginPlan> {
self.with_conn(|conn| plugin_plan_load(conn, workspace, plan_id))
}
pub fn plugin_plan_transition(
&self,
workspace: &str,
plan_id: &str,
expected: PlanPinState,
target: PlanPinState,
) -> Result<StoredPluginPlan> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let saved = plugin_plan_transition(&tx, workspace, plan_id, expected, target)?;
tx.commit()?;
Ok(saved)
})
}
}
pub(crate) fn verify_plan_schema(conn: &Connection) -> Result<()> {
let expected = Connection::open_in_memory()?;
expected.execute_batch(SCHEMA_SQL)?;
let mut statement = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name='plugin_execution_plans' ORDER BY name")?;
for item in statement.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})? {
let (name, sql) = item?;
let actual = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name=?1",
[&name],
|row| row.get::<_, String>(0),
)
.optional()?;
if actual.as_deref() != Some(&sql) {
return Err(Error::Store(
"Plugin plan storage schema has drifted".into(),
));
}
}
Ok(())
}
@@ -0,0 +1,779 @@
//! Real file-backed SQLite, production catalog operations and a checked-in PDK
//! Component. Execution/Host side effects are intentionally outside this boundary.
use super::*;
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
use rusqlite::{Connection, TransactionBehavior, params};
use server_api::*;
use std::fs;
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
store: SqliteWorkspaceStore,
}
impl Fixture {
async fn new() -> Self {
let dir = tempdir().unwrap();
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap();
for workspace in ["a", "b"] {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: workspace.into(),
owner_account_id: "owner-account".into(),
display_name: workspace.into(),
state: "active".into(),
created_at: "2026-10-28T00:00:00Z".into(),
updated_at: "2026-10-28T00:00:00Z".into(),
})
.await
.unwrap();
}
Self { dir, store }
}
fn install(&self, alias: &str) -> PluginOperationResponse {
let upload = self
.store
.plugin_upload(
&self.dir.path().join("blobs"),
"a",
"actor",
&package("0.1.0"),
)
.unwrap();
self.store
.plugin_install(
"a",
"actor",
&PluginInstallRequest {
upload_id: upload.upload_id,
alias: alias.into(),
operation_key: alias.into(),
},
)
.unwrap()
}
fn count(&self, table: &str) -> i64 {
self.store
.with_conn(|conn| {
Ok(conn.query_row(
&format!(
"SELECT count(*) FROM {table}{}",
if table == "plugin_references" {
" WHERE kind != 'config'"
} else {
""
}
),
[],
|r| r.get(0),
)?)
})
.unwrap()
}
fn kind(&self) -> String {
self.store
.with_conn(|conn| {
Ok(conn.query_row(
"SELECT kind FROM plugin_references WHERE kind != 'config' LIMIT 1",
[],
|r| r.get(0),
)?)
})
.unwrap()
}
fn transition(&self, id: &str, from: PlanPinState, to: PlanPinState) -> StoredPluginPlan {
self.store
.plugin_plan_transition("a", id, from, to)
.unwrap()
}
}
fn package(version: &str) -> Vec<u8> {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("plugin.toml"),
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
.replace("version = \"0.1.0\"", &format!("version = \"{version}\"")),
)
.unwrap();
fs::write(
dir.path().join("plugin.component.wasm"),
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
)
.unwrap();
let package = plugin::read_plugin_directory(
dir.path(),
plugin::PluginSourceKind::Explicit,
&plugin::PluginPackageLimits::default(),
)
.unwrap();
plugin::pack_plugin_package(&package, &plugin::PluginPackageLimits::default())
.unwrap()
.bytes
}
fn digest(seed: &str) -> String {
hash_bytes(seed.as_bytes())
}
fn projection() -> PluginPinProjection {
PluginPinProjection {
config: serde_json::json!({}),
surfaces: vec![PluginSurface::Tool],
tools: vec!["example_echo".into()],
grants: vec![],
bindings: vec![],
}
}
fn pin(installed: &PluginOperationResponse) -> WorkerPluginPin {
WorkerPluginPin::from_artifact(
installed.installation.installation_id.clone(),
installed.operation_id.clone(),
&installed.installation.artifacts[0],
projection(),
)
.unwrap()
}
fn input(plugins: Vec<WorkerPluginPin>) -> WorkerPluginPlanInput {
WorkerPluginPlanInput {
workspace_id: "a".into(),
config: ConfigProvenance {
content_digest: digest("config"),
toolchain_fingerprint: digest("toolchain"),
},
profile: ProfileProvenance {
identity: "profile:coder".into(),
projection_digest: digest("profile"),
},
execution: HostExecutionIdentity {
runtime_id: "runtime".into(),
worker_id: "worker".into(),
principal_id: "worker-principal".into(),
delegation_id: Some("delegation".into()),
},
plugins,
}
}
fn plan(plugins: Vec<WorkerPluginPin>) -> WorkerPluginExecutionPlan {
WorkerPluginExecutionPlan::canonicalize(input(plugins)).unwrap()
}
fn error<T: std::fmt::Debug>(result: Result<T>, expected: PluginCatalogError) {
assert!(
matches!(result,Err(Error::PluginCatalog(actual)) if actual == expected),
"expected {expected:?}, got {result:?}"
);
}
fn uninstall(
f: &Fixture,
installation: &PluginOperationResponse,
key: &str,
) -> Result<PluginOperationResponse> {
f.store.plugin_uninstall(
"a",
"actor",
&installation.installation.installation_id,
&PluginUninstallRequest {
expected_last_operation: installation.operation_id.clone(),
operation_key: key.into(),
},
)
}
#[tokio::test]
async fn canonical_order_and_fingerprint_bind_all_provenance_and_both_digests() {
let f = Fixture::new().await;
let a = f.install("one");
let b = f.install("two");
let p = plan(vec![pin(&a), pin(&b)]);
assert_eq!(p, plan(vec![pin(&b), pin(&a)]));
assert_eq!(
p.fingerprint().unwrap(),
plan(vec![pin(&b), pin(&a)]).fingerprint().unwrap()
);
let mut changes = Vec::new();
let base = input(vec![pin(&a), pin(&b)]);
let mut changed = base.clone();
changed.config.content_digest = digest("new config");
changes.push(changed);
let mut changed = base.clone();
changed.config.toolchain_fingerprint = digest("new toolchain");
changes.push(changed);
let mut changed = base.clone();
changed.profile.projection_digest = digest("new profile");
changes.push(changed);
let mut changed = base.clone();
changed.execution.delegation_id = Some("different".into());
changes.push(changed);
let mut changed = base.clone();
changed.plugins[0].package_digest = digest("new package");
changes.push(changed);
let mut changed = base.clone();
changed.plugins[0].artifact_digest = digest("new artifact");
changes.push(changed);
let mut changed = base;
changed.plugins[0].expected_last_operation = "new observation".into();
changes.push(changed);
for changed in changes {
assert_ne!(
p.fingerprint().unwrap(),
WorkerPluginExecutionPlan::canonicalize(changed)
.unwrap()
.fingerprint()
.unwrap()
);
}
error(
WorkerPluginExecutionPlan::canonicalize(input(vec![pin(&a), pin(&a)])),
PluginCatalogError::Invalid,
);
}
#[tokio::test]
async fn canonical_json_sorts_nested_config_and_selected_sets_without_copying_catalog_payloads() {
let f = Fixture::new().await;
let installed = f.install("one");
let mut artifact = installed.installation.artifacts[0].clone();
artifact.tools.push(PluginToolMetadata {
name: "other".into(),
..artifact.tools[0].clone()
});
artifact.requested_grants = vec![
PluginGrantMetadata::Logging,
PluginGrantMetadata::Https { slot: "api".into() },
];
artifact.connections = vec![PluginConnectionMetadata {
name: "api".into(),
methods: vec![PluginHttpMethod::Get],
path_prefixes: vec!["/".into()],
}];
let mut first = projection();
first.config = serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[{"z":0,"a":false}]}"#).unwrap();
first.tools = vec!["other".into(), "example_echo".into()];
first.grants = vec![
PluginGrant::Https { slot: "api".into() },
PluginGrant::Logging,
];
first.bindings = vec![BindingPolicyReference {
slot: "api".into(),
binding_id: "binding".into(),
policy_digest: digest("policy"),
}];
let mut second = first.clone();
second.config = serde_json::from_str(r#"{"a":[{"a":false,"z":0}],"z":{"a":1,"b":2}}"#).unwrap();
second.tools.reverse();
second.grants.reverse();
let construct = |projection| {
plan(vec![
WorkerPluginPin::from_artifact(
installed.installation.installation_id.clone(),
installed.operation_id.clone(),
&artifact,
projection,
)
.unwrap(),
])
};
let canonical = construct(first);
assert_eq!(canonical, construct(second));
let json = canonical.canonical_json().unwrap();
for excluded in [
"revocation_reason",
"description",
"input_schema",
"output_schema",
"dcdl",
"artifact_url",
"host_path",
"secret_value",
"provider_handle",
"token",
"package_bytes",
] {
assert!(
!json.contains(&format!("\"{excluded}\"")),
"unexpected {excluded}"
);
}
let mut unknown = serde_json::to_value(&canonical).unwrap();
unknown["input"]["execution"]["token"] = Value::String("credential".into());
assert!(serde_json::from_value::<WorkerPluginExecutionPlan>(unknown).is_err());
}
#[tokio::test]
async fn create_replay_is_immutable_across_restart_and_rejects_changed_input() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
assert_eq!(receipt.initial_pin_state, PlanPinState::Pending);
assert_eq!(f.kind(), "pending");
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
assert_eq!(
reopened
.plugin_plan_create("actor", "launch", &plan)
.unwrap(),
receipt
);
assert_eq!(
reopened
.plugin_plan_load("a", &receipt.plan_id)
.unwrap()
.pin_state,
PlanPinState::Active
);
let mut changed = plan.input.clone();
changed.profile.projection_digest = digest("new profile");
error(
reopened.plugin_plan_create(
"actor",
"launch",
&WorkerPluginExecutionPlan::canonicalize(changed).unwrap(),
),
PluginCatalogError::Conflict,
);
assert_eq!(f.count("plugin_execution_plans"), 1);
assert_eq!(f.count("plugin_references"), 1);
assert_eq!(
reopened
.plugin_plan_load("a", &receipt.plan_id)
.unwrap()
.plan,
plan
);
error(
reopened.plugin_plan_load("b", &receipt.plan_id),
PluginCatalogError::NotFound,
);
}
#[tokio::test]
async fn retained_pin_lifecycle_blocks_uninstall_and_released_replay_never_reacquires() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
error(
uninstall(&f, &installed, "uninstall-pending"),
PluginCatalogError::Referenced,
);
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
assert_eq!(f.kind(), "active");
error(
uninstall(&f, &installed, "uninstall-active"),
PluginCatalogError::Referenced,
);
f.transition(
&receipt.plan_id,
PlanPinState::Active,
PlanPinState::Restorable,
);
assert_eq!(f.kind(), "restorable");
error(
uninstall(&f, &installed, "uninstall-restorable"),
PluginCatalogError::Referenced,
);
f.transition(
&receipt.plan_id,
PlanPinState::Restorable,
PlanPinState::Active,
);
f.transition(
&receipt.plan_id,
PlanPinState::Active,
PlanPinState::Released,
);
f.transition(
&receipt.plan_id,
PlanPinState::Released,
PlanPinState::Released,
);
assert_eq!(f.count("plugin_references"), 0);
f.disable();
uninstall(&f, &installed, "uninstall-released").unwrap();
assert_eq!(
f.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap(),
receipt
);
assert_eq!(f.count("plugin_references"), 0);
error(
f.store.plugin_plan_transition(
"a",
&receipt.plan_id,
PlanPinState::Released,
PlanPinState::Active,
),
PluginCatalogError::Conflict,
);
error(
f.store
.plugin_plan_create("actor", "different-launch", &plan),
PluginCatalogError::NotFound,
);
assert_eq!(f.count("plugin_execution_plans"), 1);
f.store
.with_conn(|conn| {
assert!(
conn.execute("UPDATE plugin_execution_plans SET plan_json=plan_json", [])
.is_err()
);
assert!(
conn.execute("UPDATE plugin_execution_plans SET pin_state='pending'", [])
.is_err()
);
assert!(
conn.execute("DELETE FROM plugin_execution_plans", [])
.is_err()
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn revoke_blocks_create_and_new_pin_kinds_but_not_release_or_historical_replay() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
f.store
.plugin_revoke(
"a",
"actor",
&installed.installation.installation_id,
&PluginRevokeRequest {
artifact_digest: installed.installation.artifacts[0].artifact_digest.clone(),
expected_last_operation: installed.operation_id.clone(),
reason: "revoked".into(),
operation_key: "revoke".into(),
},
)
.unwrap();
assert_eq!(
f.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap(),
receipt
);
error(
f.store.plugin_plan_transition(
"a",
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
),
PluginCatalogError::Conflict,
);
assert_eq!(f.kind(), "pending");
error(
f.store.plugin_plan_create("actor", "new-launch", &plan),
PluginCatalogError::Conflict,
);
assert_eq!(f.count("plugin_execution_plans"), 1);
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Released,
);
assert_eq!(f.count("plugin_references"), 0);
}
#[tokio::test]
async fn artifact_addition_does_not_reselect_or_invalidate_existing_plan_lifecycle() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
let upload = f
.store
.plugin_upload(&f.dir.path().join("blobs"), "a", "actor", &package("0.2.0"))
.unwrap();
f.store
.plugin_add_artifact(
"a",
"actor",
&installed.installation.installation_id,
&PluginAddArtifactRequest {
upload_id: upload.upload_id,
expected_last_operation: installed.operation_id.clone(),
operation_key: "update".into(),
},
)
.unwrap();
let active = f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
assert_eq!(active.plan, plan);
assert_eq!(
active.plan.plugins()[0].artifact_digest(),
installed.installation.artifacts[0].artifact_digest
);
error(
f.store
.plugin_plan_create("actor", "new-with-stale-observation", &plan),
PluginCatalogError::Conflict,
);
}
#[tokio::test]
async fn owner_savepoint_and_outer_transaction_rollback_leave_no_partial_plan_or_pins() {
let f = Fixture::new().await;
let first = f.install("one");
let second = f.install("two");
let mut bad_input = f.configure(&[&first, &second]).input;
let mut pins = bad_input.plugins.clone();
pins.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
pins[1].artifact_size += 1;
bad_input.plugins = pins;
let bad_plan = WorkerPluginExecutionPlan::canonicalize(bad_input).unwrap();
f.store
.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
error(
plugin_plan_create(&tx, "actor", "bad", &bad_plan),
PluginCatalogError::Conflict,
);
// Even if the caller commits after a handled error, first pin is gone.
tx.commit()?;
Ok(())
})
.unwrap();
assert_eq!(f.count("plugin_execution_plans"), 0);
assert_eq!(f.count("plugin_references"), 0);
let valid = f.configure(&[&first]);
f.store
.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
plugin_plan_create(&tx, "actor", "outer-rollback", &valid)?;
tx.rollback()?;
Ok(())
})
.unwrap();
assert_eq!(f.count("plugin_execution_plans"), 0);
assert_eq!(f.count("plugin_references"), 0);
// The same key is usable after rollback because neither domain fact committed.
f.store
.plugin_plan_create("actor", "outer-rollback", &valid)
.unwrap();
}
#[tokio::test]
async fn cross_workspace_and_exact_digest_mismatches_fail_without_retaining_references() {
let f = Fixture::new().await;
let installed = f.install("one");
let base = f.configure(&[&installed]);
let mut other_workspace = base.input.clone();
other_workspace.workspace_id = "b".into();
error(
f.store.plugin_plan_create(
"actor",
"cross",
&WorkerPluginExecutionPlan::canonicalize(other_workspace).unwrap(),
),
PluginCatalogError::NotFound,
);
for change_package in [true, false] {
let mut changed = base.input.clone();
if change_package {
changed.plugins[0].package_digest = digest("wrong-package");
} else {
changed.plugins[0].artifact_digest = digest("wrong-artifact");
}
let result = f.store.plugin_plan_create(
"actor",
"digests",
&WorkerPluginExecutionPlan::canonicalize(changed).unwrap(),
);
error(
result,
if change_package {
PluginCatalogError::Conflict
} else {
PluginCatalogError::NotFound
},
);
}
assert_eq!(f.count("plugin_execution_plans"), 0);
assert_eq!(f.count("plugin_references"), 0);
}
#[tokio::test]
async fn sqlite_write_fence_serializes_uninstall_and_pin_creation_across_connections() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let mut other = Connection::open(f.dir.path().join("server.db")).unwrap();
other.busy_timeout(std::time::Duration::ZERO).unwrap();
f.store
.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
plugin_plan_create(&tx, "actor", "launch", &plan)?;
// A second writer cannot pass the catalog deletion fence before commit.
assert!(
other
.transaction_with_behavior(TransactionBehavior::Immediate)
.is_err()
);
tx.commit()?;
Ok(())
})
.unwrap();
let tx = other
.transaction_with_behavior(TransactionBehavior::Immediate)
.unwrap();
assert!(tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id=?1", params![installed.installation.installation_id]).is_err());
tx.rollback().unwrap();
error(
uninstall(&f, &installed, "after-committed-pin"),
PluginCatalogError::Referenced,
);
// Opposite ordering: uninstall wins before a new plan's owner acquires pins.
let unpinned = f.install("unpinned");
let unpinned_plan = f.configure(&[&unpinned]);
f.disable();
uninstall(&f, &unpinned, "uninstall-first").unwrap();
error(
f.store
.plugin_plan_create("actor", "after-uninstall", &unpinned_plan),
PluginCatalogError::NotFound,
);
}
impl Fixture {
fn configure(&self, installed: &[&PluginOperationResponse]) -> WorkerPluginExecutionPlan {
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaProvider};
use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle};
let bundle = WorkspaceConfigSchemaBundle::compose([
crate::plugin_settings::PluginConfigSchemaProvider
.contribution()
.unwrap(),
crate::profile_settings::ProfileConfigSchemaProvider
.contribution()
.unwrap(),
])
.unwrap();
let state = self
.store
.ensure_workspace_config_materialized_with_schema("a", "1", bundle.clone())
.unwrap();
let mut enabled = serde_json::Map::new();
let mut selections = Vec::new();
for (index, item) in installed.iter().enumerate() {
let a = &item.installation.artifacts[0];
enabled.insert(
format!("plugin_{index}"),
serde_json::json!({
"installation_id": item.installation.installation_id,
"expected_last_operation": item.operation_id,
"package_digest": a.package_digest, "artifact_digest": a.artifact_digest,
"tools": ["example_echo"], "config": {},
}),
);
selections.push(serde_json::json!({"installation_id":item.installation.installation_id,"tools":["example_echo"]}));
}
let value = serde_json::json!({"plugins":{"enabled": enabled}, "profile":{"entries":[{"selector":"project:tool","profile":{"plugins":{"selections":selections}}}]}});
let path = VirtualPath::parse("main.dcdl").unwrap();
let candidate = self
.store
.evaluate_workspace_config_candidate_with_schema(
"a",
&ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
entrypoints: vec![path.clone()],
changes: vec![ConfigTreeChange::Update {
path: path.clone(),
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
content: format!(
"{} as WorkspaceConfigSchema",
crate::plugin_settings::render_value(&value).unwrap()
),
}],
},
bundle,
)
.unwrap();
let state = self
.store
.commit_evaluated_workspace_config("a", &candidate)
.unwrap();
self.store
.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
crate::plugin_settings::resolve_worker_plugin_plan(
&tx,
"a",
&state.snapshot.digest,
"project:tool",
input(vec![]).execution,
)
})
.unwrap()
}
fn disable(&self) {
self.configure(&[]);
}
}
impl SqliteWorkspaceStore {
// Data-only donor for cross-owner migration preservation tests. The caller
// imports explicit rows into real retained DDL, never this donor's schema or
// migration ledgers. Production catalog/config/plan APIs generate the bytes.
pub(crate) async fn plugin_plan_migration_donor() -> (
TempDir,
Self,
Vec<(String, PluginPlanCreateReceipt, PlanPinState)>,
) {
let f = Fixture::new().await;
let one = f.install("migration-one");
let two = f.install("migration-two");
let plan = f.configure(&[&one, &two]);
assert_ne!(
plan.plugins()[0].package_digest,
plan.plugins()[0].artifact_digest
);
let mut receipts = Vec::new();
for (key, state) in [
("pending", PlanPinState::Pending),
("active", PlanPinState::Active),
("restorable", PlanPinState::Restorable),
("released", PlanPinState::Released),
] {
let receipt = f.store.plugin_plan_create("actor", key, &plan).unwrap();
if matches!(state, PlanPinState::Active | PlanPinState::Restorable) {
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
}
if state == PlanPinState::Restorable {
f.transition(&receipt.plan_id, PlanPinState::Active, state);
} else if state == PlanPinState::Released {
f.transition(&receipt.plan_id, PlanPinState::Pending, state);
}
receipts.push((key.into(), receipt, state));
}
(f.dir, f.store, receipts)
}
}
@@ -0,0 +1,709 @@
//! Catalog-backed Plugin configuration. The virtual config tree remains the only
//! configuration authority; static metadata resolution never instantiates a guest.
use std::collections::{BTreeMap, BTreeSet};
use config_source::{
ConfigSchemaContribution, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment,
ToolchainContract, WorkspaceConfigSchemaBundle,
};
use rusqlite::Transaction;
use serde::{Deserialize, Serialize};
use server_api::{PluginArtifact, PluginGrantMetadata};
use crate::config_source::WorkspaceConfigSchemaProvider;
use crate::plugin_binding::PluginConnectionBinding;
use crate::plugin_catalog::{
PluginReference, PluginReferenceKind, plugin_artifact_admit, plugin_reference_put,
plugin_reference_remove,
};
use crate::{Error, Result};
const PROVIDER: &str = "builtin:plugins";
const BOOTSTRAP: &str = include_str!("../../../resources/config-schema/plugins.dcdl");
const CONFIG_OWNER: &str = "workspace-config";
#[derive(Default)]
pub struct PluginConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for PluginConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new(
PROVIDER,
"plugins",
config_source::digest_bytes(BOOTSTRAP.as_bytes()),
BOOTSTRAP,
)
.map_err(config_error)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginEnablement {
pub installation_id: String,
pub expected_last_operation: String,
pub package_digest: String,
pub artifact_digest: String,
pub tools: Vec<String>,
pub logging: bool,
pub connections: BTreeMap<String, String>,
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginSettings {
pub enabled: BTreeMap<String, PluginEnablement>,
pub bindings: BTreeMap<String, PluginConnectionBinding>,
}
#[derive(Debug, Clone)]
pub struct ResolvedPluginSettings {
pub settings: PluginSettings,
pub artifacts: BTreeMap<String, PluginArtifact>,
}
fn config_error(error: impl std::fmt::Display) -> Error {
Error::InvalidInput(format!("Plugin config: {error}"))
}
fn contract(bundle: WorkspaceConfigSchemaBundle) -> ToolchainContract {
ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![
config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT)
.expect("main path"),
],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
)
}
fn evaluate(
snapshot: &ConfigTreeSnapshot,
contract: &ToolchainContract,
) -> Result<EvaluationResult> {
SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(contract)
.map_err(|diagnostics| {
config_error(
serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "invalid static config".into()),
)
})
}
pub fn settings_from_evaluation(evaluation: &EvaluationResult) -> Result<PluginSettings> {
let value = evaluation
.projections
.first()
.ok_or_else(|| config_error("missing projection"))?;
let root = &value.data_json;
match root.get("plugins") {
Some(value) => serde_json::from_value(value.clone()).map_err(config_error),
None => Ok(PluginSettings::default()),
}
}
/// Resolve core references before composing the closed artifact-specific schema.
/// Both reads use the owner's transaction; the resulting bundle fixes the exact
/// catalog operation as well as both byte identities, not a generic counter.
pub(crate) fn evaluate_catalog_config(
tx: &Transaction<'_>,
workspace: &str,
snapshot: &ConfigTreeSnapshot,
bundle: WorkspaceConfigSchemaBundle,
) -> Result<(ToolchainContract, EvaluationResult, ResolvedPluginSettings)> {
let mut providers = bundle
.contributions
.into_iter()
.filter(|p| p.provider_id != PROVIDER)
.collect::<Vec<_>>();
providers.push(PluginConfigSchemaProvider.contribution()?);
let bootstrap =
contract(WorkspaceConfigSchemaBundle::compose(providers.clone()).map_err(config_error)?);
let core = evaluate(snapshot, &bootstrap)?;
let settings = settings_from_evaluation(&core)?;
crate::plugin_binding::validate_bindings(
&settings.bindings,
&crate::plugin_binding::PluginConnectionPolicyCeiling::default(),
)
.map_err(config_error)?;
if settings.enabled.len() > 128 {
return Err(config_error("too many enablements"));
}
let mut artifacts = BTreeMap::new();
let mut schema_identities = BTreeMap::new();
let mut installations = BTreeSet::new();
for (alias, enabled) in &settings.enabled {
if !installations.insert(&enabled.installation_id) {
return Err(config_error("duplicate enabled installation"));
}
if !safe_name(alias) {
return Err(config_error("invalid enablement alias"));
}
let artifact = plugin_artifact_admit(
tx,
workspace,
&enabled.installation_id,
&enabled.expected_last_operation,
&enabled.package_digest,
&enabled.artifact_digest,
)?;
validate_enablement(enabled, &artifact, &settings)?;
let schema = &artifact.config_schema;
let key = (schema.identity.clone(), schema.version.clone());
let value = (schema.namespace.clone(), schema.digest.clone());
if let Some(previous) = schema_identities.insert(key, value.clone()) {
if previous != value {
return Err(config_error("schema identity collision"));
}
}
artifacts.insert(alias.clone(), artifact);
}
// Each alias has its own closed schema. Reusing an artifact with distinct
// bindings is intentional and does not manufacture another schema authority.
let enabled_fields = artifacts.iter().map(|(alias, artifact)| {
format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl)
}).collect::<Vec<_>>().join("\n");
let enabled_schema = if enabled_fields.is_empty() {
"{} default {}".to_string()
} else {
format!("{{{enabled_fields}}}")
};
let (prefix, remaining) = BOOTSTRAP
.split_once("enabled = ")
.ok_or_else(|| config_error("missing bootstrap enablement"))?;
let (_, suffix) = remaining
.split_once(";\n bindings = ")
.ok_or_else(|| config_error("missing bootstrap bindings"))?;
let source = format!("{prefix}enabled = {enabled_schema};\n bindings = {suffix}");
let identities = settings
.enabled
.iter()
.map(|(alias, e)| {
(
alias,
&e.installation_id,
&e.expected_last_operation,
&e.package_digest,
&e.artifact_digest,
&artifacts[alias].config_schema,
)
})
.collect::<Vec<_>>();
let version = format!(
"{}:{}",
config_source::digest_bytes(BOOTSTRAP.as_bytes()),
config_source::digest_bytes(&serde_json::to_vec(&identities).map_err(config_error)?)
);
providers.retain(|p| p.provider_id != PROVIDER);
providers.push(
ConfigSchemaContribution::new(PROVIDER, "plugins", version, source)
.map_err(config_error)?,
);
let full_contract =
contract(WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?);
let full = evaluate(snapshot, &full_contract)?;
let final_settings = settings_from_evaluation(&full)?;
let resolved = ResolvedPluginSettings {
settings: final_settings,
artifacts,
};
validate_profile_selections(&full, &resolved)?;
Ok((full_contract, full, resolved))
}
fn safe_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
fn validate_enablement(
enabled: &PluginEnablement,
artifact: &PluginArtifact,
settings: &PluginSettings,
) -> Result<()> {
if artifact.schema_version != 2
|| !matches!(
artifact.world.as_str(),
manifest::plugin::PLUGIN_COMPONENT_TOOL_WORLD
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
| manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
)
|| artifact.required_imports.iter().any(|i| {
!matches!(
i.as_str(),
"yoi:host/errors@2.0.0"
| "yoi:host/connections@2.0.0"
| "yoi:host/http@2.0.0"
| "yoi:host/logging@2.0.0"
)
})
{
return Err(config_error("unsupported Component API"));
}
let tools = enabled.tools.iter().collect::<BTreeSet<_>>();
if tools.len() != enabled.tools.len()
|| tools
.iter()
.any(|name| !artifact.tools.iter().any(|tool| &tool.name == *name))
{
return Err(config_error("unknown or duplicate Tool selection"));
}
if enabled.logging
&& !artifact
.requested_grants
.contains(&PluginGrantMetadata::Logging)
{
return Err(config_error("logging exceeds package request"));
}
for (slot, binding) in &enabled.connections {
let requested = artifact
.connections
.iter()
.find(|s| &s.name == slot)
.ok_or_else(|| config_error("undeclared connection slot"))?;
let binding = settings
.bindings
.get(binding)
.ok_or_else(|| config_error("missing connection binding"))?;
// A binding can be narrower than the package request, never wider.
if binding
.methods
.iter()
.any(|m| !requested.methods.contains(m))
|| binding.path_prefixes.iter().any(|path| {
!requested.path_prefixes.iter().any(|prefix| {
prefix == "/"
|| path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
})
})
{
return Err(config_error("binding exceeds package request"));
}
}
if serde_json::to_vec(&enabled.config)
.map_err(config_error)?
.len()
> 64 * 1024
{
return Err(config_error("non-secret config exceeds 64 KiB"));
}
Ok(())
}
pub(crate) fn sync_config_references(
tx: &Transaction<'_>,
workspace: &str,
resolved: &ResolvedPluginSettings,
) -> Result<()> {
let existing = {
let mut statement = tx.prepare("SELECT installation_id, artifact_digest FROM plugin_references WHERE workspace_id=?1 AND kind='config' AND owner=?2")?;
statement
.query_map(rusqlite::params![workspace, CONFIG_OWNER], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?
};
for (installation, digest) in existing {
plugin_reference_remove(
tx,
workspace,
PluginReferenceKind::Config,
CONFIG_OWNER,
&installation,
&digest,
)?;
}
for enabled in resolved.settings.enabled.values() {
plugin_reference_put(
tx,
workspace,
&PluginReference {
kind: PluginReferenceKind::Config,
owner: CONFIG_OWNER.into(),
installation_id: enabled.installation_id.clone(),
expected_last_operation: enabled.expected_last_operation.clone(),
package_digest: enabled.package_digest.clone(),
artifact_digest: enabled.artifact_digest.clone(),
},
)?;
}
Ok(())
}
fn validate_profile_selections(
evaluation: &EvaluationResult,
resolved: &ResolvedPluginSettings,
) -> Result<()> {
let root = &evaluation.projections[0].data_json;
if let Some(entries) = root
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
{
for entry in entries {
if let Some(plugins) = entry.pointer("/profile/plugins") {
let plugins: manifest::PluginProfileConfig =
serde_json::from_value(plugins.clone()).map_err(config_error)?;
project_profile_plugins(resolved, &plugins)?;
}
}
}
Ok(())
}
fn project_profile_plugins(
resolved: &ResolvedPluginSettings,
profile: &manifest::PluginProfileConfig,
) -> Result<Vec<crate::plugin_plan::WorkerPluginPin>> {
use crate::plugin_plan::{
BindingPolicyReference, PluginGrant, PluginPinProjection, PluginSurface, WorkerPluginPin,
};
let mut selected = BTreeSet::new();
let mut pins = Vec::new();
for selection in &profile.selections {
if !selected.insert(&selection.installation_id) {
return Err(config_error("duplicate Profile installation"));
}
let (alias, enabled) = resolved
.settings
.enabled
.iter()
.find(|(_, e)| e.installation_id == selection.installation_id)
.ok_or_else(|| {
config_error("Profile selects an installation not enabled by Workspace")
})?;
if selection
.tools
.iter()
.any(|tool| !enabled.tools.contains(tool))
|| (selection.logging && !enabled.logging)
|| selection
.https_slots
.iter()
.any(|slot| !enabled.connections.contains_key(slot))
{
return Err(config_error(
"Profile exceeds Workspace surface/grant ceiling",
));
}
let mut config = enabled.config.clone();
let object = config
.as_object_mut()
.ok_or_else(|| config_error("non-secret config must be an object"))?;
for (key, value) in &selection.config {
object.insert(key.clone(), value.clone());
}
let config =
evaluate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?;
let mut grants = Vec::new();
if selection.logging {
grants.push(PluginGrant::Logging);
}
let mut bindings = Vec::new();
for slot in &selection.https_slots {
let binding_id = &enabled.connections[slot];
let binding = &resolved.settings.bindings[binding_id];
grants.push(PluginGrant::Https { slot: slot.clone() });
bindings.push(BindingPolicyReference {
slot: slot.clone(),
binding_id: binding_id.clone(),
policy_digest: binding.digest(),
});
}
pins.push(WorkerPluginPin::from_artifact(
enabled.installation_id.clone(),
enabled.expected_last_operation.clone(),
&resolved.artifacts[alias],
PluginPinProjection {
config,
surfaces: if selection.tools.is_empty() {
vec![]
} else {
vec![PluginSurface::Tool]
},
tools: selection.tools.clone(),
grants,
bindings,
},
)?);
}
Ok(pins)
}
fn evaluate_effective_config(value: &serde_json::Value, schema: &str) -> Result<serde_json::Value> {
use config_source::{ConfigContentType, ConfigEntry, VirtualPath};
if serde_json::to_vec(value).map_err(config_error)?.len() > 64 * 1024 {
return Err(config_error("non-secret config exceeds 64 KiB"));
}
let path = VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT).expect("main path");
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path,
ConfigContentType::Decodal,
format!(
"{{ config = {}; }} as WorkspaceConfigSchema",
render_value(value)?
),
)
.map_err(config_error)?])
.map_err(config_error)?;
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"plugin-effective-config",
"config",
"1",
format!("{{ config = ({schema}); }}"),
)
.map_err(config_error)?])
.map_err(config_error)?;
// Static singleton fields can concretize omitted nested override values.
// Pin the complete projection, not just the candidate that passed validation.
let evaluated = evaluate(&snapshot, &contract(bundle))?;
let config = evaluated
.projections
.first()
.and_then(|projection| projection.data_json.get("config"))
.filter(|config| config.is_object())
.ok_or_else(|| config_error("effective Plugin config projection must be an object"))?;
if serde_json::to_vec(config).map_err(config_error)?.len() > 64 * 1024 {
return Err(config_error("non-secret config exceeds 64 KiB"));
}
Ok(config.clone())
}
pub(crate) fn render_value(value: &serde_json::Value) -> Result<String> {
Ok(match value {
serde_json::Value::Object(fields) => format!(
"{{ {} }}",
fields
.iter()
.map(|(key, value)| Ok(format!(
"{} = {};",
if safe_name(key) {
key.clone()
} else {
return Err(config_error("config key is not a static identifier"));
},
render_value(value)?
)))
.collect::<Result<Vec<_>>>()?
.join(" ")
),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(render_value)
.collect::<Result<Vec<_>>>()?
.join(", ")
),
serde_json::Value::Null => return Err(config_error("null is not a Plugin config value")),
value => serde_json::to_string(value).map_err(config_error)?,
})
}
/// Build a maximum immutable snapshot from the current Workspace config and an
/// actual registered Profile. The Host supplies execution/delegation identities;
/// a caller-provided projection, binding digest or package payload is never used.
/// The owning Worker-create transaction may further narrow this snapshot, then
/// persist it with `plugin_plan_create` and its own launch fingerprint atomically.
/// This does not admit execution, resolve secrets or start/restore a Worker.
pub fn resolve_worker_plugin_plan(
tx: &Transaction<'_>,
workspace: &str,
expected_content_digest: &str,
profile_selector: &str,
execution: crate::plugin_plan::HostExecutionIdentity,
) -> Result<crate::plugin_plan::WorkerPluginExecutionPlan> {
use crate::plugin_plan::{
ConfigProvenance, ProfileProvenance, WorkerPluginExecutionPlan, WorkerPluginPlanInput,
};
let state = crate::config_source::load_state(tx, workspace)?
.ok_or_else(|| config_error("Workspace config is not materialized"))?;
if state.snapshot.digest != expected_content_digest {
return Err(Error::WorkspaceConfigConflict(
"plan source content changed".into(),
));
}
let (contract, evaluation, resolved) = evaluate_catalog_config(
tx,
workspace,
&state.snapshot,
state.contract.schema_bundle.clone(),
)?;
if state.contract != contract || state.projection_digest != evaluation.projection_digest {
return Err(Error::WorkspaceConfigConflict(
"plan source catalog/schema changed".into(),
));
}
let profile = resolve_profile(&evaluation, &state.snapshot.digest, profile_selector)?;
let projection_digest = config_source::digest_bytes(
&serde_json::to_vec(&profile.manifest_snapshot).map_err(config_error)?,
);
WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput {
workspace_id: workspace.into(),
config: ConfigProvenance {
content_digest: state.snapshot.digest,
toolchain_fingerprint: contract.fingerprint,
},
profile: ProfileProvenance {
identity: profile_selector.into(),
projection_digest,
},
execution,
plugins: project_profile_plugins(&resolved, &profile.manifest.plugins)?,
})
}
fn resolve_profile(
evaluation: &EvaluationResult,
content_digest: &str,
selector: &str,
) -> Result<manifest::ResolvedProfile> {
let root = &evaluation.projections[0].data_json;
if selector.starts_with("project:") {
let value = root
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
.and_then(|entries| {
entries.iter().find(|entry| {
entry.get("selector").and_then(serde_json::Value::as_str) == Some(selector)
})
})
.and_then(|entry| entry.get("profile"))
.ok_or_else(|| config_error("unknown registered Profile"))?;
manifest::resolve_profile_artifact_value(
value.clone(),
manifest::ProfileSource::Archive {
archive_id: format!("workspace-config:{content_digest}"),
source: selector.into(),
},
std::path::Path::new("/"),
"plugin-plan-projection",
)
.map_err(config_error)
} else if selector.starts_with("builtin:") {
let registry = manifest::ProfileDiscovery::with_sources(None, None)
.discover()
.map_err(config_error)?;
manifest::ProfileResolver::new()
.with_workspace_base("/")
.resolve_from_registry(
&manifest::ProfileSelector::source_named(
manifest::ProfileRegistrySource::Builtin,
selector.trim_start_matches("builtin:"),
),
&registry,
manifest::ProfileResolveOptions::with_worker_name("plugin-plan-projection"),
)
.map_err(config_error)
} else {
Err(config_error("unknown registered Profile selector"))
}
}
/// Check the owner-supplied launch snapshot against config authority on first
/// create. Exact replay deliberately uses the saved snapshot instead of rereading
/// changed config. Delegation can only remove pins/tools/grants from this maximum.
pub(crate) fn validate_plan_source(
tx: &Transaction<'_>,
plan: &crate::plugin_plan::WorkerPluginExecutionPlan,
) -> Result<()> {
let maximum = resolve_worker_plugin_plan(
tx,
plan.workspace_id(),
&plan.config().content_digest,
&plan.profile().identity,
plan.execution().clone(),
)?;
if maximum.config() != plan.config() || maximum.profile() != plan.profile() {
return Err(config_error("plan source provenance mismatch"));
}
for pin in plan.plugins() {
let upper = maximum
.plugins()
.iter()
.find(|p| p.installation_id() == pin.installation_id())
.ok_or_else(|| config_error("plan exceeds selected Profile"))?;
let lower = pin.projection();
let max = upper.projection();
if pin.package_digest() != upper.package_digest()
|| pin.artifact_digest() != upper.artifact_digest()
|| pin.expected_last_operation() != upper.expected_last_operation()
|| pin.config_digest() != upper.config_digest()
|| pin.config_schema() != upper.config_schema()
|| lower.config != max.config
|| lower.tools.iter().any(|tool| !max.tools.contains(tool))
|| lower.grants.iter().any(|grant| !max.grants.contains(grant))
|| lower
.bindings
.iter()
.any(|binding| !max.bindings.contains(binding))
|| lower
.surfaces
.iter()
.any(|surface| !max.surfaces.contains(surface))
{
return Err(config_error("plan exceeds config/Profile upper bound"));
}
}
Ok(())
}
#[cfg(test)]
mod tests;
pub(crate) fn base_schema_bundle(
bundle: &WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigSchemaBundle> {
let mut contributions = bundle
.contributions
.iter()
.filter(|p| p.provider_id != PROVIDER)
.cloned()
.collect::<Vec<_>>();
if bundle
.contributions
.iter()
.any(|p| p.provider_id == PROVIDER)
{
contributions.push(PluginConfigSchemaProvider.contribution()?);
}
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
}
// Saved config retains the schema that validated it, even when live catalog
// admission changes. Management must remain available to disable/rebind it after
// restart. New preview/create still rechecks the live catalog inside its tx.
pub(crate) fn materialization_schema(
desired: WorkspaceConfigSchemaBundle,
saved: &WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigSchemaBundle> {
let prefix = format!("{}:", config_source::digest_bytes(BOOTSTRAP.as_bytes()));
let active = saved
.contributions
.iter()
.find(|p| p.provider_id == PROVIDER && p.version.starts_with(&prefix));
let Some(active) = active else {
return Ok(desired);
};
let contributions = desired.contributions.into_iter().map(|p| {
if p.provider_id == PROVIDER {
active.clone()
} else {
p
}
});
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
}
@@ -0,0 +1,584 @@
//! Real catalog + virtual config + SQLite transactions. HTTP authentication and
//! guest execution are outside this suite; the canonical Server config path is used.
use super::*;
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState};
use crate::store::{ControlPlaneStore, WorkspaceRecord};
use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle};
use serde_json::json;
use server_api::*;
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
store: crate::SqliteWorkspaceStore,
installed: PluginOperationResponse,
}
impl Fixture {
async fn new() -> Self {
Self::with_schema("{ mode = String; }").await
}
async fn with_schema(schema: &str) -> Self {
let dir = tempdir().unwrap();
let store = crate::SqliteWorkspaceStore::open_for_test(dir.path().join("db")).unwrap();
for id in ["a", "b"] {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: id.into(),
owner_account_id: "owner-account".into(),
display_name: id.into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
}
let upload = store
.plugin_upload(
&dir.path().join("artifacts"),
"a",
"owner",
&package_with_schema("0.1.0", schema),
)
.unwrap();
let installed = store
.plugin_install(
"a",
"owner",
&PluginInstallRequest {
upload_id: upload.upload_id,
alias: "echo".into(),
operation_key: "install".into(),
},
)
.unwrap();
for id in ["a", "b"] {
store
.ensure_workspace_config_materialized_with_schema(id, "1", bundle())
.unwrap();
}
Self {
dir,
store,
installed,
}
}
fn source(&self) -> serde_json::Value {
let a = &self.installed.installation.artifacts[0];
json!({"plugins":{"enabled":{"echo":{
"installation_id":self.installed.installation.installation_id,
"expected_last_operation":self.installed.operation_id,
"package_digest":a.package_digest,"artifact_digest":a.artifact_digest,
"tools":["example_echo"],"logging":false,"connections":{},"config":{"mode":"hello"}
}},"bindings":{}},"profile":{"default_profile":"project:tool","entries":[{"selector":"project:tool","profile":{"plugins":{"selections":[{"installation_id":self.installed.installation.installation_id,"tools":["example_echo"],"logging":false}]}},"label":"","description":""}]}})
}
fn candidate(
&self,
workspace: &str,
value: serde_json::Value,
) -> Result<crate::config_source::EvaluatedConfigCandidate> {
let state = self.store.load_workspace_config(workspace)?.unwrap();
let path = VirtualPath::parse("main.dcdl").unwrap();
let request = ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
entrypoints: vec![path.clone()],
changes: vec![ConfigTreeChange::Update {
path: path.clone(),
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
content: format!("{} as WorkspaceConfigSchema", render_value(&value)?),
}],
};
self.store
.evaluate_workspace_config_candidate_with_schema(workspace, &request, bundle())
}
fn commit(&self, value: serde_json::Value) -> WorkspaceConfigState {
let c = self.candidate("a", value).unwrap();
self.store
.commit_evaluated_workspace_config("a", &c)
.unwrap()
}
fn refs(&self) -> i64 {
self.store
.with_conn(|c| {
Ok(c.query_row(
"SELECT count(*) FROM plugin_references WHERE kind='config'",
[],
|r| r.get(0),
)?)
})
.unwrap()
}
fn plan(&self, state: &WorkspaceConfigState) -> crate::plugin_plan::WorkerPluginExecutionPlan {
self.store
.with_conn(|c| {
let tx = c.unchecked_transaction()?;
resolve_worker_plugin_plan(
&tx,
"a",
&state.snapshot.digest,
"project:tool",
crate::plugin_plan::HostExecutionIdentity {
runtime_id: "runtime".into(),
worker_id: "worker".into(),
principal_id: "principal".into(),
delegation_id: None,
},
)
})
.unwrap()
}
}
fn bundle() -> WorkspaceConfigSchemaBundle {
WorkspaceConfigSchemaBundle::compose([
PluginConfigSchemaProvider.contribution().unwrap(),
crate::profile_settings::ProfileConfigSchemaProvider
.contribution()
.unwrap(),
])
.unwrap()
}
fn package(version: &str) -> Vec<u8> {
package_with_schema(version, "{ mode = String; }")
}
fn package_with_schema(version: &str, schema: &str) -> Vec<u8> {
use manifest::plugin::*;
let d = tempdir().unwrap();
let source =
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
.replace(
"config_schema = \"{}\"",
&format!("config_schema = {}", serde_json::to_string(schema).unwrap()),
);
std::fs::write(d.path().join("plugin.toml"), source).unwrap();
std::fs::write(
d.path().join("plugin.component.wasm"),
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
)
.unwrap();
let parsed = read_plugin_directory(
d.path(),
PluginSourceKind::Explicit,
&PluginPackageLimits::default(),
)
.unwrap();
pack_plugin_package(&parsed, &PluginPackageLimits::default())
.unwrap()
.bytes
}
#[tokio::test]
async fn config_two_phase_closed_schema_and_profile_narrowing_reject_unknown_abstract_and_expansion()
{
let f = Fixture::new().await;
let source = f.source();
let candidate = f.candidate("a", source.clone()).unwrap();
assert!(
candidate
.contract
.schema_bundle
.source
.contains("mode = String")
);
let native = SnapshotEnvironment::new(candidate.snapshot.clone())
.evaluate_contract(&candidate.contract)
.unwrap();
assert_eq!(native, candidate.evaluation);
for pointer in [
"/plugins/enabled/echo/config/extra",
"/plugins/enabled/echo/logging",
"/profile/entries/0/profile/plugins/selections/0/logging",
] {
let mut bad = source.clone();
if pointer.ends_with("extra") {
bad["plugins"]["enabled"]["echo"]["config"]["extra"] = json!(true);
} else {
*bad.pointer_mut(pointer)
.unwrap_or_else(|| panic!("missing {pointer}")) = json!(true);
}
assert!(f.candidate("a", bad).is_err(), "accepted {pointer}");
}
let mut invalid = source.clone();
invalid["plugins"]["enabled"]["echo"]["config"]["mode"] = json!(3);
assert!(f.candidate("a", invalid).is_err());
assert!(f.candidate("b", source).is_err());
assert_eq!(f.refs(), 0);
}
#[tokio::test]
async fn config_refs_commit_atomically_block_uninstall_and_release_on_disable() {
let f = Fixture::new().await;
f.commit(f.source());
assert_eq!(f.refs(), 1);
assert!(
f.store
.plugin_uninstall(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginUninstallRequest {
expected_last_operation: f.installed.operation_id.clone(),
operation_key: "remove".into()
}
)
.is_err()
);
f.commit(json!({"plugins":{},"profile":{"entries":[]}}));
assert_eq!(f.refs(), 0);
f.store
.plugin_uninstall(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginUninstallRequest {
expected_last_operation: f.installed.operation_id.clone(),
operation_key: "remove-after-disable".into(),
},
)
.unwrap();
}
#[tokio::test]
async fn catalog_add_after_preview_rejects_stale_candidate_and_preserves_config_refs() {
let f = Fixture::new().await;
let candidate = f.candidate("a", f.source()).unwrap();
let upload = f
.store
.plugin_upload(
&f.dir.path().join("artifacts"),
"a",
"owner",
&package("0.2.0"),
)
.unwrap();
f.store
.plugin_add_artifact(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginAddArtifactRequest {
upload_id: upload.upload_id,
expected_last_operation: f.installed.operation_id.clone(),
operation_key: "update".into(),
},
)
.unwrap();
assert!(
f.store
.commit_evaluated_workspace_config("a", &candidate)
.is_err()
);
assert_eq!(f.refs(), 0);
}
#[tokio::test]
async fn immutable_plan_create_replay_and_config_changes_do_not_hot_reload() {
use crate::plugin_plan::*;
let f = Fixture::new().await;
let state = f.commit(f.source());
let plan = f.plan(&state);
assert_eq!(plan, f.plan(&state));
let receipt = f.store.plugin_plan_create("host", "create", &plan).unwrap();
let json = plan.canonical_json().unwrap();
assert!(!json.contains("https://"));
assert!(!json.contains(f.dir.path().to_str().unwrap()));
let mut source = f.source();
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]);
f.commit(source);
assert_eq!(
f.store.plugin_plan_create("host", "create", &plan).unwrap(),
receipt
);
let saved = f.store.plugin_plan_load("a", &receipt.plan_id).unwrap();
assert_eq!(saved.plan, plan);
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
assert_eq!(
reopened.plugin_plan_load("a", &receipt.plan_id).unwrap(),
saved
);
reopened
.plugin_plan_transition(
"a",
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Released,
)
.unwrap();
assert_eq!(
reopened
.plugin_plan_create("host", "create", &plan)
.unwrap(),
receipt
);
}
#[tokio::test]
async fn profile_override_is_closed_schema_validated_and_plan_only_uses_effective_config() {
let f = Fixture::new().await;
let mut source = f.source();
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
json!({"mode":"override"});
let state = f.commit(source.clone());
let plan = f.plan(&state);
assert_eq!(
plan.plugins()[0].projection().config,
json!({"mode":"override"})
);
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
json!({"unknown":"not permitted"});
assert!(f.candidate("a", source).is_err());
}
#[tokio::test]
async fn public_create_rejects_stale_or_forged_config_profile_and_projection_without_pins() {
use crate::plugin_plan::WorkerPluginExecutionPlan;
let f = Fixture::new().await;
let state = f.commit(f.source());
let plan = f.plan(&state);
for pointer in [
"/input/config/content_digest",
"/input/profile/projection_digest",
"/input/plugins/0/config_digest",
] {
let mut forged = serde_json::to_value(&plan).unwrap();
*forged.pointer_mut(pointer).unwrap() = json!(config_source::digest_bytes(b"forged"));
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
assert!(
f.store
.plugin_plan_create("host", pointer, &forged)
.is_err()
);
}
let mut forged = serde_json::to_value(&plan).unwrap();
forged["input"]["plugins"][0]["projection"]["config"] = json!({"mode":"caller-injected"});
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
assert!(
f.store
.plugin_plan_create("host", "value-injection", &forged)
.is_err()
);
let mut next = f.source();
next["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]);
f.commit(next);
assert!(
f.store
.plugin_plan_create("host", "stale-plan", &plan)
.is_err()
);
f.store
.with_conn(|c| {
assert_eq!(
c.query_row("SELECT count(*) FROM plugin_execution_plans", [], |r| r
.get::<_, i64>(0))?,
0
);
assert_eq!(
c.query_row(
"SELECT count(*) FROM plugin_references WHERE kind != 'config'",
[],
|r| r.get::<_, i64>(0)
)?,
0
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn config_persistence_failure_rolls_back_reference_changes_and_restart_keeps_management_after_revoke()
{
let f = Fixture::new().await;
let before = f.store.load_workspace_config("a").unwrap().unwrap();
let candidate = f.candidate("a", f.source()).unwrap();
f.store.with_conn(|c| { c.execute_batch("CREATE TRIGGER stop_config BEFORE INSERT ON workspace_config_entries BEGIN SELECT RAISE(ABORT,'config failure'); END;")?; Ok(()) }).unwrap();
assert!(
f.store
.commit_evaluated_workspace_config("a", &candidate)
.is_err()
);
assert_eq!(f.refs(), 0);
assert_eq!(f.store.load_workspace_config("a").unwrap().unwrap(), before);
f.store
.with_conn(|c| {
c.execute_batch("DROP TRIGGER stop_config")?;
Ok(())
})
.unwrap();
let committed = f
.store
.commit_evaluated_workspace_config("a", &candidate)
.unwrap();
let a = &f.installed.installation.artifacts[0];
f.store
.plugin_revoke(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginRevokeRequest {
artifact_digest: a.artifact_digest.clone(),
expected_last_operation: f.installed.operation_id.clone(),
reason: "withdraw".into(),
operation_key: "withdraw".into(),
},
)
.unwrap();
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
assert_eq!(
reopened
.ensure_workspace_config_materialized_with_schema("a", "2", bundle())
.unwrap(),
committed
);
assert!(f.candidate("a", f.source()).is_err());
f.commit(json!({"plugins":{},"profile":{"entries":[]}}));
assert_eq!(f.refs(), 0);
}
#[tokio::test]
async fn https_config_binding_is_validated_pinned_by_digest_and_never_copied_to_plan() {
use crate::plugin_plan::WorkerPluginExecutionPlan;
use manifest::plugin::*;
let f = Fixture::new().await;
let d = tempdir().unwrap();
std::fs::write(
d.path().join("plugin.toml"),
include_str!("../../../../resources/plugin/templates/rust-component-https/plugin.toml"),
)
.unwrap();
std::fs::write(
d.path().join("plugin.component.wasm"),
include_bytes!("../../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"),
)
.unwrap();
let limits = PluginPackageLimits::default();
let bytes = pack_plugin_package(
&read_plugin_directory(d.path(), PluginSourceKind::Explicit, &limits).unwrap(),
&limits,
)
.unwrap()
.bytes;
let upload = f
.store
.plugin_upload(&f.dir.path().join("artifacts"), "a", "owner", &bytes)
.unwrap();
let installed = f
.store
.plugin_install(
"a",
"owner",
&PluginInstallRequest {
upload_id: upload.upload_id,
alias: "https".into(),
operation_key: "https-install".into(),
},
)
.unwrap();
let artifact = &installed.installation.artifacts[0];
let mut source = f.source();
source["plugins"]["enabled"]["echo"] = json!({"installation_id":installed.installation.installation_id,"expected_last_operation":installed.operation_id,"package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest,"tools":["example_status"],"connections":{"api":"remote"},"config":{}});
source["plugins"]["bindings"] = json!({"remote":{"origin":"https://api.example.com","methods":["GET"],"path_prefixes":["/status"],"request_headers":["accept"],"response_headers":["content-type"],"secret_ref":"credential_ref","limits":{"timeout_ms":1000,"max_request_bytes":1024,"max_response_bytes":1024,"max_header_bytes":512}}});
source["profile"]["entries"][0]["profile"]["plugins"]["selections"] = json!([{"installation_id":installed.installation.installation_id,"tools":["example_status"],"https_slots":["api"]}]);
let state = f.commit(source.clone());
let plan = f.plan(&state);
let encoded = plan.canonical_json().unwrap();
for excluded in [
"https://api.example.com",
"credential_ref",
"origin",
"request_headers",
"secret_ref",
] {
assert!(!encoded.contains(excluded), "leaked {excluded}");
}
assert_eq!(
plan.plugins()[0].projection().bindings[0].binding_id,
"remote"
);
let receipt = f
.store
.plugin_plan_create("host", "https-plan", &plan)
.unwrap();
let mut forged = serde_json::to_value(&plan).unwrap();
forged["input"]["plugins"][0]["projection"]["bindings"][0]["policy_digest"] =
json!(config_source::digest_bytes(b"forged-policy"));
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
assert!(
f.store
.plugin_plan_create("host", "forged-policy", &forged)
.is_err()
);
for path in ["/statusish", "/%2e%2e/private"] {
let mut bad = source.clone();
bad["plugins"]["bindings"]["remote"]["path_prefixes"] = json!([path]);
assert!(f.candidate("a", bad).is_err());
}
source["plugins"]["bindings"]["remote"]["origin"] = json!("https://other.example.com");
f.commit(source);
assert!(
f.store
.plugin_plan_create("host", "stale-binding", &plan)
.is_err()
);
assert_eq!(
f.store
.plugin_plan_create("host", "https-plan", &plan)
.unwrap(),
receipt
);
}
#[tokio::test]
async fn nested_profile_override_pins_evaluated_singletons_through_create_restart_and_replay() {
use crate::plugin_plan::{WorkerPluginExecutionPlan, WorkerPluginPlanInput};
let f = Fixture::with_schema("{ nested = { mode = String; count = 1; }; }").await;
let mut source = f.source();
source["plugins"]["enabled"]["echo"]["config"] = json!({"nested":{"mode":"base","count":1}});
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
json!({"nested":{"mode":"override"}});
let state = f.commit(source);
let plan = f.plan(&state);
let expected = json!({"nested":{"count":1,"mode":"override"}});
assert_eq!(plan.plugins()[0].projection().config, expected);
assert_eq!(
serde_json::to_value(&plan).unwrap()["input"]["plugins"][0]["config_digest"],
config_source::digest_bytes(&serde_json::to_vec(&expected).unwrap())
);
let receipt = f
.store
.plugin_plan_create("host", "singletons", &plan)
.unwrap();
assert_eq!(receipt.plan.plugins()[0].projection().config, expected);
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
let stored = reopened.plugin_plan_load("a", &receipt.plan_id).unwrap();
assert_eq!(stored.plan, plan);
assert_eq!(
reopened
.plugin_plan_create("host", "singletons", &plan)
.unwrap(),
receipt
);
// Even a canonically rehashed candidate cannot substitute the raw override
// for the complete projection reconstructed at the public create fence.
let mut forged = serde_json::to_value(&plan).unwrap()["input"].clone();
forged["plugins"][0]["projection"]["config"] = json!({"nested":{"mode":"override"}});
let input: WorkerPluginPlanInput = serde_json::from_value(forged).unwrap();
let incomplete = WorkerPluginExecutionPlan::canonicalize(input).unwrap();
assert!(
reopened
.plugin_plan_create("host", "raw-override", &incomplete)
.is_err()
);
}
#[test]
fn effective_config_size_limit_applies_after_schema_concretization() {
let schema = format!("{{ value = {}; }}", json!("a".repeat(64 * 1024)));
let error = evaluate_effective_config(&json!({}), &schema).unwrap_err();
assert!(
error
.to_string()
.contains("non-secret config exceeds 64 KiB"),
"{error}"
);
}
@@ -106,6 +106,10 @@ mod schema_convergence {
}, },
WorkspaceSchemaMigrationStep { WorkspaceSchemaMigrationStep {
version: 107, version: 107,
name: "Immutable Worker Plugin plans and retained pins".into(),
},
WorkspaceSchemaMigrationStep {
version: 108,
name: "Subject Ticket responsibility and current-body notification routing".into(), name: "Subject Ticket responsibility and current-body notification routing".into(),
}, },
] ]
@@ -125,7 +129,8 @@ mod schema_convergence {
expected.push((104, "Converge Passkey results and Job input retention".into())); expected.push((104, "Converge Passkey results and Job input retention".into()));
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into())); expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into())); expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
expected.push((107, "Subject Ticket responsibility and current-body notification routing".into())); expected.push((107, "Immutable Worker Plugin plans and retained pins".into()));
expected.push((108, "Subject Ticket responsibility and current-body notification routing".into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected); assert_eq!(workspace_schema_migration_history(conn)?, expected);
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] { for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}"); assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
@@ -1,8 +1,11 @@
// Included in store::tests so both paths use the real retained migration chain. // Included in store::tests so both paths use the real retained migration chain.
fn state_contract_schema_signature(conn: &Connection, table: &str) -> Vec<Vec<String>> { fn state_contract_schema_signature(conn: &Connection, table: &str) -> Vec<Vec<String>> {
// Retained host generations alone add this exact Workspace-parent FK to
// the three principal tables. All other FKs (including composite owner FKs)
// remain in the signature; do not discard foreign-key contracts wholesale.
let queries = [ let queries = [
"SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, '<NULL>'), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name", "SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, '<NULL>'), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name",
"SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\", \"from\", \"to\", seq", "SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) WHERE NOT (?1 IN ('ticket_worker_assignments','ticket_current_worker_assignments','ticket_assignment_operations') AND \"table\"='workspaces' AND \"from\"='workspace_id' AND \"to\"='workspace_id' AND on_update='NO ACTION' AND on_delete='CASCADE' AND match='NONE' AND seq=0) ORDER BY \"table\", \"from\", \"to\", seq",
"SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3", "SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3",
"SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name", "SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name",
]; ];
@@ -52,8 +55,16 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
"typed_ticket_case_history", "typed_ticket_case_history",
"typed_ticket_legacy_workflow_snapshot", "typed_ticket_legacy_workflow_snapshot",
"ticket_current_worker_assignments", "ticket_current_worker_assignments",
"ticket_worker_assignments",
"ticket_assignment_operations",
"ticket_assignment_work_releases", "ticket_assignment_work_releases",
"artifacts", "artifacts",
"plugin_installations",
"plugin_artifacts",
"plugin_uploads",
"plugin_operations",
"plugin_references",
"plugin_execution_plans",
"workspace_config_trees", "workspace_config_trees",
"workspace_config_tree_history", "workspace_config_tree_history",
"workspace_memory_settings", "workspace_memory_settings",
+33 -1
View File
@@ -4425,7 +4425,8 @@ impl WorkspaceApi {
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
.with_provider(Arc::new(RuntimeConfigSchemaProvider)) .with_provider(Arc::new(RuntimeConfigSchemaProvider))
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
.with_provider(Arc::new(skills::SkillConfigSchemaProvider)); .with_provider(Arc::new(skills::SkillConfigSchemaProvider))
.with_provider(Arc::new(crate::plugin_settings::PluginConfigSchemaProvider));
let materialization_store = config_store.clone(); let materialization_store = config_store.clone();
let workspace_id = config.workspace_id.clone(); let workspace_id = config.workspace_id.clone();
let schema = config_schema_registry.compose()?; let schema = config_schema_registry.compose()?;
@@ -7510,6 +7511,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::workspace_prompt_projection( .merge(server_api::server_api_axum::workspace_prompt_projection(
service.clone(), service.clone(),
)) ))
.merge(server_api::server_api_axum::workspace_config_tree_preview(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_config_tree_commit( .merge(server_api::server_api_axum::workspace_config_tree_commit(
service.clone(), service.clone(),
)) ))
@@ -9358,6 +9362,34 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error) .map_err(ApiError::into_repository_api_error)
} }
async fn workspace_config_tree_preview(
&self,
workspace_id: String,
request: server_api::ConfigCommitRequest,
) -> std::result::Result<server_api::WorkspaceConfigTreeResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?.clone();
api.store
.db_executor()
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let candidate = prepare_workspace_config_tree(
&api,
&workspace_id,
&config_commit_request_from_api(request)?,
)?;
Ok(workspace_config_state_to_api(
crate::config_source::WorkspaceConfigState {
snapshot: candidate.snapshot,
contract: candidate.contract,
projection_digest: candidate.evaluation.projection_digest,
},
))
})
.await
.map_err(|error: ApiError| error.into_repository_api_error())
}
async fn workspace_config_tree_commit( async fn workspace_config_tree_commit(
&self, &self,
workspace_id: String, workspace_id: String,
@@ -412,3 +412,32 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
assert_eq!(bytes, package_bytes("0.1.0")); assert_eq!(bytes, package_bytes("0.1.0"));
shutdown_workspace_feature_storage(&reopened).await.unwrap(); shutdown_workspace_feature_storage(&reopened).await.unwrap();
} }
#[tokio::test]
async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() {
let dir=tempfile::tempdir().unwrap();
let api=test_api(dir.path()).await;
let owner=seed_test_workspace_owner_token(api.store.as_ref(),"plugin-config-owner").await;
let reader=seed_test_api_token(api.store.as_ref(),"plugin-config-reader");
let app=build_router(api.clone());
let prefix=format!("/api/w/{TEST_WORKSPACE_ID}");
let upload=request(app.clone(),Some(&owner),"POST",&format!("{prefix}/plugin-uploads"),package_bytes("0.1.0"),true,StatusCode::OK).await;
let installed=json(app.clone(),&owner,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"configured","operation_key":"config-install"}),StatusCode::OK).await;
let state=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
let a=&installed["installation"]["artifacts"][0];
let source=format!("{{ plugins.enabled.echo = {{ installation_id = {}; expected_last_operation = {}; package_digest = {}; artifact_digest = {}; tools = [\"example_echo\"]; config = {{}}; }}; }} as WorkspaceConfigSchema",installed["installation"]["installation_id"],installed["operation_id"],a["package_digest"],a["artifact_digest"]);
let change=serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]});
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::FORBIDDEN).await;
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::FORBIDDEN).await;
let preview=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::OK).await;
let unchanged=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
assert_eq!(unchanged["snapshot"]["digest"],state["snapshot"]["digest"]);
let saved=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::CREATED).await;
assert_eq!(saved,preview);
assert_ne!(saved["snapshot"]["digest"],state["snapshot"]["digest"]);
json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change,StatusCode::CONFLICT).await;
json(app.clone(),&owner,&format!("{prefix}/plugins/{}/uninstall",installed["installation"]["installation_id"].as_str().unwrap()),serde_json::json!({"expected_last_operation":installed["operation_id"],"operation_key":"config-blocks-uninstall"}),StatusCode::CONFLICT).await;
let current=api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
let candidates=current.contract.schema_bundle.contributions.iter().filter(|c|c.provider_id=="builtin:plugins").count();
assert_eq!(candidates,1);
}
@@ -908,6 +908,18 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr
released, released,
}; };
observed(waiting).await.unwrap(); // actual SQL transaction owns the Feature connection observed(waiting).await.unwrap(); // actual SQL transaction owns the Feature connection
// Force close to be queued before the sole DB worker can start it. Pending
// admission alone must not be mistaken for an available second queue slot.
let (started, running) = oneshot::channel();
let (start_close, held) = mpsc::channel();
fixture
.executor
.dispatch(move || {
let _ = started.send(());
let _ = held.recv_timeout(WATCHDOG);
})
.unwrap();
observed(running).await.unwrap();
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut close = Box::pin(crate::db_executor::with_operation_lifetime( let mut close = Box::pin(crate::db_executor::with_operation_lifetime(
Arc::new(CountJobLifetime(leases.clone())), Arc::new(CountJobLifetime(leases.clone())),
@@ -917,6 +929,30 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr
futures::poll!(&mut close).is_pending(), futures::poll!(&mut close).is_pending(),
"Feature connection close must not wait on Tokio" "Feature connection close must not wait on Tokio"
); );
assert!(matches!(
fixture.executor.dispatch(|| {}),
Err(Error::DbCapacityExceeded)
));
start_close.send(()).unwrap();
let fenced = observed(async {
loop {
match fixture
.api
.feature_storage
.open(&fixture.api.subjektiv_registration)
{
Err(crate::FeatureStorageError::ShuttingDown) => break,
Ok(_) => tokio::task::yield_now().await,
Err(error) => panic!("unexpected Feature admission failure: {error}"),
}
}
});
tokio::select! {
_ = fenced => {},
result = &mut close => panic!("close completed while its connection is held: {result:?}"),
}
// Observing the real shutdown fence proves close was dequeued and started;
// only now can the one waiting slot be filled deterministically.
runtime_progress_while_locked(&lock).await; runtime_progress_while_locked(&lock).await;
assert_eq!( assert_eq!(
observed(fixture.host_executor.run(|| Ok::<_, Error>("control"))) observed(fixture.host_executor.run(|| Ok::<_, Error>("control")))
@@ -595,7 +595,10 @@ pub(super) async fn commit(
if candidate.base_digest != state.snapshot.digest if candidate.base_digest != state.snapshot.digest
|| candidate.base_toolchain_fingerprint != state.contract.fingerprint || candidate.base_toolchain_fingerprint != state.contract.fingerprint
|| candidate.base_projection_digest != state.projection_digest || candidate.base_projection_digest != state.projection_digest
|| candidate.contract.schema_bundle.fingerprint != schema_fingerprint || crate::plugin_settings::base_schema_bundle(&candidate.contract.schema_bundle)
.map_err(pre_error)?
.fingerprint
!= schema_fingerprint
{ {
return Err(fail(409, "stale_validator")); return Err(fail(409, "stale_validator"));
} }
+42 -9
View File
@@ -129,7 +129,7 @@ pub use ticket::{
}; };
const OLDEST_SCHEMA_VERSION: i64 = 50; const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 107; const LATEST_SCHEMA_VERSION: i64 = 108;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -469,8 +469,13 @@ const MIGRATIONS: &[Migration] = &[
}, },
Migration { Migration {
version: 107, version: 107,
name: "Immutable Worker Plugin plans and retained pins",
apply: migrate_plugin_plans_v106_to_v107,
},
Migration {
version: 108,
name: "Subject Ticket responsibility and current-body notification routing", name: "Subject Ticket responsibility and current-body notification routing",
apply: migrate_subject_assignments_v106_to_v107, apply: migrate_subject_assignments_v107_to_v108,
}, },
]; ];
@@ -15823,17 +15828,23 @@ fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn migrate_subject_assignments_v106_to_v107(conn: &Connection) -> Result<()> { fn migrate_subject_assignments_v107_to_v108(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 106 { if current_schema_version(conn)? != 107 {
return Err(Error::Store( return Err(Error::Store(
"Subject assignment migration requires schema 106".into(), "Subject assignment migration requires schema 107".into(),
)); ));
} }
// Refuse preexisting drift before the new tail can commit any changes.
// Migration 107 and its historical identity remain frozen.
verify_schema_history(conn, 107)?;
crate::plugin_catalog::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
subject_assignment_migration::install(&tx)?; subject_assignment_migration::install(&tx)?;
notification_routing::install_subject_recipients(&tx)?; notification_routing::install_subject_recipients(&tx)?;
subject_assignment_migration::verify(&tx)?;
tx.execute( tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,?1)", "INSERT INTO __yoi_schema_migrations(version,name) VALUES(108,?1)",
["Subject Ticket responsibility and current-body notification routing"], ["Subject Ticket responsibility and current-body notification routing"],
)?; )?;
tx.commit()?; tx.commit()?;
@@ -15858,12 +15869,26 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
}) })
} }
fn migrate_plugin_plans_v106_to_v107(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 106 {
return Err(Error::Store(
"Plugin plan migration requires schema 106".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,'Immutable Worker Plugin plans and retained pins')", [])?;
tx.commit()?;
Ok(())
}
// Fresh composition uses the posts contract. Retained fixtures pass frozen host // Fresh composition uses the posts contract. Retained fixtures pass frozen host
// and component DDL to the shared cross-domain constraint composition below. // and component DDL to the shared cross-domain constraint composition below.
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?; create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?; conn.execute_batch(include_str!("activation_schema.sql"))?;
conn.execute_batch(include_str!("passkey_results_schema.sql"))?; conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
Ok(()) Ok(())
} }
@@ -16905,6 +16930,7 @@ fn apply_migrations_with_secret_source(
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?; verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
crate::plugin_catalog::verify(conn)?; crate::plugin_catalog::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
subject_assignment_migration::verify(conn)?; subject_assignment_migration::verify(conn)?;
verify_workspace_runtime_binding_schema(conn)?; verify_workspace_runtime_binding_schema(conn)?;
verify_workspace_runtime_verification_schema(conn)?; verify_workspace_runtime_verification_schema(conn)?;
@@ -19601,7 +19627,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()}, WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()},
WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()}, WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()},
WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()}, WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
WorkspaceSchemaMigrationStep {version:107, name:"Subject Ticket responsibility and current-body notification routing".into()}, WorkspaceSchemaMigrationStep {version:107, name:"Immutable Worker Plugin plans and retained pins".into()},
WorkspaceSchemaMigrationStep {version:108, name:"Subject Ticket responsibility and current-body notification routing".into()},
] ]
); );
@@ -19718,7 +19745,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(104, "Converge Passkey results and Job input retention".to_string()), (104, "Converge Passkey results and Job input retention".to_string()),
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()), (105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()), (106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
(107, "Subject Ticket responsibility and current-body notification routing".to_string()), (107, "Immutable Worker Plugin plans and retained pins".to_string()),
(108, "Subject Ticket responsibility and current-body notification routing".to_string()),
] ]
); );
assert!(!table_exists(conn, "trusted_runtime_records")?); assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -23007,7 +23035,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(104, "Converge Passkey results and Job input retention".into()), (104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into()), (105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into()), (106, crate::plugin_catalog::MIGRATION_NAME.into()),
(107, "Subject Ticket responsibility and current-body notification routing".into())]); (107, "Immutable Worker Plugin plans and retained pins".into()),
(108, "Subject Ticket responsibility and current-body notification routing".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) { for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
} }
@@ -28551,3 +28580,7 @@ mod frozen_runtime_binding_tests;
#[cfg(test)] #[cfg(test)]
#[path = "plugin_catalog/migration_tests.rs"] #[path = "plugin_catalog/migration_tests.rs"]
mod plugin_catalog_migration_tests; mod plugin_catalog_migration_tests;
#[cfg(test)]
#[path = "plugin_plan/migration_tests.rs"]
mod plugin_plan_migration_tests;
@@ -27,9 +27,14 @@ mod subject_assignment_migration_tests {
conn.execute_batch("DELETE FROM __yoi_schema_migrations; conn.execute_batch("DELETE FROM __yoi_schema_migrations;
INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,'workspace schema baseline');").unwrap(); INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,'workspace schema baseline');").unwrap();
} }
seed_workspace(&conn); seed_legacy_evidence(&conn);
(directory, path, conn)
}
fn seed_legacy_evidence(conn: &Connection) {
seed_workspace(conn);
for status in ["waiting", "inprogress", "done", "unknown", "released"] { for status in ["waiting", "inprogress", "done", "unknown", "released"] {
seed_ticket(&conn, status); seed_ticket(conn, status);
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
VALUES('w',?1,?1,'orchestrator','workspace_agent','workspace-orchestrator','original actor','original time')",[status]).unwrap(); VALUES('w',?1,?1,'orchestrator','workspace_agent','workspace-orchestrator','original actor','original time')",[status]).unwrap();
if status == "unknown" { if status == "unknown" {
@@ -72,14 +77,16 @@ mod subject_assignment_migration_tests {
VALUES('w','waiting','implementation','worker','worker','r','worker','original assigner','original time'); VALUES('w','waiting','implementation','worker','worker','r','worker','original assigner','original time');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,runtime_id,worker_id,updated_at) INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,runtime_id,worker_id,updated_at)
VALUES('w','waiting','worker','implementation','worker','r','worker','original time'); VALUES('w','waiting','worker','implementation','worker','r','worker','original time');
INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting'); INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap();
if table_exists(conn, "ticket_implementation_results").unwrap() {
conn.execute_batch(" INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting');
INSERT INTO ticket_implementation_results VALUES('w','waiting','result-op','saved-result','r','worker','{ \"body\" : \"original result\", \"kind\" : \"result\" }','result time'); INSERT INTO ticket_implementation_results VALUES('w','waiting','result-op','saved-result','r','worker','{ \"body\" : \"original result\", \"kind\" : \"result\" }','result time');
INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose)
SELECT sequence,'w','saved-notification','deliver','original reason','attention' FROM notification_sources WHERE input_event_id='implementation-result:saved-result'; SELECT sequence,'w','saved-notification','deliver','original reason','attention' FROM notification_sources WHERE input_event_id='implementation-result:saved-result';
INSERT INTO notification_requests VALUES('w','saved-notification','original common request','{ \"target\" : \"old body\" }'); INSERT INTO notification_requests VALUES('w','saved-notification','original common request','{ \"target\" : \"old body\" }');
INSERT INTO notification_acknowledgements VALUES('w','saved-notification','{ \"accepted\" : \"old body\" }'); INSERT INTO notification_acknowledgements VALUES('w','saved-notification','{ \"accepted\" : \"old body\" }');
INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time'); INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');").unwrap();
INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap(); }
// A live body candidate and the old singleton exist simultaneously. A // A live body candidate and the old singleton exist simultaneously. A
// migration must not infer stable-agent responsibility from either one. // migration must not infer stable-agent responsibility from either one.
conn.execute_batch("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) conn.execute_batch("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
@@ -88,7 +95,6 @@ mod subject_assignment_migration_tests {
VALUES('w','r','body','Body','normal','1','2'); VALUES('w','r','body','Body','normal','1','2');
INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
VALUES('w','subjektiv:tempting-subject','r','body',1,'1','2');").unwrap(); VALUES('w','subjektiv:tempting-subject','r','body',1,'1','2');").unwrap();
(directory, path, conn)
} }
fn seed_workspace(conn: &Connection) { fn seed_workspace(conn: &Connection) {
@@ -407,9 +413,7 @@ mod subject_assignment_migration_tests {
}; };
assert_eq!(workspace_parents(new)?, 0, "fresh standalone-first preimage: {table}"); assert_eq!(workspace_parents(new)?, 0, "fresh standalone-first preimage: {table}");
assert_eq!(workspace_parents(&conn)?, if table=="ticket_current_worker_assignments" {0} else {1}, "retained Workspace preimage: {table}"); assert_eq!(workspace_parents(&conn)?, if table=="ticket_current_worker_assignments" {0} else {1}, "retained Workspace preimage: {table}");
let contract = |db: &Connection| state_contract_schema_signature(db,table).into_iter() let contract = |db: &Connection| state_contract_schema_signature(db,table);
.filter(|row| !row.first().is_some_and(|name| name=="workspaces"))
.collect::<Vec<_>>();
assert_eq!(contract(&conn),contract(new),"{table}"); assert_eq!(contract(&conn),contract(new),"{table}");
for db in [&conn,new] { for db in [&conn,new] {
let ddl:String=db.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",[table],|row|row.get(0))?; let ddl:String=db.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",[table],|row|row.get(0))?;
@@ -839,4 +843,545 @@ mod subject_assignment_migration_tests {
assert_eq!(conn.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE operation_id='cached-subject'", [], |row| row.get::<_, String>(0)).unwrap(), "cached fingerprint"); assert_eq!(conn.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE operation_id='cached-subject'", [], |row| row.get::<_, String>(0)).unwrap(), "cached fingerprint");
assert_subject_contract(&conn); assert_subject_contract(&conn);
} }
mod subject_assignment_final_tail {
use super::*;
use crate::plugin_plan::{
self, PlanPinState, PluginPlanCreateReceipt, WorkerPluginExecutionPlan,
WorkerPluginPlanInput,
};
const PLAN_NAME: &str = "Immutable Worker Plugin plans and retained pins";
const SUBJECT_NAME: &str =
"Subject Ticket responsibility and current-body notification routing";
type Rows = Vec<Vec<rusqlite::types::Value>>;
fn raw(path: &Path) -> Connection {
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
conn
}
fn retained(version: i64) -> (tempfile::TempDir, std::path::PathBuf, Connection) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tail.db");
prepare_retained_schema(&path, version);
let conn = raw(&path);
seed_legacy_evidence(&conn);
assert_component13(&conn);
assert!(!table_exists(&conn, "plugin_execution_plans").unwrap());
assert_eq!(
table_exists(&conn, "plugin_installations").unwrap(),
version >= 106
);
(dir, path, conn)
}
// Explicit data dependencies only. Real 107 DDL is installed by the frozen
// migration, not copied from a latest store or relabelled after downgrade.
async fn populated107() -> (
tempfile::TempDir,
std::path::PathBuf,
Connection,
Vec<(String, PluginPlanCreateReceipt, PlanPinState)>,
) {
let (dir, path, conn) = retained(106);
migrate_plugin_plans_v106_to_v107(&conn).unwrap();
let (_donor_dir, donor, receipts) =
SqliteWorkspaceStore::plugin_plan_migration_donor().await;
for table in [
"accounts",
"workspaces",
"workspace_config_trees",
"workspace_config_entries",
"workspace_config_tree_history",
"plugin_installations",
"plugin_artifacts",
"plugin_uploads",
"plugin_operations",
"plugin_execution_plans",
"plugin_references",
] {
let columns = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
let names = columns.join(",");
let rows = donor
.with_conn(|source| {
Ok(fixture_query_rows(
source,
&format!("SELECT {names} FROM {table} ORDER BY rowid"),
))
})
.unwrap();
for row in rows {
conn.execute(
&format!(
"INSERT INTO {table} ({names}) VALUES ({})",
vec!["?"; columns.len()].join(",")
),
rusqlite::params_from_iter(row),
)
.unwrap();
}
}
assert_eq!(
workspace_schema_migration_history(&conn).unwrap().last(),
Some(&(107, PLAN_NAME.into()))
);
assert_pre_subject(&conn, 107);
crate::plugin_catalog::verify(&conn).unwrap();
plugin_plan::verify_plan_schema(&conn).unwrap();
for (_, receipt, state) in &receipts {
let loaded = plugin_plan::plugin_plan_load(&conn, "a", &receipt.plan_id).unwrap();
assert_eq!(loaded.plan, receipt.plan);
assert_eq!(loaded.pin_state, *state);
}
assert_health(&conn);
drop(conn);
let conn = raw(&path);
(dir, path, conn, receipts)
}
fn assert_pre_subject(conn: &Connection, version: i64) {
assert_eq!(current_schema_version(conn).unwrap(), version);
assert_component13(conn);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_schema WHERE name LIKE 'ticket_subject_%' OR name LIKE 'ticket_legacy_orchestrator_%'", [], |r|r.get::<_,i64>(0)).unwrap(), 0);
let kinds: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
kinds.contains("CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))")
);
assert!(!kinds.contains("'subject'"));
let capture: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name='notification_assignment_change'",
[],
|r| r.get(0),
)
.unwrap();
assert!(!capture.contains("'subject'"));
}
fn assert_health(conn: &Connection) {
assert_eq!(
fixture_query_rows(conn, "PRAGMA integrity_check"),
vec![vec![rusqlite::types::Value::Text("ok".into())]]
);
assert!(fixture_query_rows(conn, "PRAGMA foreign_key_check").is_empty());
assert_closed_schema_writes(conn);
}
fn host_history(conn: &Connection) -> Rows {
fixture_query_rows(
conn,
"SELECT rowid,* FROM __yoi_schema_migrations ORDER BY version",
)
}
// Save the exact source columns, including rowids and original JSON bytes.
// Only the owning diagnostic enrichment and two append-only ledgers are
// excluded from the success oracle; failures compare ALL data and schema.
fn data(conn: &Connection, preserved: bool) -> Vec<(String, Rows)> {
let tables = conn
.prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
tables
.into_iter()
.filter(|table| !preserved || !table.contains("schema_migrations"))
.map(|table| {
let columns = conn
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
.unwrap()
.query_map([], |r| r.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
.into_iter()
.filter(|column| {
!(preserved
&& table == "ticket_worker_assignments"
&& column == "tracking_migration_diagnostic")
})
.map(|column| format!("\"{column}\""))
.collect::<Vec<_>>()
.join(",");
// Later historical migrations may introduce a new sequence.
// Existing append identities, particularly notification sources,
// must not move; allow only newly introduced sequence names.
let sequence_filter = if preserved && table == "sqlite_sequence" {
let names = conn
.prepare("SELECT quote(name) FROM sqlite_sequence ORDER BY name")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
if names.is_empty() {
" WHERE 0".into()
} else {
format!(" WHERE name IN ({})", names.join(","))
}
} else {
String::new()
};
let query = format!(
"SELECT rowid,{columns} FROM \"{table}\"{sequence_filter} ORDER BY rowid"
);
let rows = fixture_query_rows(conn, &query);
(query, rows)
})
.collect()
}
fn assert_data(conn: &Connection, before: &[(String, Rows)]) {
for (query, rows) in before {
assert_eq!(&fixture_query_rows(conn, query), rows, "{query}");
}
}
fn snapshot(conn: &Connection) -> (i64, Rows, Vec<(String, Rows)>) {
(cookie(conn), schema(conn), data(conn, false))
}
fn assert_final(conn: &Connection, version: i64, host: &Rows, component: &Rows) {
let history = host_history(conn);
assert_eq!(&history[..host.len()], host);
let expected = expected_tail(version);
assert_eq!(
workspace_schema_migration_history(conn)
.unwrap()
.into_iter()
.filter(|(v, _)| *v > version)
.collect::<Vec<_>>(),
expected
);
assert_eq!(history.len(), host.len() + expected.len());
assert_component14_append(conn, component);
crate::plugin_catalog::verify(conn).unwrap();
plugin_plan::verify_plan_schema(conn).unwrap();
verify(conn).unwrap();
assert_health(conn);
// Reuse the owner's real constraint probe, rolled back so it is not
// confused with migration writes or preserved notification evidence.
let tx = conn.unchecked_transaction().unwrap();
assert_subject_contract(&tx);
tx.rollback().unwrap();
for status in ["waiting", "inprogress", "done", "unknown", "released"] {
let actual: String = conn.query_row("SELECT tracking_migration_diagnostic FROM ticket_worker_assignments WHERE assignment_id=?1", [status], |r|r.get(0)).unwrap();
assert_eq!(
actual,
if status == "unknown" {
format!("Existing ambiguity\n{LEGACY_DIAGNOSTIC}")
} else {
LEGACY_DIAGNOSTIC.into()
}
);
}
}
fn expected_tail(version: i64) -> Vec<(i64, String)> {
match version {
96 => vec![
(
97,
"MR notifications and authenticated implementation results".into(),
),
(98, "Assignment-independent implementation reports".into()),
(
99,
"Explicit hosting administrators and self-registration policy".into(),
),
(
100,
"Provisional users and scoped passkey activation".into(),
),
(101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into()),
(
104,
"Converge Passkey results and Job input retention".into(),
),
(
105,
"Durable Subject deletion snapshots and Job admission fences".into(),
),
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
(107, PLAN_NAME.into()),
(108, SUBJECT_NAME.into()),
],
106 => vec![(107, PLAN_NAME.into()), (108, SUBJECT_NAME.into())],
107 => vec![(108, SUBJECT_NAME.into())],
_ => panic!("unsupported fixture origin"),
}
}
fn plan_steps(version: i64) -> Vec<WorkspaceSchemaMigrationStep> {
expected_tail(version)
.into_iter()
.map(|(version, name)| WorkspaceSchemaMigrationStep { version, name })
.collect()
}
#[test]
fn retained96_and106_preflight_apply_and_reopen_preserve_original_evidence() {
for version in [96, 106] {
let (_dir, path, conn) = retained(version);
assert_pre_subject(&conn, version);
let before = snapshot(&conn);
let rows = data(&conn, true);
let host = host_history(&conn);
let component = component_history(&conn);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, version);
assert_eq!(plan.migrations, plan_steps(version));
assert_eq!(
snapshot(&conn),
before,
"read-only preflight changed source"
);
drop(conn);
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
let conn = raw(&path);
assert_data(&conn, &rows);
assert_final(&conn, version, &host, &component);
let final_snapshot = snapshot(&conn);
drop(conn);
drop(SqliteWorkspaceStore::open_for_test(&path).unwrap());
let conn = raw(&path);
assert_eq!(snapshot(&conn), final_snapshot);
assert!(
SqliteWorkspaceStore::migration_plan(&path)
.unwrap()
.migrations
.is_empty()
);
}
}
fn plugin_schema(conn: &Connection) -> Rows {
fixture_query_rows(
conn,
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' ORDER BY type,name",
)
}
#[tokio::test]
async fn applied107_only108_preserves_canonical_plans_pins_and_released_replay() {
let (_dir, path, conn, receipts) = populated107().await;
let before = snapshot(&conn);
let rows = data(&conn, true);
let plans = plugin_schema(&conn);
let contracts = principal_table_contracts(&conn);
let host = host_history(&conn);
let component = component_history(&conn);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 107);
assert_eq!(
plan.migrations,
vec![WorkspaceSchemaMigrationStep {
version: 108,
name: SUBJECT_NAME.into()
}]
);
assert_eq!(snapshot(&conn), before);
drop(conn);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = raw(&path);
assert_data(&conn, &rows);
assert_final(&conn, 107, &host, &component);
assert_eq!(plugin_schema(&conn), plans);
assert_eq!(principal_table_contracts(&conn), contracts);
let final_snapshot = snapshot(&conn);
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let conn = raw(&path);
assert_eq!(snapshot(&conn), final_snapshot);
for (key, receipt, state) in receipts {
let saved = store.plugin_plan_load("a", &receipt.plan_id).unwrap();
assert_eq!(saved.plan, receipt.plan);
assert_eq!(saved.fingerprint, receipt.fingerprint);
assert_eq!(saved.pin_state, state);
assert_eq!(
store
.plugin_plan_create("actor", &key, &receipt.plan)
.unwrap(),
receipt
);
if state == PlanPinState::Released {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM plugin_references WHERE owner=?1",
[&receipt.plan_id],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
}
let changed = WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput {
workspace_id: "a".into(),
config: receipt.plan.config().clone(),
profile: receipt.plan.profile().clone(),
execution: crate::plugin_plan::HostExecutionIdentity {
worker_id: "changed-worker".into(),
..receipt.plan.execution().clone()
},
plugins: receipt.plan.plugins().to_vec(),
})
.unwrap();
assert!(matches!(
store.plugin_plan_create("actor", &key, &changed),
Err(Error::PluginCatalog(
crate::plugin_catalog::PluginCatalogError::Conflict
))
));
}
assert_data(&conn, &rows);
}
fn interrupt108(conn: &Connection) {
conn.execute_batch("CREATE TRIGGER interrupt_subject_tail BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=108 BEGIN SELECT RAISE(ABORT,'subject tail marker failure'); END;").unwrap();
}
#[tokio::test]
async fn marker108_failure_rolls_back_all_subject_changes_retains107_and_raw_retries() {
let (_dir, path, conn, _receipts) = populated107().await;
interrupt108(&conn);
let before = snapshot(&conn);
let host = host_history(&conn);
let component = component_history(&conn);
assert!(SqliteWorkspaceStore::migration_plan(&path).is_err());
assert_eq!(snapshot(&conn), before);
let error = apply_migrations(&conn).unwrap_err();
assert!(
error.to_string().contains("subject tail marker failure"),
"{error}"
);
assert_eq!(snapshot(&conn), before);
assert_pre_subject(&conn, 107);
assert_health(&conn);
drop(conn);
let conn = raw(&path);
assert_eq!(snapshot(&conn), before);
conn.execute_batch("DROP TRIGGER interrupt_subject_tail")
.unwrap();
assert_eq!(
SqliteWorkspaceStore::migration_plan(&path)
.unwrap()
.migrations,
plan_steps(107)
);
apply_migrations(&conn).unwrap();
assert_final(&conn, 107, &host, &component);
}
#[test]
fn real106_commits107_before108_failure_but_dryrun_never_changes_source() {
let (_dir, path, conn) = retained(106);
interrupt108(&conn);
let before = snapshot(&conn);
let host = host_history(&conn);
let component = component_history(&conn);
let capture = fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' AND type='trigger' ORDER BY name",
);
assert!(SqliteWorkspaceStore::migration_plan(&path).is_err());
assert_eq!(snapshot(&conn), before);
assert!(SqliteWorkspaceStore::migrate_database(&path).is_err());
assert_eq!(snapshot(&conn), before);
assert!(
apply_migrations(&conn)
.unwrap_err()
.to_string()
.contains("subject tail marker failure")
);
assert_pre_subject(&conn, 107);
assert!(table_exists(&conn, "plugin_execution_plans").unwrap());
plugin_plan::verify_plan_schema(&conn).unwrap();
let history = host_history(&conn);
assert_eq!(&history[..host.len()], &host);
assert_eq!(history.len(), host.len() + 1);
assert_eq!(
workspace_schema_migration_history(&conn).unwrap().last(),
Some(&(107, PLAN_NAME.into()))
);
assert_eq!(component_history(&conn), component);
assert_eq!(
fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' AND type='trigger' ORDER BY name"
),
capture
);
assert_health(&conn);
let failed = snapshot(&conn);
drop(conn);
let conn = raw(&path);
assert_eq!(snapshot(&conn), failed);
conn.execute_batch("DROP TRIGGER interrupt_subject_tail")
.unwrap();
assert_eq!(
SqliteWorkspaceStore::migration_plan(&path)
.unwrap()
.migrations,
plan_steps(107)
);
apply_migrations(&conn).unwrap();
assert_final(&conn, 106, &host, &component);
}
#[tokio::test]
async fn preexisting_plan_or_catalog_drift_refuses108_without_repair() {
for trigger in ["plugin_plan_snapshot_immutable", "plugin_reference_live"] {
let (_dir, path, conn, _receipts) = populated107().await;
conn.execute_batch(&format!("DROP TRIGGER {trigger}"))
.unwrap();
let before = snapshot(&conn);
assert!(SqliteWorkspaceStore::migration_plan(&path).is_err());
assert_eq!(snapshot(&conn), before);
assert!(apply_migrations(&conn).is_err());
assert_eq!(snapshot(&conn), before);
assert_pre_subject(&conn, 107);
drop(conn);
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
assert_eq!(snapshot(&raw(&path)), before);
}
}
#[tokio::test]
async fn current_missing_host_guard_or_plan_drift_reopen_refuses_without_repair() {
for trigger in [
"ticket_legacy_orchestrator_delivery_no_insert",
"ticket_legacy_orchestrator_start_no_insert",
"plugin_plan_snapshot_immutable",
] {
let (_dir, path, conn, _receipts) = populated107().await;
apply_migrations(&conn).unwrap();
conn.execute_batch(&format!("DROP TRIGGER {trigger}"))
.unwrap();
let before = snapshot(&conn);
drop(conn);
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
let conn = raw(&path);
assert_eq!(snapshot(&conn), before);
assert_health(&conn);
}
}
}
} }
@@ -16,6 +16,7 @@ use crate::{Error, Result};
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally /// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
/// excluded so retries and audit remain available after the Workspace row is gone. /// excluded so retries and audit remain available after the Workspace row is gone.
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"plugin_execution_plans",
"plugin_references", "plugin_references",
"plugin_operations", "plugin_operations",
"plugin_uploads", "plugin_uploads",
@@ -508,6 +509,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
// commit; update guards remain installed throughout. // commit; update guards remain installed throughout.
let mut ticket_delete_guards = Vec::new(); let mut ticket_delete_guards = Vec::new();
for (table, trigger) in [ for (table, trigger) in [
("plugin_execution_plans", "plugin_plan_replay_retained"),
("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"), ("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"),
("notification_job_targets", "notification_job_targets_no_delete"), ("notification_job_targets", "notification_job_targets_no_delete"),
("notification_job_legacy_receipts", "notification_job_legacy_no_delete"), ("notification_job_legacy_receipts", "notification_job_legacy_no_delete"),
+108 -1
View File
@@ -19114,7 +19114,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472" "x-yoi-source-digest": "sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -23631,6 +23631,113 @@
] ]
} }
}, },
"/api/w/{workspace_id}/config/source-tree/preview": {
"post": {
"operationId": "workspace_config_tree_preview",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigCommitRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorkspaceConfigTreeResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/drive/capabilities": { "/api/w/{workspace_id}/drive/capabilities": {
"get": { "get": {
"operationId": "drive_capabilities", "operationId": "drive_capabilities",
@@ -0,0 +1,21 @@
{
"schema": "{ plugins = { enabled = { echo = { installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = { ...String } default {}; config = { mode = String; }; }; }; bindings = {} default {}; }; }",
"cases": [
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = \"test\"; }; } as WorkspaceConfigSchema",
"mode": "test"
},
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config = { mode = \"test\"; unknown = true; }; }; } as WorkspaceConfigSchema",
"diagnostic": "constraint_violation"
},
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = 42; }; } as WorkspaceConfigSchema",
"diagnostic": "constraint_violation"
},
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = String; }; } as WorkspaceConfigSchema",
"diagnostic": "materialize"
}
]
}
+31
View File
@@ -0,0 +1,31 @@
let
PluginEnablement = {
installation_id = String;
expected_last_operation = String;
package_digest = String;
artifact_digest = String;
tools = [...String];
logging = Bool default false;
connections = { ...String } default {};
config = { ...Unknown } default {};
};
in {
plugins = {
enabled = { ...PluginEnablement } default {};
bindings = { ...{
origin = String;
network = String default "public_internet";
methods = [...String];
path_prefixes = [...String];
request_headers = [...String] default [];
response_headers = [...String] default [];
secret_ref = String default "";
limits = {
timeout_ms = Int;
max_request_bytes = Int;
max_response_bytes = Int;
max_header_bytes = Int;
};
} } default {};
} default {};
}
@@ -76,6 +76,13 @@ let
search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; }; search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; };
fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; }; fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; };
}; };
plugins = { selections = [...{
installation_id = String; tools = [...String]; logging = Bool;
https_slots = [...String];
# 非秘密のoverrideは補完用のshapeではなく、導入済みpackageの
# closed typed schemaで検証する。
config = { ...Unknown };
}]; };
skills = { directories = [...String]; }; skills = { directories = [...String]; };
}; };
in { in {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) // Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) // Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) // Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) // Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit. // @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) // Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts // Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472) // Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -316,6 +316,21 @@ export async function fetchConfigHistory(
); );
} }
export async function previewConfigTree(
workspaceId: string,
request: ConfigCommitRequest,
fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> {
return await readJson(
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
);
}
export async function commitConfigTree( export async function commitConfigTree(
workspaceId: string, workspaceId: string,
request: ConfigCommitRequest, request: ConfigCommitRequest,
@@ -7,6 +7,12 @@ export function apply_changes(changes: any): any;
export function changes_between(base: any, candidate: any): any; export function changes_between(base: any, candidate: any): any;
/**
* Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
*
* Convert the returned start position back to UTF-16 so non-ASCII characters do not
* misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
*/
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any; export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
export function compose_schema_bundle(contributions: any): any; export function compose_schema_bundle(contributions: any): any;
@@ -21,6 +27,12 @@ export function format_source(source: string): string;
export function set_schema_bundle(schema_bundle: any): void; export function set_schema_bundle(schema_bundle: any): void;
/**
* Retain the browser's edit candidate as a snapshot.
*
* Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
* Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
*/
export function set_snapshot(snapshot: any): void; export function set_snapshot(snapshot: any): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
@@ -44,6 +44,10 @@ export function changes_between(base, candidate) {
} }
/** /**
* Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
*
* Convert the returned start position back to UTF-16 so non-ASCII characters do not
* misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
* @param {string} entrypoint * @param {string} entrypoint
* @param {string} source * @param {string} source
* @param {number} utf16_offset * @param {number} utf16_offset
@@ -154,6 +158,10 @@ export function set_schema_bundle(schema_bundle) {
} }
/** /**
* Retain the browser's edit candidate as a snapshot.
*
* Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
* Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
* @param {any} snapshot * @param {any} snapshot
*/ */
export function set_snapshot(snapshot) { export function set_snapshot(snapshot) {
@@ -13,6 +13,7 @@ import {
fetchConfigHistory, fetchConfigHistory,
fetchConfigTree, fetchConfigTree,
parseWorkspaceConfigTreeResponse, parseWorkspaceConfigTreeResponse,
previewConfigTree,
} from "../../src/lib/workspace/config-source/api.ts"; } from "../../src/lib/workspace/config-source/api.ts";
function response(body: unknown, status = 200): Response { function response(body: unknown, status = 200): Response {
@@ -335,3 +336,24 @@ Deno.test("config source API carries bounded optional authoring schemas without
}), ConfigSourceApiError); }), ConfigSourceApiError);
} }
}); });
Deno.test("config source API previews a candidate without calling commit", async () => {
const request = {
base_digest: "sha256:base",
changes: [],
entrypoints: ["main.dcdl"],
};
const calls: string[] = [];
const result = await previewConfigTree(
"w/one",
request,
((input: string | URL | Request, init?: RequestInit) => {
calls.push(String(input));
assertEquals(init?.method, "POST");
assertEquals(JSON.parse(String(init?.body)), request);
return Promise.resolve(response(tree));
}) as typeof fetch,
);
assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]);
assertEquals<unknown>(result, tree);
});
@@ -985,3 +985,61 @@ Deno.test("generated WASM rejects forged content identity at the snapshot bounda
assertThrows(() => set_snapshot(forged)); assertThrows(() => set_snapshot(forged));
} }
}); });
Deno.test("Plugin closed preflight shares native projection and diagnostic fixture without catalog authority", async () => {
const fixture = JSON.parse(
await Deno.readTextFile(
new URL(
"../../../../resources/config-schema/fixtures/plugin-preflight.json",
import.meta.url,
),
),
) as {
schema: string;
cases: Array<{ source: string; mode?: string; diagnostic?: string }>;
};
const bundle = compose_schema_bundle([{
provider_id: "plugin-preflight",
namespace: "plugins",
version: "1",
source: fixture.schema,
source_digest: await digestText(fixture.schema),
}]) as WorkspaceConfigSchemaBundle;
const contract: ToolchainContract = {
contract_version: 2,
decodal_version: "0.4.0",
schema_version: 1,
entrypoints: ["main.dcdl"],
import_policy_version: 1,
schema_bundle: bundle,
fingerprint: await toolchainFingerprint(["main.dcdl"], bundle),
};
for (const testCase of fixture.cases) {
const candidate = fixtureSnapshot(schemaSnapshot(testCase.source));
if (testCase.diagnostic) {
let failure: ProjectedDiagnostic[] = [];
try {
evaluate_snapshot(candidate, contract);
} catch (error) {
failure = error as ProjectedDiagnostic[];
}
assertEquals(Array.isArray(failure), true);
assertEquals(failure[0].kind, testCase.diagnostic);
assertEquals(failure[0].path, "main.dcdl");
} else {
const result = evaluate_snapshot(candidate, contract) as {
projections: Array<
{
data_json: {
plugins: { enabled: { echo: { config: { mode: string } } } };
};
}
>;
};
assertEquals(
result.projections[0].data_json.plugins.enabled.echo.config.mode,
testCase.mode,
);
}
}
});