fix: append Subject migration after retained Plugin plan schema
This commit is contained in:
@@ -4093,3 +4093,52 @@ mod tests {
|
||||
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"])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
|
||||
/// never starts a local MCP subprocess.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub compaction: Option<CompactionConfigPartial>,
|
||||
/// 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)",
|
||||
));
|
||||
}
|
||||
if value.get("plugins").is_some() {
|
||||
return Err(toml::de::Error::custom(
|
||||
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
|
||||
));
|
||||
}
|
||||
if value
|
||||
.get("feature")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -930,6 +929,7 @@ impl WorkerManifestConfig {
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||
plugins: upper.plugins.or(self.plugins),
|
||||
compaction: merge_option(
|
||||
self.compaction,
|
||||
upper.compaction,
|
||||
@@ -1445,6 +1445,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
mcp: cfg.mcp,
|
||||
plugins: cfg.plugins.unwrap_or_default(),
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
skills: cfg.skills,
|
||||
@@ -1492,6 +1493,7 @@ mod tests {
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
mcp: McpConfig::default(),
|
||||
plugins: None,
|
||||
session: None,
|
||||
compaction: None,
|
||||
web: None,
|
||||
|
||||
@@ -7,6 +7,7 @@ mod model;
|
||||
pub mod model_catalog;
|
||||
pub mod paths;
|
||||
pub mod plugin;
|
||||
mod plugin_profile;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
@@ -24,6 +25,7 @@ pub use model::{
|
||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||
};
|
||||
pub use paths::user_profiles_path;
|
||||
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection};
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
|
||||
/// consumer boundary.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// First-class web tools configuration. Network access remains fail-closed
|
||||
@@ -1116,6 +1123,7 @@ impl Default for CompactionConfig {
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
|| !self.plugins.is_empty()
|
||||
|| self.feature.memory.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();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("dynamic Plugins are not supported"),
|
||||
error.to_string().contains("unknown field `enabled`"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
@@ -1617,7 +1623,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}),
|
||||
);
|
||||
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;
|
||||
nested
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,7 @@ fn resolve_profile_value(
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
mcp: profile.mcp,
|
||||
plugins: profile.plugins,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
skills: profile.skills,
|
||||
@@ -700,6 +701,8 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
|
||||
@@ -1213,6 +1213,20 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
) -> 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(
|
||||
"/api/w/{workspace_id}/config/source-tree/commit",
|
||||
status = 201,
|
||||
@@ -12824,6 +12838,11 @@ mod tests {
|
||||
HttpMethod::Get,
|
||||
"/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",
|
||||
HttpMethod::Post,
|
||||
|
||||
@@ -1006,6 +1006,9 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
||||
}),
|
||||
feature: manifest.feature.clone().into(),
|
||||
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
|
||||
.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()),
|
||||
(
|
||||
107,
|
||||
"Immutable Worker Plugin plans and retained pins".into()
|
||||
),
|
||||
(
|
||||
108,
|
||||
"Subject Ticket responsibility and current-body notification routing"
|
||||
.into()
|
||||
)
|
||||
|
||||
@@ -206,7 +206,18 @@ impl SqliteWorkspaceStore {
|
||||
let state = match load_state(&tx, workspace_id)? {
|
||||
Some(state) => state,
|
||||
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)?;
|
||||
state
|
||||
}
|
||||
@@ -215,6 +226,10 @@ impl SqliteWorkspaceStore {
|
||||
tx.commit()?;
|
||||
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
|
||||
.snapshot
|
||||
.get(&main_config_path())
|
||||
@@ -223,7 +238,17 @@ impl SqliteWorkspaceStore {
|
||||
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|
||||
|| 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);
|
||||
}
|
||||
Ok(state)
|
||||
@@ -277,18 +302,23 @@ impl SqliteWorkspaceStore {
|
||||
request: &ConfigCommitRequest,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
let (current, base_identity) = self.with_conn(|conn| {
|
||||
self.with_conn(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?;
|
||||
let identity = load_evaluation_identity(&tx, workspace_id)?;
|
||||
tx.commit()?;
|
||||
Ok((current, identity))
|
||||
})?;
|
||||
validate_entrypoint_request(&request.entrypoints)?;
|
||||
if current.snapshot.digest != request.base_digest {
|
||||
return Err(config_conflict("base content digest mismatch"));
|
||||
}
|
||||
evaluate_candidate(current, base_identity, &request.changes, schema_bundle)
|
||||
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
|
||||
validate_entrypoint_request(&request.entrypoints)?;
|
||||
if current.snapshot.digest != request.base_digest {
|
||||
return Err(config_conflict("base content digest mismatch"));
|
||||
}
|
||||
evaluate_candidate_in_transaction(
|
||||
&tx,
|
||||
workspace_id,
|
||||
current,
|
||||
base_identity,
|
||||
&request.changes,
|
||||
schema_bundle,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evaluate_workspace_config_candidate(
|
||||
@@ -328,8 +358,28 @@ impl SqliteWorkspaceStore {
|
||||
{
|
||||
return Err(config_conflict("base evaluation identity mismatch"));
|
||||
}
|
||||
if has_plugin_provider(¤t.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();
|
||||
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);
|
||||
tx.execute(
|
||||
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
|
||||
@@ -452,6 +502,40 @@ fn load_evaluation_identity(
|
||||
.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(
|
||||
current: WorkspaceConfigState,
|
||||
base_identity: (String, String),
|
||||
|
||||
@@ -12,7 +12,10 @@ pub mod hosts;
|
||||
pub mod memory_backend;
|
||||
pub mod memory_staging;
|
||||
pub mod observation;
|
||||
pub mod plugin_binding;
|
||||
pub mod plugin_catalog;
|
||||
pub mod plugin_plan;
|
||||
pub mod plugin_settings;
|
||||
pub mod profile_settings;
|
||||
pub mod prompt_settings;
|
||||
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.
|
||||
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
||||
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!(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);
|
||||
@@ -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((
|
||||
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(c)?, expected);
|
||||
|
||||
@@ -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:"),
|
||||
),
|
||||
®istry,
|
||||
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 {
|
||||
version: 107,
|
||||
name: "Immutable Worker Plugin plans and retained pins".into(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 108,
|
||||
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((105, "Durable Subject deletion snapshots and Job admission fences".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);
|
||||
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}");
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// 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>> {
|
||||
// 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 = [
|
||||
"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 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_legacy_workflow_snapshot",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
"ticket_assignment_operations",
|
||||
"ticket_assignment_work_releases",
|
||||
"artifacts",
|
||||
"plugin_installations",
|
||||
"plugin_artifacts",
|
||||
"plugin_uploads",
|
||||
"plugin_operations",
|
||||
"plugin_references",
|
||||
"plugin_execution_plans",
|
||||
"workspace_config_trees",
|
||||
"workspace_config_tree_history",
|
||||
"workspace_memory_settings",
|
||||
|
||||
@@ -4425,7 +4425,8 @@ impl WorkspaceApi {
|
||||
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
|
||||
.with_provider(Arc::new(RuntimeConfigSchemaProvider))
|
||||
.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 workspace_id = config.workspace_id.clone();
|
||||
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(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::workspace_config_tree_preview(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::workspace_config_tree_commit(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -9358,6 +9362,34 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.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(
|
||||
&self,
|
||||
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"));
|
||||
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,
|
||||
};
|
||||
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 mut close = Box::pin(crate::db_executor::with_operation_lifetime(
|
||||
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(),
|
||||
"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;
|
||||
assert_eq!(
|
||||
observed(fixture.host_executor.run(|| Ok::<_, Error>("control")))
|
||||
|
||||
@@ -595,7 +595,10 @@ pub(super) async fn commit(
|
||||
if candidate.base_digest != state.snapshot.digest
|
||||
|| candidate.base_toolchain_fingerprint != state.contract.fingerprint
|
||||
|| 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"));
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
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_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -469,8 +469,13 @@ const MIGRATIONS: &[Migration] = &[
|
||||
},
|
||||
Migration {
|
||||
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",
|
||||
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(())
|
||||
}
|
||||
|
||||
fn migrate_subject_assignments_v106_to_v107(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 106 {
|
||||
fn migrate_subject_assignments_v107_to_v108(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 107 {
|
||||
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()?;
|
||||
subject_assignment_migration::install(&tx)?;
|
||||
notification_routing::install_subject_recipients(&tx)?;
|
||||
subject_assignment_migration::verify(&tx)?;
|
||||
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"],
|
||||
)?;
|
||||
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
|
||||
// and component DDL to the shared cross-domain constraint composition below.
|
||||
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
|
||||
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!("passkey_results_schema.sql"))?;
|
||||
conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -16905,6 +16930,7 @@ fn apply_migrations_with_secret_source(
|
||||
|
||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||
crate::plugin_catalog::verify(conn)?;
|
||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||
subject_assignment_migration::verify(conn)?;
|
||||
verify_workspace_runtime_binding_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:105, name:"Durable Subject deletion snapshots and Job admission fences".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()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".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")?);
|
||||
@@ -23007,7 +23035,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(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, "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()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
@@ -28551,3 +28580,7 @@ mod frozen_runtime_binding_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "plugin_catalog/migration_tests.rs"]
|
||||
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;
|
||||
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"] {
|
||||
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)
|
||||
VALUES('w',?1,?1,'orchestrator','workspace_agent','workspace-orchestrator','original actor','original time')",[status]).unwrap();
|
||||
if status == "unknown" {
|
||||
@@ -72,14 +77,16 @@ mod subject_assignment_migration_tests {
|
||||
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)
|
||||
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 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';
|
||||
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_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');
|
||||
INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap();
|
||||
INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');").unwrap();
|
||||
}
|
||||
// A live body candidate and the old singleton exist simultaneously. A
|
||||
// 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)
|
||||
@@ -88,7 +95,6 @@ mod subject_assignment_migration_tests {
|
||||
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)
|
||||
VALUES('w','subjektiv:tempting-subject','r','body',1,'1','2');").unwrap();
|
||||
(directory, path, conn)
|
||||
}
|
||||
|
||||
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(&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()
|
||||
.filter(|row| !row.first().is_some_and(|name| name=="workspaces"))
|
||||
.collect::<Vec<_>>();
|
||||
let contract = |db: &Connection| state_contract_schema_signature(db,table);
|
||||
assert_eq!(contract(&conn),contract(new),"{table}");
|
||||
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))?;
|
||||
@@ -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_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
|
||||
/// excluded so retries and audit remain available after the Workspace row is gone.
|
||||
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"plugin_execution_plans",
|
||||
"plugin_references",
|
||||
"plugin_operations",
|
||||
"plugin_uploads",
|
||||
@@ -508,6 +509,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
// commit; update guards remain installed throughout.
|
||||
let mut ticket_delete_guards = Vec::new();
|
||||
for (table, trigger) in [
|
||||
("plugin_execution_plans", "plugin_plan_replay_retained"),
|
||||
("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"),
|
||||
("notification_job_targets", "notification_job_targets_no_delete"),
|
||||
("notification_job_legacy_receipts", "notification_job_legacy_no_delete"),
|
||||
|
||||
+108
-1
@@ -19114,7 +19114,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472"
|
||||
"x-yoi-source-digest": "sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"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": {
|
||||
"get": {
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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; };
|
||||
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]; };
|
||||
};
|
||||
in {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472)
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256: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
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// 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.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// 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.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472)
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:7a78c365c7f30639cff040698f94ba6430be9e5bdeba866a3037bafc2fe1a472)
|
||||
// Input contract: 0.1.0 (sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -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(
|
||||
workspaceId: string,
|
||||
request: ConfigCommitRequest,
|
||||
|
||||
+12
@@ -7,6 +7,12 @@ export function apply_changes(changes: 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 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;
|
||||
|
||||
/**
|
||||
* 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 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} source
|
||||
* @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
|
||||
*/
|
||||
export function set_snapshot(snapshot) {
|
||||
|
||||
Binary file not shown.
@@ -13,6 +13,7 @@ import {
|
||||
fetchConfigHistory,
|
||||
fetchConfigTree,
|
||||
parseWorkspaceConfigTreeResponse,
|
||||
previewConfigTree,
|
||||
} from "../../src/lib/workspace/config-source/api.ts";
|
||||
|
||||
function response(body: unknown, status = 200): Response {
|
||||
@@ -335,3 +336,24 @@ Deno.test("config source API carries bounded optional authoring schemas without
|
||||
}), 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));
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user