feat: resolve Workspace Plugin config into immutable execution plans

This commit is contained in:
2026-10-29 08:46:39 +09:00
parent 7ad6030498
commit 9b7a58d5c4
42 changed files with 4528 additions and 38 deletions
+49
View File
@@ -4093,3 +4093,52 @@ mod tests {
assert_eq!(diagnostics[0].kind, "cycle"); assert_eq!(diagnostics[0].kind, "cycle");
} }
} }
#[cfg(test)]
mod plugin_preflight_tests {
use super::*;
#[test]
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../resources/config-schema/fixtures/plugin-preflight.json"
))
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"plugin-preflight",
"plugins",
"1",
fixture["schema"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let path = VirtualPath::parse("main.dcdl").unwrap();
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
for case in fixture["cases"].as_array().unwrap() {
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
case["source"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
match case.get("diagnostic") {
Some(kind) => {
let errors = result.unwrap_err();
assert_eq!(errors[0].kind, kind.as_str().unwrap());
assert_eq!(errors[0].path.as_str(), "main.dcdl");
}
None => {
let evaluated = result.unwrap();
assert_eq!(
evaluated.projections[0]
.data_json
.pointer("/plugins/enabled/echo/config/mode"),
Some(&case["mode"])
);
}
}
}
}
}
+7 -5
View File
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
/// never starts a local MCP subprocess. /// never starts a local MCP subprocess.
#[serde(default)] #[serde(default)]
pub mcp: McpConfig, pub mcp: McpConfig,
/// Omission retains the lower layer; an explicit section replaces the entire
/// selection list, including an empty list. Never unions Plugin grants.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<crate::PluginProfileConfig>,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfigPartial>, pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`]. /// First-class web tool opt-in. See [`WebConfig`].
@@ -795,11 +799,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
"unknown field in manifest: memory (removed; configure feature.memory)", "unknown field in manifest: memory (removed; configure feature.memory)",
)); ));
} }
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value if value
.get("feature") .get("feature")
.and_then(toml::Value::as_table) .and_then(toml::Value::as_table)
@@ -930,6 +929,7 @@ impl WorkerManifestConfig {
), ),
feature: self.feature.merge(upper.feature), feature: self.feature.merge(upper.feature),
mcp: merge_mcp_config(self.mcp, upper.mcp), mcp: merge_mcp_config(self.mcp, upper.mcp),
plugins: upper.plugins.or(self.plugins),
compaction: merge_option( compaction: merge_option(
self.compaction, self.compaction,
upper.compaction, upper.compaction,
@@ -1445,6 +1445,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
permissions, permissions,
feature: FeatureConfig::from(cfg.feature), feature: FeatureConfig::from(cfg.feature),
mcp: cfg.mcp, mcp: cfg.mcp,
plugins: cfg.plugins.unwrap_or_default(),
compaction, compaction,
web: cfg.web, web: cfg.web,
skills: cfg.skills, skills: cfg.skills,
@@ -1492,6 +1493,7 @@ mod tests {
permissions: None, permissions: None,
feature: FeatureConfigPartial::default(), feature: FeatureConfigPartial::default(),
mcp: McpConfig::default(), mcp: McpConfig::default(),
plugins: None,
session: None, session: None,
compaction: None, compaction: None,
web: None, web: None,
+10 -4
View File
@@ -7,6 +7,7 @@ mod model;
pub mod model_catalog; pub mod model_catalog;
pub mod paths; pub mod paths;
pub mod plugin; pub mod plugin;
mod plugin_profile;
mod profile; mod profile;
mod scope; mod scope;
@@ -24,6 +25,7 @@ pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
}; };
pub use paths::user_profiles_path; pub use paths::user_profiles_path;
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection};
pub use profile::{ pub use profile::{
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot, ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
/// consumer boundary. /// consumer boundary.
#[serde(default)] #[serde(default)]
pub mcp: McpConfig, pub mcp: McpConfig,
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
/// their serialized fingerprint inputs.
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
pub plugins: PluginProfileConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfig>, pub compaction: Option<CompactionConfig>,
/// First-class web tools configuration. Network access remains fail-closed /// First-class web tools configuration. Network access remains fail-closed
@@ -1116,6 +1123,7 @@ impl Default for CompactionConfig {
impl WorkerManifest { impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool { pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some() self.profile.is_some()
|| !self.plugins.is_empty()
|| self.feature.memory.workspace_settings.is_some() || self.feature.memory.workspace_settings.is_some()
|| self.feature.subjektiv.workspace_settings.is_some() || self.feature.subjektiv.workspace_settings.is_some()
} }
@@ -1595,9 +1603,7 @@ model_id = "claude-sonnet-4-20250514"
); );
let error = WorkerManifest::from_toml(&toml).unwrap_err(); let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!( assert!(
error error.to_string().contains("unknown field `enabled`"),
.to_string()
.contains("dynamic Plugins are not supported"),
"unexpected error: {error}" "unexpected error: {error}"
); );
} }
@@ -1617,7 +1623,7 @@ model_id = "claude-sonnet-4-20250514"
}), }),
); );
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err(); let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`")); assert!(error.to_string().contains("unknown field `resolved`"));
let mut nested = base; let mut nested = base;
nested nested
+269
View File
@@ -0,0 +1,269 @@
//! Narrowing requests for Workspace-managed Plugin installations.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Profile input only, not Workspace enablement or a validated execution plan.
///
/// The Workspace owner must resolve each installation against its enabled exact
/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither
/// these names nor config values grant authority, select package paths, or cause
/// discovery, dynamic registration, or guest execution.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileConfig {
#[serde(default)]
pub selections: Vec<PluginProfileSelection>,
}
impl PluginProfileConfig {
pub fn is_empty(&self) -> bool {
self.selections.is_empty()
}
}
/// One installation's explicit subset; omitted surfaces and capabilities are off.
///
/// `tools` and `https_slots` are package-declared names, not public Tool names,
/// URLs, host paths, or connection bindings. `config` contains non-secret override
/// candidates only: the Workspace schema validator must reject them unless the
/// package's closed typed schema permits those overrides. Deserialization alone
/// cannot certify that arbitrary package-specific values are non-secret or valid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileSelection {
pub installation_id: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub logging: bool,
#[serde(default)]
pub https_slots: Vec<String>,
#[serde(default)]
pub config: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ProfileSource, WorkerManifest, WorkerManifestConfig,
read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value,
write_persisted_worker_manifest_snapshot,
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
fn partial(plugins: Value) -> WorkerManifestConfig {
serde_json::from_value(json!({
"worker": { "name": "plugin-worker" },
"plugins": plugins,
}))
.unwrap()
}
fn resolve_profile(plugins: Value) -> crate::ResolvedProfile {
let dir = tempfile::tempdir().unwrap();
resolve_profile_artifact_value(
json!({ "plugins": plugins }),
ProfileSource::Archive {
archive_id: "profile-input".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap()
}
#[test]
fn omitted_plugin_selections_and_grants_default_off() {
let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap();
assert!(
serde_json::to_value(&omitted)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(omitted).unwrap();
assert!(manifest.plugins.selections.is_empty());
assert!(!manifest.requires_persisted_execution_snapshot());
assert!(
serde_json::to_value(manifest)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(partial(json!({
"selections": [{ "installation_id": "installation-a" }]
})))
.unwrap();
let selection = &manifest.plugins.selections[0];
assert!(selection.tools.is_empty());
assert!(!selection.logging);
assert!(selection.https_slots.is_empty());
assert!(selection.config.is_empty());
assert!(manifest.requires_persisted_execution_snapshot());
}
#[test]
fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() {
let lower = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read", "write"],
"logging": true, "https_slots": ["remote"], "config": {"limit": 10}
}] }));
let omitted = lower.clone().merge(WorkerManifestConfig::default());
assert_eq!(omitted.plugins, lower.plugins);
let upper = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"]
}] }));
let merged = lower.clone().merge(upper.clone());
assert_eq!(merged.plugins, upper.plugins);
let round_trip: WorkerManifestConfig =
serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap();
assert_eq!(round_trip.plugins, merged.plugins);
for empty in [json!({}), json!({ "selections": [] })] {
let cleared = lower.clone().merge(partial(empty));
assert_eq!(cleared.plugins, Some(PluginProfileConfig::default()));
let manifest = WorkerManifest::try_from(cleared).unwrap();
assert!(manifest.plugins.selections.is_empty());
}
}
#[test]
fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() {
let dir = tempfile::tempdir().unwrap();
let source = r#"
[[plugins.selections]]
installation_id = "installation-a"
tools = ["lookup"]
logging = true
https_slots = ["api"]
[plugins.selections.config]
limit = 5
labels = ["one", "two"]
[plugins.selections.config.nested]
strict = true
"#;
let parsed = WorkerManifestConfig::from_toml(source).unwrap();
let authored: toml::Value = toml::from_str(source).unwrap();
let resolved = resolve_profile_artifact_value(
serde_json::to_value(authored).unwrap(),
ProfileSource::Archive {
archive_id: "typed-profile".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap();
assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref());
assert_eq!(
resolved.manifest.plugins.selections[0].config["nested"],
json!({"strict": true})
);
let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap();
let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.plugins, resolved.manifest.plugins);
assert_eq!(
write_persisted_worker_manifest_snapshot(&restored).unwrap(),
snapshot
);
assert_eq!(
serde_json::to_value(restored).unwrap(),
resolved.manifest_snapshot
);
let toml_round_trip = toml::to_string(&resolved.manifest).unwrap();
assert_eq!(
WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins,
resolved.manifest.plugins
);
}
#[test]
fn plugin_fields_change_serialized_profile_fingerprint_input() {
let baseline = json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"],
"logging": false, "https_slots": [], "config": {"limit": 5}
}] });
let fingerprint = |plugins| {
let resolved = resolve_profile(plugins);
Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap())
};
let original = fingerprint(baseline.clone());
assert_eq!(fingerprint(baseline.clone()), original);
for (field, value) in [
("installation_id", json!("installation-b")),
("tools", json!(["write"])),
("logging", json!(true)),
("https_slots", json!(["api"])),
("config", json!({"limit": 6})),
] {
let mut changed = baseline.clone();
changed["selections"][0][field] = value;
assert_ne!(fingerprint(changed), original, "{field} was lost");
}
assert_eq!(
fingerprint(json!({})),
fingerprint(json!({"selections": []}))
);
}
#[test]
fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() {
let dir = tempfile::tempdir().unwrap();
let mut invalid = vec![
json!({ "enabled": [{ "id": "project:legacy" }] }),
json!({ "packages": ["/tmp/legacy.yoi-plugin"] }),
json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }),
json!({ "enable": true }),
];
for (field, value) in [
("package_path", json!("/tmp/legacy.yoi-plugin")),
("path", json!("./plugin")),
("url", json!("https://example.invalid/plugin")),
("artifact_digest", json!("sha256:uninstalled")),
("secret", json!("not-a-secret-reference")),
("data", json!(true)),
("events", json!(["changed"])),
("websocket", json!(true)),
("workdir", json!(true)),
("unknown", json!(true)),
] {
let mut selection = json!({ "installation_id": "installation-a" });
selection[field] = value;
invalid.push(json!({ "selections": [selection] }));
}
invalid.extend([
json!({ "selections": [{ "tools": ["read"] }] }),
json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }),
]);
for plugins in invalid {
let partial_input = json!({ "plugins": plugins.clone() });
assert!(
serde_json::from_value::<WorkerManifestConfig>(partial_input.clone()).is_err(),
"{plugins}"
);
let toml = toml::to_string(&partial_input).unwrap();
assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}");
let error = resolve_profile_artifact_value(
partial_input,
ProfileSource::Archive {
archive_id: "invalid".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap_err();
assert!(
error.to_string().contains("failed to decode Profile"),
"{error}"
);
}
}
}
+3
View File
@@ -652,6 +652,7 @@ fn resolve_profile_value(
permissions: profile.permissions, permissions: profile.permissions,
feature: profile.feature, feature: profile.feature,
mcp: profile.mcp, mcp: profile.mcp,
plugins: profile.plugins,
compaction, compaction,
web: profile.web, web: profile.web,
skills: profile.skills, skills: profile.skills,
@@ -700,6 +701,8 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
mcp: McpConfig, mcp: McpConfig,
#[serde(default)] #[serde(default)]
plugins: Option<crate::PluginProfileConfig>,
#[serde(default)]
compaction: Option<serde_json::Value>, compaction: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
web: Option<WebConfig>, web: Option<WebConfig>,
+19
View File
@@ -1213,6 +1213,20 @@ pub trait ServerApi {
#[path] workspace_id: String, #[path] workspace_id: String,
) -> Result<WorkspacePromptProjection, RepositoryApiError>; ) -> Result<WorkspacePromptProjection, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/config/source-tree/preview",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true
)]
async fn workspace_config_tree_preview(
&self,
#[path] workspace_id: String,
#[body] request: ConfigCommitRequest,
) -> Result<WorkspaceConfigTreeResponse, RepositoryApiError>;
#[post( #[post(
"/api/w/{workspace_id}/config/source-tree/commit", "/api/w/{workspace_id}/config/source-tree/commit",
status = 201, status = 201,
@@ -12806,6 +12820,11 @@ mod tests {
HttpMethod::Get, HttpMethod::Get,
"/api/w/{workspace_id}/config/projections/prompts", "/api/w/{workspace_id}/config/projections/prompts",
), ),
(
"workspace_config_tree_preview",
HttpMethod::Post,
"/api/w/{workspace_id}/config/source-tree/preview",
),
( (
"workspace_config_tree_commit", "workspace_config_tree_commit",
HttpMethod::Post, HttpMethod::Post,
+3
View File
@@ -1006,6 +1006,9 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
}), }),
feature: manifest.feature.clone().into(), feature: manifest.feature.clone().into(),
mcp: manifest.mcp.clone(), mcp: manifest.mcp.clone(),
// Reusable selection is not a plan or delegation authority. Backend launch
// must resolve it again within the child's own Workspace/delegation bound.
plugins: Some(manifest.plugins.clone()),
compaction: manifest compaction: manifest
.compaction .compaction
.as_ref() .as_ref()
@@ -480,7 +480,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(103, "Passkey metadata and atomic addition results".into()), (103, "Passkey metadata and atomic addition results".into()),
(104, "Converge Passkey results and Job input retention".into()), (104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into()), (105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into()) (106, crate::plugin_catalog::MIGRATION_NAME.into()),
(107, "Immutable Worker Plugin plans and retained pins".into())
] ]
); );
for (table, rows) in retained.iter().zip(original.iter()) { for (table, rows) in retained.iter().zip(original.iter()) {
+96 -12
View File
@@ -206,7 +206,18 @@ impl SqliteWorkspaceStore {
let state = match load_state(&tx, workspace_id)? { let state = match load_state(&tx, workspace_id)? {
Some(state) => state, Some(state) => state,
None => { None => {
let state = initial_state_with_schema(schema_bundle.clone())?; let mut state = initial_state_with_schema(schema_bundle.clone())?;
if has_plugin_provider(&schema_bundle) {
let (contract, evaluation, _) =
crate::plugin_settings::evaluate_catalog_config(
&tx,
workspace_id,
&state.snapshot,
schema_bundle.clone(),
)?;
state.contract = contract;
state.projection_digest = evaluation.projection_digest;
}
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?; insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
state state
} }
@@ -215,6 +226,10 @@ impl SqliteWorkspaceStore {
tx.commit()?; tx.commit()?;
Ok((state, requires_toolchain_refresh, base_identity)) Ok((state, requires_toolchain_refresh, base_identity))
})?; })?;
let desired_schema = crate::plugin_settings::materialization_schema(
desired_schema,
&state.contract.schema_bundle,
)?;
let main_needs_normalization = state let main_needs_normalization = state
.snapshot .snapshot
.get(&main_config_path()) .get(&main_config_path())
@@ -223,7 +238,17 @@ impl SqliteWorkspaceStore {
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint || state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|| main_needs_normalization || main_needs_normalization
{ {
let candidate = evaluate_candidate(state, base_identity, &[], desired_schema)?; let candidate = self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
evaluate_candidate_in_transaction(
&tx,
workspace_id,
state,
base_identity,
&[],
desired_schema,
)
})?;
return self.commit_evaluated_workspace_config(workspace_id, &candidate); return self.commit_evaluated_workspace_config(workspace_id, &candidate);
} }
Ok(state) Ok(state)
@@ -277,18 +302,23 @@ impl SqliteWorkspaceStore {
request: &ConfigCommitRequest, request: &ConfigCommitRequest,
schema_bundle: WorkspaceConfigSchemaBundle, schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
let (current, base_identity) = self.with_conn(|conn| { self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?; let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?;
let identity = load_evaluation_identity(&tx, workspace_id)?; let base_identity = load_evaluation_identity(&tx, workspace_id)?;
tx.commit()?; validate_entrypoint_request(&request.entrypoints)?;
Ok((current, identity)) if current.snapshot.digest != request.base_digest {
})?; return Err(config_conflict("base content digest mismatch"));
validate_entrypoint_request(&request.entrypoints)?; }
if current.snapshot.digest != request.base_digest { evaluate_candidate_in_transaction(
return Err(config_conflict("base content digest mismatch")); &tx,
} workspace_id,
evaluate_candidate(current, base_identity, &request.changes, schema_bundle) current,
base_identity,
&request.changes,
schema_bundle,
)
})
} }
pub fn evaluate_workspace_config_candidate( pub fn evaluate_workspace_config_candidate(
@@ -328,8 +358,28 @@ impl SqliteWorkspaceStore {
{ {
return Err(config_conflict("base evaluation identity mismatch")); return Err(config_conflict("base evaluation identity mismatch"));
} }
if has_plugin_provider(&current.contract.schema_bundle)
&& !has_plugin_provider(&candidate.contract.schema_bundle)
{
return Err(config_conflict("Plugin schema authority cannot be removed"));
}
let snapshot = candidate.snapshot.clone(); let snapshot = candidate.snapshot.clone();
snapshot.validate().map_err(config_error)?; snapshot.validate().map_err(config_error)?;
if has_plugin_provider(&candidate.contract.schema_bundle) {
let (contract, evaluation, resolved) =
crate::plugin_settings::evaluate_catalog_config(
&tx,
workspace_id,
&snapshot,
candidate.contract.schema_bundle.clone(),
)?;
if contract != candidate.contract || evaluation != candidate.evaluation {
return Err(config_conflict(
"Plugin catalog/schema evaluation changed since Preview",
));
}
crate::plugin_settings::sync_config_references(&tx, workspace_id, &resolved)?;
}
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
tx.execute( tx.execute(
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1", "DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
@@ -452,6 +502,40 @@ fn load_evaluation_identity(
.map_err(Error::from) .map_err(Error::from)
} }
fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
bundle
.contributions
.iter()
.any(|provider| provider.provider_id == "builtin:plugins")
}
fn evaluate_candidate_in_transaction(
tx: &rusqlite::Transaction<'_>,
workspace_id: &str,
current: WorkspaceConfigState,
base_identity: (String, String),
changes: &[ConfigTreeChange],
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> {
if !has_plugin_provider(&schema_bundle) {
return evaluate_candidate(current, base_identity, changes, schema_bundle);
}
let mut providers = schema_bundle.contributions.clone();
providers.retain(|provider| provider.provider_id != "builtin:plugins");
providers.push(crate::plugin_settings::PluginConfigSchemaProvider.contribution()?);
let bootstrap = WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?;
let mut candidate = evaluate_candidate(current, base_identity, changes, bootstrap)?;
let (contract, evaluation, _) = crate::plugin_settings::evaluate_catalog_config(
tx,
workspace_id,
&candidate.snapshot,
schema_bundle,
)?;
candidate.contract = contract;
candidate.evaluation = evaluation;
Ok(candidate)
}
fn evaluate_candidate( fn evaluate_candidate(
current: WorkspaceConfigState, current: WorkspaceConfigState,
base_identity: (String, String), base_identity: (String, String),
@@ -372,9 +372,9 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no
// and erasure guards in an otherwise legacy fixture. // and erasure guards in an otherwise legacy fixture.
fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> { fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> {
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations; conn.execute_batch("DROP TABLE plugin_execution_plans; DROP TABLE plugin_references; DROP TABLE plugin_operations;
DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations;
UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?; UPDATE __yoi_schema_migrations SET version=105 WHERE version=107;")?;
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("retained96.db"); let path = directory.path().join("retained96.db");
prepare_retained_schema(&path, 96); prepare_retained_schema(&path, 96);
+3
View File
@@ -12,7 +12,10 @@ pub mod hosts;
pub mod memory_backend; pub mod memory_backend;
pub mod memory_staging; pub mod memory_staging;
pub mod observation; pub mod observation;
pub mod plugin_binding;
pub mod plugin_catalog; pub mod plugin_catalog;
pub mod plugin_plan;
pub mod plugin_settings;
pub mod profile_settings; pub mod profile_settings;
pub mod prompt_settings; pub mod prompt_settings;
pub mod records; pub mod records;
@@ -0,0 +1,371 @@
//! Static Workspace HTTPS policy, not network admission. Runtime must recheck live
//! binding state, resolve/check every DNS address, pin the connection, verify TLS,
//! inject credentials, and enforce these bounds (including decompressed bytes).
//! Plans retain the binding identity and digest, never this destination policy.
use std::collections::{BTreeMap, BTreeSet};
use std::net::{Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
use server_api::PluginHttpMethod;
use sha2::{Digest, Sha256};
use url::{Host, Url};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PluginNetworkPolicy {
/// DNS names are provisional until all resolved addresses pass live checks.
/// Private-network exceptions are deliberately not supported by this policy.
#[default]
PublicInternet,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionLimits {
pub timeout_ms: u32,
pub max_request_bytes: u32,
pub max_response_bytes: u32,
pub max_header_bytes: u32,
}
/// Trusted server operational ceiling, supplied separately from Workspace input.
/// Deserializing a binding cannot set or expand this ceiling.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginConnectionPolicyCeiling {
pub limits: PluginConnectionLimits,
}
impl Default for PluginConnectionPolicyCeiling {
fn default() -> Self {
Self {
limits: PluginConnectionLimits {
timeout_ms: 15_000,
max_request_bytes: 1024 * 1024,
max_response_bytes: 1024 * 1024,
max_header_bytes: 16 * 1024,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionBinding {
/// Canonical origin only: no trailing slash, path, query, credentials or fragment.
pub origin: String,
#[serde(default)]
pub network: PluginNetworkPolicy,
pub methods: Vec<PluginHttpMethod>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub request_headers: Vec<String>,
#[serde(default)]
pub response_headers: Vec<String>,
/// Empty means no credential. Otherwise an opaque Workspace-local secret ID;
/// never a value, token, provider URL, or filesystem reference.
#[serde(default)]
pub secret_ref: String,
pub limits: PluginConnectionLimits,
}
/// Safe diagnostic categories contain no caller-supplied URLs, headers or secrets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PluginBindingError {
#[error("invalid plugin binding identity")]
InvalidIdentity,
#[error("plugin connection requires an exact canonical HTTPS origin")]
InvalidOrigin,
#[error("plugin connection destination is not public internet")]
NonPublicDestination,
#[error("plugin connection methods must be nonempty and unique")]
InvalidMethods,
#[error("plugin connection paths must be nonempty, unique canonical segment prefixes")]
InvalidPaths,
#[error("plugin connection header allowlist is invalid or contains a reserved header")]
InvalidHeaders,
#[error("plugin connection secret must be an opaque reference")]
InvalidSecretReference,
#[error("plugin connection limits must be positive and within the server ceiling")]
InvalidLimits,
#[error("too many plugin bindings")]
TooManyBindings,
}
impl PluginConnectionBinding {
pub fn validate(
&self,
ceiling: &PluginConnectionPolicyCeiling,
) -> Result<(), PluginBindingError> {
validate_origin(&self.origin)?;
if self.methods.is_empty() || self.methods.len() > 7 {
return Err(PluginBindingError::InvalidMethods);
}
let mut methods = BTreeSet::new();
if self
.methods
.iter()
.any(|method| !methods.insert(method_name(method)))
{
return Err(PluginBindingError::InvalidMethods);
}
if self.path_prefixes.is_empty()
|| self.path_prefixes.len() > 64
|| !unique_strings(&self.path_prefixes)
|| self.path_prefixes.iter().any(|path| !canonical_path(path))
{
return Err(PluginBindingError::InvalidPaths);
}
for headers in [&self.request_headers, &self.response_headers] {
if headers.len() > 64
|| !unique_strings(headers)
|| headers.iter().any(|header| !allowed_header(header))
{
return Err(PluginBindingError::InvalidHeaders);
}
}
if !self.secret_ref.is_empty() && !opaque_reference(&self.secret_ref) {
return Err(PluginBindingError::InvalidSecretReference);
}
for (value, maximum) in [
(self.limits.timeout_ms, ceiling.limits.timeout_ms),
(
self.limits.max_request_bytes,
ceiling.limits.max_request_bytes,
),
(
self.limits.max_response_bytes,
ceiling.limits.max_response_bytes,
),
(
self.limits.max_header_bytes,
ceiling.limits.max_header_bytes,
),
] {
if value == 0 || maximum == 0 || value > maximum {
return Err(PluginBindingError::InvalidLimits);
}
}
Ok(())
}
/// Versioned SHA-256 of policy content. Allowlists have set semantics, so their
/// order does not affect identity. Call `validate` before accepting a digest.
/// Binding map identity is separate and must also be pinned by the caller.
pub fn digest(&self) -> String {
let mut canonical = self.clone();
canonical.methods.sort_by_key(method_name);
canonical.path_prefixes.sort();
canonical.request_headers.sort();
canonical.response_headers.sort();
let bytes = serde_json::to_vec(&canonical).expect("binding contains only JSON primitives");
let mut hash = Sha256::new();
hash.update(b"yoi-plugin-connection-policy-v1\0");
hash.update(bytes);
let hex: String = hash
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{hex}")
}
/// Static segment-boundary matching only; this is not runtime authorization.
/// Conservative paths exclude percent encodings to avoid decoder disagreement.
pub fn allows_path(&self, path: &str) -> bool {
self.path_prefixes
.iter()
.any(|prefix| path_within(prefix, path))
}
}
/// Validates the Workspace-owned map without copying any destination into a plan.
/// The map key is the binding identity; pin that key and `binding.digest()` only.
pub fn validate_bindings(
bindings: &BTreeMap<String, PluginConnectionBinding>,
ceiling: &PluginConnectionPolicyCeiling,
) -> Result<(), PluginBindingError> {
if bindings.len() > 256 {
return Err(PluginBindingError::TooManyBindings);
}
for (identity, binding) in bindings {
if !opaque_reference(identity) {
return Err(PluginBindingError::InvalidIdentity);
}
binding.validate(ceiling)?;
}
Ok(())
}
/// Conservative canonical path containment on segment boundaries, not a raw
/// string prefix. Runtime must use the same canonical path for checking/sending.
pub fn path_within(prefix: &str, path: &str) -> bool {
canonical_path(prefix)
&& canonical_path(path)
&& (prefix == "/"
|| path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/')))
}
fn method_name(method: &PluginHttpMethod) -> &'static str {
match method {
PluginHttpMethod::Get => "GET",
PluginHttpMethod::Head => "HEAD",
PluginHttpMethod::Post => "POST",
PluginHttpMethod::Put => "PUT",
PluginHttpMethod::Patch => "PATCH",
PluginHttpMethod::Delete => "DELETE",
PluginHttpMethod::Options => "OPTIONS",
}
}
fn opaque_reference(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
}
fn unique_strings(values: &[String]) -> bool {
values.iter().collect::<BTreeSet<_>>().len() == values.len()
}
fn canonical_path(path: &str) -> bool {
if path == "/" {
return true;
}
path.len() <= 2048
&& path.starts_with('/')
&& path.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'/' | b'-'
| b'_'
| b'.'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b':'
| b'@'
)
})
&& path[1..]
.split('/')
.all(|part| !matches!(part, "" | "." | ".."))
}
fn allowed_header(header: &str) -> bool {
!header.is_empty()
&& header.len() <= 128
&& header
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !header.starts_with("proxy-")
&& !header.starts_with("sec-")
&& !matches!(
header,
"authorization"
| "www-authenticate"
| "cookie"
| "set-cookie"
| "host"
| "connection"
| "keep-alive"
| "transfer-encoding"
| "te"
| "trailer"
| "upgrade"
| "content-length"
| "expect"
| "forwarded"
| "via"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-api-key"
| "api-key"
)
}
fn validate_origin(origin: &str) -> Result<(), PluginBindingError> {
if origin.len() > 512 || !origin.is_ascii() {
return Err(PluginBindingError::InvalidOrigin);
}
let url = Url::parse(origin).map_err(|_| PluginBindingError::InvalidOrigin)?;
if url.scheme() != "https" || url.origin().ascii_serialization() != origin {
return Err(PluginBindingError::InvalidOrigin);
}
let public = match url.host().ok_or(PluginBindingError::InvalidOrigin)? {
Host::Domain(host) => {
let labels: Vec<_> = host.split('.').collect();
labels.len() >= 2
&& host.len() <= 253
&& labels.iter().all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.as_bytes()[0].is_ascii_alphanumeric()
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
})
&& ![
"localhost",
"local",
"internal",
"home",
"lan",
"test",
"invalid",
"onion",
"arpa",
]
.iter()
.any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}")))
}
Host::Ipv4(ip) => public_ipv4(ip),
Host::Ipv6(ip) => public_ipv6(ip),
};
if !public {
return Err(PluginBindingError::NonPublicDestination);
}
Ok(())
}
fn public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
// Conservative special-purpose exclusion; no Workspace-configurable bypass.
!matches!(a, 0 | 10 | 127 | 224..=255)
&& !(a == 100 && (64..=127).contains(&b))
&& !(a == 169 && b == 254)
&& !(a == 172 && (16..=31).contains(&b))
&& !(a == 192 && (b == 0 || b == 168 || (b == 88 && c == 99)))
&& !(a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100)))
&& !(a == 203 && b == 0 && c == 113)
}
fn public_ipv6(ip: Ipv6Addr) -> bool {
let [a, b, ..] = ip.segments();
// Only current global-unicast space; reject transition/special-purpose and
// documentation ranges as well as mapped IPv4, local, multicast and ULA.
(0x2000..=0x3fff).contains(&a)
&& !(a == 0x2001 && (b <= 0x01ff || b == 0x0db8))
&& a != 0x2002
&& !(a == 0x3fff && b <= 0x0fff)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,529 @@
use super::*;
use serde_json::{Value, json};
fn input() -> Value {
json!({
"origin": "https://api.example.com",
"methods": ["GET", "POST"],
"path_prefixes": ["/api", "/v2"],
"request_headers": ["accept", "content-type"],
"response_headers": ["content-type", "etag"],
"secret_ref": "secret-123",
"limits": {
"timeout_ms": 1000,
"max_request_bytes": 1024,
"max_response_bytes": 4096,
"max_header_bytes": 512
}
})
}
fn binding(value: Value) -> PluginConnectionBinding {
serde_json::from_value(value).unwrap()
}
#[test]
fn origin_input_table_requires_canonical_exact_https_and_public_destination() {
for (origin, expected) in [
("https://api.example.com", Ok(())),
("https://api.example.com:8443", Ok(())),
("https://8.8.8.8", Ok(())),
("https://[2606:4700:4700::1111]", Ok(())),
(
"http://api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com/",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com/api",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com?token=value",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com#fragment",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://user:password@api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://@api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com:443",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://API.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com\\evil",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com\n",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://*.example.com",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://localhost",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://metadata.google.internal",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://service.local",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://example.com.",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://127.0.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://10.0.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://172.16.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://192.168.1.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://169.254.169.254",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://100.64.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://192.0.2.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://198.51.100.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://203.0.113.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://224.1.2.3",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://0.0.0.0",
Err(PluginBindingError::NonPublicDestination),
),
("https://2130706433", Err(PluginBindingError::InvalidOrigin)),
("https://0177.0.0.1", Err(PluginBindingError::InvalidOrigin)),
(
"https://[::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[::ffff:7f00:1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[fd00::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[fe80::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[2001:db8::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[2002:7f00:1::]",
Err(PluginBindingError::NonPublicDestination),
),
] {
let mut value = input();
value["origin"] = json!(origin);
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
expected,
"{origin}"
);
}
}
#[test]
fn path_input_table_rejects_traversal_and_preserves_segment_boundaries() {
for (path, accepted) in [
("/", true),
("/api", true),
("/api/v1", true),
("api", false),
("//evil.example", false),
("/api/", false),
("/api//v1", false),
("/api/../admin", false),
("/./api", false),
("/api\\admin", false),
("/api?x=1", false),
("/api#x", false),
("/api/%2e%2e/admin", false),
("/api%2fadmin", false),
("/api/%252e%252e", false),
("/api/%5Cadmin", false),
("https://evil.example/api", false),
("/api/\n", false),
] {
let mut value = input();
value["path_prefixes"] = json!([path]);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{path}"
);
}
let value = binding(input());
for (path, allowed) in [
("/api", true),
("/api/v1", true),
("/apix", false),
("/admin", false),
("/api/../admin", false),
] {
assert_eq!(value.allows_path(path), allowed, "{path}");
}
for paths in [json!([]), json!(["/api", "/api"])] {
let mut value = input();
value["path_prefixes"] = paths;
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidPaths)
);
}
}
#[test]
fn header_input_table_rejects_guest_credentials_and_transport_controls() {
for field in ["request_headers", "response_headers"] {
for (name, accepted) in [
("accept", true),
("content-type", true),
("x-request-id", true),
("authorization", false),
("cookie", false),
("set-cookie", false),
("host", false),
("proxy-authorization", false),
("connection", false),
("content-length", false),
("transfer-encoding", false),
("upgrade", false),
("te", false),
("trailer", false),
("keep-alive", false),
("sec-websocket-key", false),
("x-api-key", false),
("Content-Type", false),
("x-a\r\n", false),
("x-a: value", false),
("", false),
] {
let mut value = input();
value[field] = json!([name]);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{field}:{name}"
);
}
let mut value = input();
value[field] = json!(["accept", "accept"]);
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidHeaders)
);
}
}
#[test]
fn limits_input_table_requires_positive_finite_values_within_each_server_bound() {
let ceiling = PluginConnectionPolicyCeiling::default();
for (field, max) in [
("timeout_ms", ceiling.limits.timeout_ms),
("max_request_bytes", ceiling.limits.max_request_bytes),
("max_response_bytes", ceiling.limits.max_response_bytes),
("max_header_bytes", ceiling.limits.max_header_bytes),
] {
for (amount, accepted) in [(0, false), (1, true), (max, true), (max + 1, false)] {
let mut value = input();
value["limits"][field] = json!(amount);
assert_eq!(
binding(value).validate(&ceiling).is_ok(),
accepted,
"{field}:{amount}"
);
}
for invalid in [
json!(-1),
json!(1.5),
json!(4294967296u64),
json!("unlimited"),
Value::Null,
] {
let mut value = input();
value["limits"][field] = invalid;
assert!(
serde_json::from_value::<PluginConnectionBinding>(value).is_err(),
"{field}"
);
}
}
let mut zero_ceiling = ceiling;
zero_ceiling.limits.timeout_ms = 0;
assert_eq!(
binding(input()).validate(&zero_ceiling),
Err(PluginBindingError::InvalidLimits)
);
}
#[test]
fn secret_and_identity_inputs_accept_only_opaque_workspace_references() {
for (reference, accepted) in [
("", true),
("secret_123-a", true),
("/etc/secret", false),
("file:///etc/secret", false),
("https://secret.example/token", false),
("Bearer token", false),
("env:API_KEY", false),
("../secret", false),
] {
let mut value = input();
value["secret_ref"] = json!(reference);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{reference}"
);
}
for (identity, accepted) in [
("api-binding", true),
("", false),
("../binding", false),
("https://example.com", false),
] {
let bindings = BTreeMap::from([(identity.to_string(), binding(input()))]);
assert_eq!(
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).is_ok(),
accepted
);
}
}
#[test]
fn json_boundary_is_closed_and_methods_network_are_not_ambient_authority() {
for (field, value) in [
("secret_value", json!("password")),
("token", json!("token")),
("proxy", json!("https://proxy.example")),
("host_path", json!("/tmp")),
("tls_verify", json!(false)),
("allow_private", json!(true)),
("network", json!("private")),
("methods", json!(["CONNECT"])),
] {
let mut source = input();
source[field] = value;
assert!(
serde_json::from_value::<PluginConnectionBinding>(source).is_err(),
"{field}"
);
}
for methods in [json!([]), json!(["GET", "GET"])] {
let mut source = input();
source["methods"] = methods;
assert_eq!(
binding(source).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidMethods)
);
}
let mut source = input();
source["limits"]["unlimited"] = json!(true);
assert!(serde_json::from_value::<PluginConnectionBinding>(source).is_err());
}
#[test]
fn policy_digest_is_order_independent_and_covers_every_policy_field() {
let original = binding(input());
let mut reordered = original.clone();
reordered.methods.reverse();
reordered.path_prefixes.reverse();
reordered.request_headers.reverse();
reordered.response_headers.reverse();
assert_eq!(original.digest(), reordered.digest());
assert_eq!(
original.digest(),
"sha256:df6bd126eb6e825349a3c2e40f48f9d18732969a0775fe6f1b7f11b1667869c9"
);
assert_eq!(original.digest().len(), 71);
assert!(original.digest().starts_with("sha256:"));
for (field, value) in [
("origin", json!("https://other.example.com")),
("methods", json!(["GET"])),
("path_prefixes", json!(["/v2"])),
("request_headers", json!([])),
("response_headers", json!([])),
("secret_ref", json!("other-secret")),
] {
let mut source = input();
source[field] = value;
assert_ne!(original.digest(), binding(source).digest(), "{field}");
}
for field in [
"timeout_ms",
"max_request_bytes",
"max_response_bytes",
"max_header_bytes",
] {
let mut source = input();
source["limits"][field] = json!(2);
assert_ne!(original.digest(), binding(source).digest(), "{field}");
}
}
#[test]
fn policy_collections_and_references_have_finite_size_bounds() {
let ceiling = PluginConnectionPolicyCeiling::default();
for (count, accepted) in [(64, true), (65, false)] {
for field in ["path_prefixes", "request_headers", "response_headers"] {
let mut source = input();
source[field] = json!(
(0..count)
.map(|n| {
if field == "path_prefixes" {
format!("/path-{n}")
} else {
format!("x-header-{n}")
}
})
.collect::<Vec<_>>()
);
assert_eq!(
binding(source).validate(&ceiling).is_ok(),
accepted,
"{field}:{count}"
);
}
}
for (count, accepted) in [(256, true), (257, false)] {
let bindings = (0..count)
.map(|n| (format!("binding-{n}"), binding(input())))
.collect();
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
}
for (size, accepted) in [(128, true), (129, false)] {
let mut source = input();
source["secret_ref"] = json!("a".repeat(size));
assert_eq!(binding(source).validate(&ceiling).is_ok(), accepted);
let bindings = BTreeMap::from([("a".repeat(size), binding(input()))]);
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
}
}
fn evaluate_schema(source: &str) -> Result<Value, String> {
use config_source::{
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeSnapshot,
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
};
let path = VirtualPath::parse("main.dcdl").unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
source,
)
.unwrap()])
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:plugins",
"plugins",
"1",
include_str!("../../../../resources/config-schema/plugins.dcdl"),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(
&ToolchainContract::with_schema_bundle(1, vec![path], 1, bundle),
);
result
.map(|evaluated| evaluated.projections[0].data_json.clone())
.map_err(|error| format!("{error:?}"))
}
#[test]
fn bootstrap_schema_defaults_maps_and_projects_typed_binding_to_rust() {
assert_eq!(
evaluate_schema("{}").unwrap(),
json!({"plugins": {"enabled": {}, "bindings": {}}})
);
let projected = evaluate_schema(r#"{
plugins = {
enabled = { demo = {
installation_id = "plugin-1"; expected_last_operation = "op-1";
package_digest = "package"; artifact_digest = "artifact"; tools = ["query"];
}; };
bindings = { api = {
origin = "https://api.example.com"; methods = ["GET"]; path_prefixes = ["/api"];
limits = { timeout_ms = 1000; max_request_bytes = 1024; max_response_bytes = 4096; max_header_bytes = 512; };
}; };
};
}"#).unwrap();
assert_eq!(projected["plugins"]["enabled"]["demo"]["logging"], false);
assert_eq!(
projected["plugins"]["enabled"]["demo"]["connections"],
json!({})
);
assert_eq!(projected["plugins"]["enabled"]["demo"]["config"], json!({}));
let bindings: BTreeMap<String, PluginConnectionBinding> =
serde_json::from_value(projected["plugins"]["bindings"].clone()).unwrap();
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).unwrap();
}
#[test]
fn bootstrap_schema_rejects_unknown_fields_and_wrong_binding_shapes() {
for source in [
"{ plugins = { token = \"value\"; }; }",
"{ plugins = { bindings = { api = { origin = 123; }; }; }; }",
"{ plugins = { enabled = { demo = { arbitrary = true; }; }; }; }",
"{ plugins = { bindings = { api = \"https://api.example.com\"; }; }; }",
] {
assert!(evaluate_schema(source).is_err(), "{source}");
}
}
@@ -27,7 +27,8 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
assert_eq!(catalog_schema(c),canonical); assert_eq!(catalog_schema(c),canonical);
let after=workspace_schema_migration_history(c)?; let after=workspace_schema_migration_history(c)?;
assert!(after.starts_with(&before)); assert!(after.starts_with(&before));
assert_eq!(after.last().unwrap(),&(106,crate::plugin_catalog::MIGRATION_NAME.into())); assert_eq!(after.last().unwrap(),&(107,"Immutable Worker Plugin plans and retained pins".into()));
assert!(after.contains(&(106,crate::plugin_catalog::MIGRATION_NAME.into())));
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins"); assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err()); assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err());
@@ -57,7 +58,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
migrated migrated
.with_conn(|c| { .with_conn(|c| {
assert_eq!(current_schema_version(c)?, 106); assert_eq!(current_schema_version(c)?, 107);
crate::plugin_catalog::verify(c)?; crate::plugin_catalog::verify(c)?;
Ok(()) Ok(())
}) })
+419
View File
@@ -0,0 +1,419 @@
//! Immutable, Server-owned Plugin execution snapshots and same-database pins.
//!
//! The config owner supplies an already validated, non-secret projection; this
//! module never reads config, selects a Profile, fetches packages or resolves
//! credentials. Host-bound execution identities are trusted inputs, not caller
//! authentication. Service entrypoints must authenticate/authorize every call,
//! including replay and reads. A saved plan is an upper bound, not live authority.
//!
//! Owner transaction functions compose with Worker creation in the catalog DB.
//! Their local savepoints also roll back partial pin acquisition if an owner
//! catches an error and commits other work. There is no cross-store compensation.
use crate::{Error, Result, plugin_catalog::PluginCatalogError};
use manifest::plugin;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use server_api::{PluginArtifact, PluginGrantMetadata};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
mod store;
pub use store::*;
/// Included by the config owner's ordered database migration, never at runtime.
pub const SCHEMA_SQL: &str = include_str!("plugin_plan/schema.sql");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigProvenance {
pub content_digest: String,
pub toolchain_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileProvenance {
pub identity: String,
pub projection_digest: String,
}
/// Supplied by the authenticated Host launch/delegation boundary, never by guest
/// or model input. These are identities only, not credentials or provider handles.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostExecutionIdentity {
pub runtime_id: String,
pub worker_id: String,
pub principal_id: String,
pub delegation_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BindingPolicyReference {
pub slot: String,
pub binding_id: String,
pub policy_digest: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginSurface {
Tool,
}
/// Only capabilities implemented by the initial Tool Host are representable.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginGrant {
Logging,
Https { slot: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginSchemaIdentity {
pub identity: String,
pub version: String,
pub digest: String,
pub namespace: String,
}
/// The upstream config/schema authority must validate config values as non-secret
/// and intersect grants/surfaces with Workspace, Profile and delegation bounds.
/// Binding policies remain with their owner: only identities and digests travel.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginPinProjection {
pub config: Value,
pub surfaces: Vec<PluginSurface>,
pub tools: Vec<String>,
pub grants: Vec<PluginGrant>,
pub bindings: Vec<BindingPolicyReference>,
}
/// All fields are private: construct from admitted artifact metadata, not a
/// hand-built wire snapshot. Deserialization alone does not establish admission.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPin {
installation_id: String,
expected_last_operation: String,
package_id: String,
package_version: String,
package_digest: String,
artifact_digest: String,
artifact_size: u32,
package_schema_version: u32,
component_world: String,
required_imports: Vec<String>,
config_schema: PluginSchemaIdentity,
config_digest: String,
projection: PluginPinProjection,
}
impl WorkerPluginPin {
pub fn from_artifact(
installation_id: String,
expected_last_operation: String,
artifact: &PluginArtifact,
mut projection: PluginPinProjection,
) -> Result<Self> {
if artifact.revoked {
return conflict();
}
if !valid_identity(&installation_id)
|| !valid_identity(&expected_last_operation)
|| !valid_digest(&artifact.package_digest)
|| !valid_digest(&artifact.artifact_digest)
|| artifact.artifact_size == 0
|| artifact.schema_version != plugin::SUPPORTED_PLUGIN_API_VERSION
|| !matches!(
artifact.world.as_str(),
plugin::PLUGIN_COMPONENT_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
)
|| !valid_digest(&artifact.config_schema.digest)
|| !valid_identity(&artifact.package_id)
|| !valid_identity(&artifact.version)
|| !valid_identity(&artifact.config_schema.identity)
|| !valid_identity(&artifact.config_schema.version)
|| !valid_identity(&artifact.config_schema.namespace)
|| !projection.config.is_object()
{
return invalid();
}
projection.surfaces.sort();
projection.surfaces.dedup();
projection.tools.sort();
projection.tools.dedup();
projection.grants.sort();
projection.grants.dedup();
projection.bindings.sort();
projection.config = canonical_value(&projection.config);
if (!projection.tools.is_empty() && !projection.surfaces.contains(&PluginSurface::Tool))
|| projection.tools.iter().any(|name| {
!valid_identity(name) || !artifact.tools.iter().any(|tool| tool.name == *name)
})
|| projection
.bindings
.windows(2)
.any(|b| b[0].slot == b[1].slot)
|| projection.bindings.iter().any(|binding| {
!valid_identity(&binding.slot)
|| !valid_identity(&binding.binding_id)
|| !valid_digest(&binding.policy_digest)
|| !artifact
.connections
.iter()
.any(|slot| slot.name == binding.slot)
})
|| projection.grants.iter().any(|grant| match grant {
PluginGrant::Logging => !artifact
.requested_grants
.contains(&PluginGrantMetadata::Logging),
PluginGrant::Https { slot } => {
!valid_identity(slot)
|| !artifact
.requested_grants
.contains(&PluginGrantMetadata::Https { slot: slot.clone() })
|| !projection
.bindings
.iter()
.any(|binding| binding.slot == *slot)
}
})
|| projection.bindings.iter().any(|binding| {
!projection.grants.contains(&PluginGrant::Https {
slot: binding.slot.clone(),
})
})
{
return invalid();
}
let mut required_imports = artifact.required_imports.clone();
required_imports.sort();
required_imports.dedup();
Ok(Self {
installation_id,
expected_last_operation,
package_id: artifact.package_id.clone(),
package_version: artifact.version.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size,
package_schema_version: artifact.schema_version,
component_world: artifact.world.clone(),
required_imports,
config_schema: PluginSchemaIdentity {
identity: artifact.config_schema.identity.clone(),
version: artifact.config_schema.version.clone(),
digest: artifact.config_schema.digest.clone(),
namespace: artifact.config_schema.namespace.clone(),
},
config_digest: digest_json(&projection.config)?,
projection,
})
}
pub fn installation_id(&self) -> &str {
&self.installation_id
}
pub fn expected_last_operation(&self) -> &str {
&self.expected_last_operation
}
pub fn package_id(&self) -> &str {
&self.package_id
}
pub fn package_version(&self) -> &str {
&self.package_version
}
pub fn package_schema_version(&self) -> u32 {
self.package_schema_version
}
pub fn component_world(&self) -> &str {
&self.component_world
}
pub fn required_imports(&self) -> &[String] {
&self.required_imports
}
pub fn package_digest(&self) -> &str {
&self.package_digest
}
pub fn artifact_digest(&self) -> &str {
&self.artifact_digest
}
pub fn artifact_size(&self) -> u32 {
self.artifact_size
}
pub fn config_schema(&self) -> &PluginSchemaIdentity {
&self.config_schema
}
pub fn config_digest(&self) -> &str {
&self.config_digest
}
pub fn projection(&self) -> &PluginPinProjection {
&self.projection
}
pub(super) fn matches_artifact(&self, artifact: &PluginArtifact) -> Result<bool> {
Ok(Self::from_artifact(
self.installation_id.clone(),
self.expected_last_operation.clone(),
artifact,
self.projection.clone(),
)? == *self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPlanInput {
pub workspace_id: String,
pub config: ConfigProvenance,
pub profile: ProfileProvenance,
pub execution: HostExecutionIdentity,
pub plugins: Vec<WorkerPluginPin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginExecutionPlan {
format_version: u32,
input: WorkerPluginPlanInput,
}
impl WorkerPluginExecutionPlan {
pub fn canonicalize(mut input: WorkerPluginPlanInput) -> Result<Self> {
if !valid_identity(&input.workspace_id)
|| !valid_digest(&input.config.content_digest)
|| !valid_identity(&input.config.toolchain_fingerprint)
|| !valid_identity(&input.profile.identity)
|| !valid_digest(&input.profile.projection_digest)
|| !valid_identity(&input.execution.runtime_id)
|| !valid_identity(&input.execution.worker_id)
|| !valid_identity(&input.execution.principal_id)
|| input
.execution
.delegation_id
.as_ref()
.is_some_and(|id| !valid_identity(id))
|| input.plugins.len() > 128
{
return invalid();
}
input
.plugins
.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
// One selection per installation; two aliases of one package instead use
// distinct installation identities, as required by the catalog contract.
if input
.plugins
.windows(2)
.any(|pins| pins[0].installation_id == pins[1].installation_id)
{
return invalid();
}
// Public Tool names use immutable installation identities, never aliases.
let mut names = BTreeSet::new();
for pin in &input.plugins {
for tool in &pin.projection.tools {
let name = format!("{}__{}", pin.installation_id, tool);
if name.len() > 128 || !names.insert(name) {
return invalid();
}
}
}
Ok(Self {
format_version: 1,
input,
})
}
pub fn workspace_id(&self) -> &str {
&self.input.workspace_id
}
pub fn config(&self) -> &ConfigProvenance {
&self.input.config
}
pub fn profile(&self) -> &ProfileProvenance {
&self.input.profile
}
pub fn execution(&self) -> &HostExecutionIdentity {
&self.input.execution
}
pub fn plugins(&self) -> &[WorkerPluginPin] {
&self.input.plugins
}
pub fn fingerprint(&self) -> Result<String> {
digest_json(self)
}
pub fn canonical_json(&self) -> Result<String> {
encode_canonical(self)
}
pub(super) fn validate_canonical(&self) -> Result<()> {
if self.format_version != 1 || Self::canonicalize(self.input.clone())? != *self {
return invalid();
}
Ok(())
}
}
fn invalid<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::Invalid))
}
fn conflict<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::Conflict))
}
fn not_found<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::NotFound))
}
fn valid_identity(value: &str) -> bool {
!value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
}
fn valid_digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
fn canonical_value(value: &Value) -> Value {
match value {
Value::Object(fields) => {
let mut keys: Vec<_> = fields.keys().collect();
keys.sort();
Value::Object(
keys.into_iter()
.map(|key| (key.clone(), canonical_value(&fields[key])))
.collect(),
)
}
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
_ => value.clone(),
}
}
fn encode_canonical(value: &impl Serialize) -> Result<String> {
let value = serde_json::to_value(value)
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))?;
serde_json::to_string(&canonical_value(&value))
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))
}
fn digest_json(value: &impl Serialize) -> Result<String> {
let bytes = encode_canonical(value)?;
Ok(hash_bytes(bytes.as_bytes()))
}
fn hash_bytes(bytes: &[u8]) -> String {
let hex: String = Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{hex}")
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,41 @@
//! 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| {
assert!(workspace_schema_migration_history(conn)?.starts_with(&before));
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,736 @@
//! 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(&[]);
}
}
@@ -0,0 +1,697 @@
//! 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());
}
validate_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 validate_effective_config(value: &serde_json::Value, schema: &str) -> Result<()> {
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)?;
evaluate(&snapshot, &contract(bundle))?;
Ok(())
}
pub(crate) fn render_value(value: &serde_json::Value) -> Result<String> {
Ok(match value {
serde_json::Value::Object(fields) => format!(
"{{ {} }}",
fields
.iter()
.map(|(key, value)| Ok(format!(
"{} = {};",
if safe_name(key) {
key.clone()
} else {
return Err(config_error("config key is not a static identifier"));
},
render_value(value)?
)))
.collect::<Result<Vec<_>>>()?
.join(" ")
),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(render_value)
.collect::<Result<Vec<_>>>()?
.join(", ")
),
serde_json::Value::Null => return Err(config_error("null is not a Plugin config value")),
value => serde_json::to_string(value).map_err(config_error)?,
})
}
/// Build a maximum immutable snapshot from the current Workspace config and an
/// actual registered Profile. The Host supplies execution/delegation identities;
/// a caller-provided projection, binding digest or package payload is never used.
/// The owning Worker-create transaction may further narrow this snapshot, then
/// persist it with `plugin_plan_create` and its own launch fingerprint atomically.
/// This does not admit execution, resolve secrets or start/restore a Worker.
pub fn resolve_worker_plugin_plan(
tx: &Transaction<'_>,
workspace: &str,
expected_content_digest: &str,
profile_selector: &str,
execution: crate::plugin_plan::HostExecutionIdentity,
) -> Result<crate::plugin_plan::WorkerPluginExecutionPlan> {
use crate::plugin_plan::{
ConfigProvenance, ProfileProvenance, WorkerPluginExecutionPlan, WorkerPluginPlanInput,
};
let state = crate::config_source::load_state(tx, workspace)?
.ok_or_else(|| config_error("Workspace config is not materialized"))?;
if state.snapshot.digest != expected_content_digest {
return Err(Error::WorkspaceConfigConflict(
"plan source content changed".into(),
));
}
let (contract, evaluation, resolved) = evaluate_catalog_config(
tx,
workspace,
&state.snapshot,
state.contract.schema_bundle.clone(),
)?;
if state.contract != contract || state.projection_digest != evaluation.projection_digest {
return Err(Error::WorkspaceConfigConflict(
"plan source catalog/schema changed".into(),
));
}
let profile = resolve_profile(&evaluation, &state.snapshot.digest, profile_selector)?;
let projection_digest = config_source::digest_bytes(
&serde_json::to_vec(&profile.manifest_snapshot).map_err(config_error)?,
);
WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput {
workspace_id: workspace.into(),
config: ConfigProvenance {
content_digest: state.snapshot.digest,
toolchain_fingerprint: contract.fingerprint,
},
profile: ProfileProvenance {
identity: profile_selector.into(),
projection_digest,
},
execution,
plugins: project_profile_plugins(&resolved, &profile.manifest.plugins)?,
})
}
fn resolve_profile(
evaluation: &EvaluationResult,
content_digest: &str,
selector: &str,
) -> Result<manifest::ResolvedProfile> {
let root = &evaluation.projections[0].data_json;
if selector.starts_with("project:") {
let value = root
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
.and_then(|entries| {
entries.iter().find(|entry| {
entry.get("selector").and_then(serde_json::Value::as_str) == Some(selector)
})
})
.and_then(|entry| entry.get("profile"))
.ok_or_else(|| config_error("unknown registered Profile"))?;
manifest::resolve_profile_artifact_value(
value.clone(),
manifest::ProfileSource::Archive {
archive_id: format!("workspace-config:{content_digest}"),
source: selector.into(),
},
std::path::Path::new("/"),
"plugin-plan-projection",
)
.map_err(config_error)
} else if selector.starts_with("builtin:") {
let registry = manifest::ProfileDiscovery::with_sources(None, None)
.discover()
.map_err(config_error)?;
manifest::ProfileResolver::new()
.with_workspace_base("/")
.resolve_from_registry(
&manifest::ProfileSelector::source_named(
manifest::ProfileRegistrySource::Builtin,
selector.trim_start_matches("builtin:"),
),
&registry,
manifest::ProfileResolveOptions::with_worker_name("plugin-plan-projection"),
)
.map_err(config_error)
} else {
Err(config_error("unknown registered Profile selector"))
}
}
/// Check the owner-supplied launch snapshot against config authority on first
/// create. Exact replay deliberately uses the saved snapshot instead of rereading
/// changed config. Delegation can only remove pins/tools/grants from this maximum.
pub(crate) fn validate_plan_source(
tx: &Transaction<'_>,
plan: &crate::plugin_plan::WorkerPluginExecutionPlan,
) -> Result<()> {
let maximum = resolve_worker_plugin_plan(
tx,
plan.workspace_id(),
&plan.config().content_digest,
&plan.profile().identity,
plan.execution().clone(),
)?;
if maximum.config() != plan.config() || maximum.profile() != plan.profile() {
return Err(config_error("plan source provenance mismatch"));
}
for pin in plan.plugins() {
let upper = maximum
.plugins()
.iter()
.find(|p| p.installation_id() == pin.installation_id())
.ok_or_else(|| config_error("plan exceeds selected Profile"))?;
let lower = pin.projection();
let max = upper.projection();
if pin.package_digest() != upper.package_digest()
|| pin.artifact_digest() != upper.artifact_digest()
|| pin.expected_last_operation() != upper.expected_last_operation()
|| pin.config_digest() != upper.config_digest()
|| pin.config_schema() != upper.config_schema()
|| lower.config != max.config
|| lower.tools.iter().any(|tool| !max.tools.contains(tool))
|| lower.grants.iter().any(|grant| !max.grants.contains(grant))
|| lower
.bindings
.iter()
.any(|binding| !max.bindings.contains(binding))
|| lower
.surfaces
.iter()
.any(|surface| !max.surfaces.contains(surface))
{
return Err(config_error("plan exceeds config/Profile upper bound"));
}
}
Ok(())
}
#[cfg(test)]
mod tests;
pub(crate) fn base_schema_bundle(
bundle: &WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigSchemaBundle> {
let mut contributions = bundle
.contributions
.iter()
.filter(|p| p.provider_id != PROVIDER)
.cloned()
.collect::<Vec<_>>();
if bundle
.contributions
.iter()
.any(|p| p.provider_id == PROVIDER)
{
contributions.push(PluginConfigSchemaProvider.contribution()?);
}
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
}
// Saved config retains the schema that validated it, even when live catalog
// admission changes. Management must remain available to disable/rebind it after
// restart. New preview/create still rechecks the live catalog inside its tx.
pub(crate) fn materialization_schema(
desired: WorkspaceConfigSchemaBundle,
saved: &WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigSchemaBundle> {
let prefix = format!("{}:", config_source::digest_bytes(BOOTSTRAP.as_bytes()));
let active = saved
.contributions
.iter()
.find(|p| p.provider_id == PROVIDER && p.version.starts_with(&prefix));
let Some(active) = active else {
return Ok(desired);
};
let contributions = desired.contributions.into_iter().map(|p| {
if p.provider_id == PROVIDER {
active.clone()
} else {
p
}
});
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
}
@@ -0,0 +1,523 @@
//! 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 {
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("0.1.0"),
)
.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> {
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 = \"{}\"",
"config_schema = \"{ mode = String; }\"",
);
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
);
}
@@ -104,6 +104,7 @@ mod schema_convergence {
version: 106, version: 106,
name: crate::plugin_catalog::MIGRATION_NAME.into(), name: crate::plugin_catalog::MIGRATION_NAME.into(),
}, },
WorkspaceSchemaMigrationStep { version:107, name:"Immutable Worker Plugin plans and retained pins".into() },
] ]
); );
assert_eq!( assert_eq!(
@@ -121,6 +122,7 @@ mod schema_convergence {
expected.push((104, "Converge Passkey results and Job input retention".into())); expected.push((104, "Converge Passkey results and Job input retention".into()));
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into())); expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into())); expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
expected.push((107,"Immutable Worker Plugin plans and retained pins".into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected); assert_eq!(workspace_schema_migration_history(conn)?, expected);
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] { for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}"); assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
+33 -1
View File
@@ -4434,7 +4434,8 @@ impl WorkspaceApi {
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) .with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
.with_provider(Arc::new(RuntimeConfigSchemaProvider)) .with_provider(Arc::new(RuntimeConfigSchemaProvider))
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
.with_provider(Arc::new(skills::SkillConfigSchemaProvider)); .with_provider(Arc::new(skills::SkillConfigSchemaProvider))
.with_provider(Arc::new(crate::plugin_settings::PluginConfigSchemaProvider));
let materialization_store = config_store.clone(); let materialization_store = config_store.clone();
let workspace_id = config.workspace_id.clone(); let workspace_id = config.workspace_id.clone();
let schema = config_schema_registry.compose()?; let schema = config_schema_registry.compose()?;
@@ -7479,6 +7480,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::workspace_prompt_projection( .merge(server_api::server_api_axum::workspace_prompt_projection(
service.clone(), service.clone(),
)) ))
.merge(server_api::server_api_axum::workspace_config_tree_preview(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_config_tree_commit( .merge(server_api::server_api_axum::workspace_config_tree_commit(
service.clone(), service.clone(),
)) ))
@@ -9324,6 +9328,34 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error) .map_err(ApiError::into_repository_api_error)
} }
async fn workspace_config_tree_preview(
&self,
workspace_id: String,
request: server_api::ConfigCommitRequest,
) -> std::result::Result<server_api::WorkspaceConfigTreeResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?.clone();
api.store
.db_executor()
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let candidate = prepare_workspace_config_tree(
&api,
&workspace_id,
&config_commit_request_from_api(request)?,
)?;
Ok(workspace_config_state_to_api(
crate::config_source::WorkspaceConfigState {
snapshot: candidate.snapshot,
contract: candidate.contract,
projection_digest: candidate.evaluation.projection_digest,
},
))
})
.await
.map_err(|error: ApiError| error.into_repository_api_error())
}
async fn workspace_config_tree_commit( async fn workspace_config_tree_commit(
&self, &self,
workspace_id: String, workspace_id: String,
@@ -412,3 +412,32 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
assert_eq!(bytes, package_bytes("0.1.0")); assert_eq!(bytes, package_bytes("0.1.0"));
shutdown_workspace_feature_storage(&reopened).await.unwrap(); shutdown_workspace_feature_storage(&reopened).await.unwrap();
} }
#[tokio::test]
async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() {
let dir=tempfile::tempdir().unwrap();
let api=test_api(dir.path()).await;
let owner=seed_test_workspace_owner_token(api.store.as_ref(),"plugin-config-owner").await;
let reader=seed_test_api_token(api.store.as_ref(),"plugin-config-reader");
let app=build_router(api.clone());
let prefix=format!("/api/w/{TEST_WORKSPACE_ID}");
let upload=request(app.clone(),Some(&owner),"POST",&format!("{prefix}/plugin-uploads"),package_bytes("0.1.0"),true,StatusCode::OK).await;
let installed=json(app.clone(),&owner,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"configured","operation_key":"config-install"}),StatusCode::OK).await;
let state=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
let a=&installed["installation"]["artifacts"][0];
let source=format!("{{ plugins.enabled.echo = {{ installation_id = {}; expected_last_operation = {}; package_digest = {}; artifact_digest = {}; tools = [\"example_echo\"]; config = {{}}; }}; }} as WorkspaceConfigSchema",installed["installation"]["installation_id"],installed["operation_id"],a["package_digest"],a["artifact_digest"]);
let change=serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]});
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::FORBIDDEN).await;
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::FORBIDDEN).await;
let preview=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::OK).await;
let unchanged=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
assert_eq!(unchanged["snapshot"]["digest"],state["snapshot"]["digest"]);
let saved=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::CREATED).await;
assert_eq!(saved,preview);
assert_ne!(saved["snapshot"]["digest"],state["snapshot"]["digest"]);
json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change,StatusCode::CONFLICT).await;
json(app.clone(),&owner,&format!("{prefix}/plugins/{}/uninstall",installed["installation"]["installation_id"].as_str().unwrap()),serde_json::json!({"expected_last_operation":installed["operation_id"],"operation_key":"config-blocks-uninstall"}),StatusCode::CONFLICT).await;
let current=api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
let candidates=current.contract.schema_bundle.contributions.iter().filter(|c|c.provider_id=="builtin:plugins").count();
assert_eq!(candidates,1);
}
@@ -595,7 +595,10 @@ pub(super) async fn commit(
if candidate.base_digest != state.snapshot.digest if candidate.base_digest != state.snapshot.digest
|| candidate.base_toolchain_fingerprint != state.contract.fingerprint || candidate.base_toolchain_fingerprint != state.contract.fingerprint
|| candidate.base_projection_digest != state.projection_digest || candidate.base_projection_digest != state.projection_digest
|| candidate.contract.schema_bundle.fingerprint != schema_fingerprint || crate::plugin_settings::base_schema_bundle(&candidate.contract.schema_bundle)
.map_err(pre_error)?
.fingerprint
!= schema_fingerprint
{ {
return Err(fail(409, "stale_validator")); return Err(fail(409, "stale_validator"));
} }
+31 -4
View File
@@ -127,7 +127,7 @@ pub use ticket::{
}; };
const OLDEST_SCHEMA_VERSION: i64 = 50; const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 106; const LATEST_SCHEMA_VERSION: i64 = 107;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -465,6 +465,11 @@ const MIGRATIONS: &[Migration] = &[
name: crate::plugin_catalog::MIGRATION_NAME, name: crate::plugin_catalog::MIGRATION_NAME,
apply: crate::plugin_catalog::migrate, apply: crate::plugin_catalog::migrate,
}, },
Migration {
version: 107,
name: "Immutable Worker Plugin plans and retained pins",
apply: migrate_plugin_plans_v106_to_v107,
},
]; ];
// Both schema-103 identities shipped. Keep the alternate implementation and accept // Both schema-103 identities shipped. Keep the alternate implementation and accept
@@ -15755,10 +15760,24 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
// Fresh composition uses the posts contract. Retained fixtures pass frozen host // Fresh composition uses the posts contract. Retained fixtures pass frozen host
// and component DDL to the shared cross-domain constraint composition below. // and component DDL to the shared cross-domain constraint composition below.
fn 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(())
}
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?; create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?; conn.execute_batch(include_str!("activation_schema.sql"))?;
conn.execute_batch(include_str!("passkey_results_schema.sql"))?; conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
Ok(()) Ok(())
} }
@@ -16800,6 +16819,7 @@ fn apply_migrations_with_secret_source(
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?; verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
crate::plugin_catalog::verify(conn)?; crate::plugin_catalog::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
verify_workspace_runtime_binding_schema(conn)?; verify_workspace_runtime_binding_schema(conn)?;
verify_workspace_runtime_verification_schema(conn)?; verify_workspace_runtime_verification_schema(conn)?;
verify_workspace_deletion_schema(conn)?; verify_workspace_deletion_schema(conn)?;
@@ -19494,6 +19514,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()}, WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()},
WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()}, WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()},
WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()}, WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
WorkspaceSchemaMigrationStep {version:107, name:"Immutable Worker Plugin plans and retained pins".into()},
] ]
); );
@@ -19610,6 +19631,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(104, "Converge Passkey results and Job input retention".to_string()), (104, "Converge Passkey results and Job input retention".to_string()),
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()), (105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()), (106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
(107, "Immutable Worker Plugin plans and retained pins".to_string()),
] ]
); );
assert!(!table_exists(conn, "trusted_runtime_records")?); assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -22897,7 +22919,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(103, "Passkey metadata and atomic addition results".into()), (103, "Passkey metadata and atomic addition results".into()),
(104, "Converge Passkey results and Job input retention".into()), (104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into()), (105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into())]); (106, crate::plugin_catalog::MIGRATION_NAME.into()),
(107, "Immutable Worker Plugin plans and retained pins".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) { for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
} }
@@ -27091,7 +27114,7 @@ INSERT INTO worker_registry (
.unwrap(); .unwrap();
store.with_conn(|conn| { store.with_conn(|conn| {
// This fixture deliberately reconstructs schema 102 from fresh. // This fixture deliberately reconstructs schema 102 from fresh.
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?; conn.execute_batch("DROP TABLE plugin_execution_plans; DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?;
// Remove the newer Subject fence composition, not just its receipt. // Remove the newer Subject fence composition, not just its receipt.
let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")? let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")?
.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?; .query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -27103,7 +27126,7 @@ INSERT INTO worker_registry (
DROP TRIGGER passkey_metadata_insert; DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts; DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata; DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=106; UPDATE __yoi_schema_migrations SET version=102 WHERE version=107;
CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(()) CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(())
}).unwrap(); }).unwrap();
drop(store); drop(store);
@@ -28419,3 +28442,7 @@ mod frozen_runtime_binding_tests;
#[cfg(test)] #[cfg(test)]
#[path = "plugin_catalog/migration_tests.rs"] #[path = "plugin_catalog/migration_tests.rs"]
mod plugin_catalog_migration_tests; mod plugin_catalog_migration_tests;
#[cfg(test)]
#[path = "plugin_plan/migration_tests.rs"]
mod plugin_plan_migration_tests;
@@ -16,6 +16,7 @@ use crate::{Error, Result};
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally /// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
/// excluded so retries and audit remain available after the Workspace row is gone. /// excluded so retries and audit remain available after the Workspace row is gone.
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"plugin_execution_plans",
"plugin_references", "plugin_references",
"plugin_operations", "plugin_operations",
"plugin_uploads", "plugin_uploads",
@@ -508,6 +509,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
// commit; update guards remain installed throughout. // commit; update guards remain installed throughout.
let mut ticket_delete_guards = Vec::new(); let mut ticket_delete_guards = Vec::new();
for (table, trigger) in [ for (table, trigger) in [
("plugin_execution_plans", "plugin_plan_replay_retained"),
("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"), ("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"),
("notification_job_targets", "notification_job_targets_no_delete"), ("notification_job_targets", "notification_job_targets_no_delete"),
("notification_job_legacy_receipts", "notification_job_legacy_no_delete"), ("notification_job_legacy_receipts", "notification_job_legacy_no_delete"),
+108 -1
View File
@@ -19062,7 +19062,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f" "x-yoi-source-digest": "sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -23579,6 +23579,113 @@
] ]
} }
}, },
"/api/w/{workspace_id}/config/source-tree/preview": {
"post": {
"operationId": "workspace_config_tree_preview",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigCommitRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorkspaceConfigTreeResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/drive/capabilities": { "/api/w/{workspace_id}/drive/capabilities": {
"get": { "get": {
"operationId": "drive_capabilities", "operationId": "drive_capabilities",
@@ -0,0 +1,21 @@
{
"schema": "{ plugins = { enabled = { echo = { installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = { ...String } default {}; config = { mode = String; }; }; }; bindings = {} default {}; }; }",
"cases": [
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = \"test\"; }; } as WorkspaceConfigSchema",
"mode": "test"
},
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config = { mode = \"test\"; unknown = true; }; }; } as WorkspaceConfigSchema",
"diagnostic": "constraint_violation"
},
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = 42; }; } as WorkspaceConfigSchema",
"diagnostic": "constraint_violation"
},
{
"source": "{ plugins.enabled.echo = { installation_id = \"i\"; expected_last_operation = \"op\"; package_digest = \"package\"; artifact_digest = \"artifact\"; tools = [\"echo\"]; config.mode = String; }; } as WorkspaceConfigSchema",
"diagnostic": "materialize"
}
]
}
+31
View File
@@ -0,0 +1,31 @@
let
PluginEnablement = {
installation_id = String;
expected_last_operation = String;
package_digest = String;
artifact_digest = String;
tools = [...String];
logging = Bool default false;
connections = { ...String } default {};
config = { ...Unknown } default {};
};
in {
plugins = {
enabled = { ...PluginEnablement } default {};
bindings = { ...{
origin = String;
network = String default "public_internet";
methods = [...String];
path_prefixes = [...String];
request_headers = [...String] default [];
response_headers = [...String] default [];
secret_ref = String default "";
limits = {
timeout_ms = Int;
max_request_bytes = Int;
max_response_bytes = Int;
max_header_bytes = Int;
};
} } default {};
} default {};
}
@@ -76,6 +76,13 @@ let
search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; }; search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; };
fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; }; fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; };
}; };
plugins = { selections = [...{
installation_id = String; tools = [...String]; logging = Bool;
https_slots = [...String];
# 非秘密のoverrideは補完用のshapeではなく、導入済みpackageの
# closed typed schemaで検証する。
config = { ...Unknown };
}]; };
skills = { directories = [...String]; }; skills = { directories = [...String]; };
}; };
in { in {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:eca6e2cd9309ecf9fe73dcb044d82a5549950d66e93cd33157b2b2b3fa8ea82f) // Input contract: 0.1.0 (sha256:e538f535da2bcf56ee07f439f8e29ef58a2fc55fc6f03eac31604ef2b3cc1dbc)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -316,6 +316,21 @@ export async function fetchConfigHistory(
); );
} }
export async function previewConfigTree(
workspaceId: string,
request: ConfigCommitRequest,
fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> {
return await readJson(
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
);
}
export async function commitConfigTree( export async function commitConfigTree(
workspaceId: string, workspaceId: string,
request: ConfigCommitRequest, request: ConfigCommitRequest,
@@ -7,6 +7,12 @@ export function apply_changes(changes: any): any;
export function changes_between(base: any, candidate: any): any; export function changes_between(base: any, candidate: any): any;
/**
* Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
*
* Convert the returned start position back to UTF-16 so non-ASCII characters do not
* misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
*/
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any; export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
export function compose_schema_bundle(contributions: any): any; export function compose_schema_bundle(contributions: any): any;
@@ -21,6 +27,12 @@ export function format_source(source: string): string;
export function set_schema_bundle(schema_bundle: any): void; export function set_schema_bundle(schema_bundle: any): void;
/**
* Retain the browser's edit candidate as a snapshot.
*
* Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
* Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
*/
export function set_snapshot(snapshot: any): void; export function set_snapshot(snapshot: any): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
@@ -44,6 +44,10 @@ export function changes_between(base, candidate) {
} }
/** /**
* Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
*
* Convert the returned start position back to UTF-16 so non-ASCII characters do not
* misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
* @param {string} entrypoint * @param {string} entrypoint
* @param {string} source * @param {string} source
* @param {number} utf16_offset * @param {number} utf16_offset
@@ -154,6 +158,10 @@ export function set_schema_bundle(schema_bundle) {
} }
/** /**
* Retain the browser's edit candidate as a snapshot.
*
* Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
* Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
* @param {any} snapshot * @param {any} snapshot
*/ */
export function set_snapshot(snapshot) { export function set_snapshot(snapshot) {
@@ -13,6 +13,7 @@ import {
fetchConfigHistory, fetchConfigHistory,
fetchConfigTree, fetchConfigTree,
parseWorkspaceConfigTreeResponse, parseWorkspaceConfigTreeResponse,
previewConfigTree,
} from "../../src/lib/workspace/config-source/api.ts"; } from "../../src/lib/workspace/config-source/api.ts";
function response(body: unknown, status = 200): Response { function response(body: unknown, status = 200): Response {
@@ -335,3 +336,24 @@ Deno.test("config source API carries bounded optional authoring schemas without
}), ConfigSourceApiError); }), ConfigSourceApiError);
} }
}); });
Deno.test("config source API previews a candidate without calling commit", async () => {
const request = {
base_digest: "sha256:base",
changes: [],
entrypoints: ["main.dcdl"],
};
const calls: string[] = [];
const result = await previewConfigTree(
"w/one",
request,
((input: string | URL | Request, init?: RequestInit) => {
calls.push(String(input));
assertEquals(init?.method, "POST");
assertEquals(JSON.parse(String(init?.body)), request);
return Promise.resolve(response(tree));
}) as typeof fetch,
);
assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]);
assertEquals<unknown>(result, tree);
});
@@ -985,3 +985,61 @@ Deno.test("generated WASM rejects forged content identity at the snapshot bounda
assertThrows(() => set_snapshot(forged)); assertThrows(() => set_snapshot(forged));
} }
}); });
Deno.test("Plugin closed preflight shares native projection and diagnostic fixture without catalog authority", async () => {
const fixture = JSON.parse(
await Deno.readTextFile(
new URL(
"../../../../resources/config-schema/fixtures/plugin-preflight.json",
import.meta.url,
),
),
) as {
schema: string;
cases: Array<{ source: string; mode?: string; diagnostic?: string }>;
};
const bundle = compose_schema_bundle([{
provider_id: "plugin-preflight",
namespace: "plugins",
version: "1",
source: fixture.schema,
source_digest: await digestText(fixture.schema),
}]) as WorkspaceConfigSchemaBundle;
const contract: ToolchainContract = {
contract_version: 2,
decodal_version: "0.4.0",
schema_version: 1,
entrypoints: ["main.dcdl"],
import_policy_version: 1,
schema_bundle: bundle,
fingerprint: await toolchainFingerprint(["main.dcdl"], bundle),
};
for (const testCase of fixture.cases) {
const candidate = fixtureSnapshot(schemaSnapshot(testCase.source));
if (testCase.diagnostic) {
let failure: ProjectedDiagnostic[] = [];
try {
evaluate_snapshot(candidate, contract);
} catch (error) {
failure = error as ProjectedDiagnostic[];
}
assertEquals(Array.isArray(failure), true);
assertEquals(failure[0].kind, testCase.diagnostic);
assertEquals(failure[0].path, "main.dcdl");
} else {
const result = evaluate_snapshot(candidate, contract) as {
projections: Array<
{
data_json: {
plugins: { enabled: { echo: { config: { mode: string } } } };
};
}
>;
};
assertEquals(
result.projections[0].data_json.plugins.enabled.echo.config.mode,
testCase.mode,
);
}
}
});