feat: add Workspace Plugin catalog and immutable artifact authority

This commit is contained in:
2026-10-29 06:49:51 +09:00
parent 5087abc5ab
commit 7ad6030498
30 changed files with 5643 additions and 42 deletions
+4
View File
@@ -60,3 +60,7 @@ required-features = ["typescript"]
[[example]]
name = "generate_drive_api_types"
required-features = ["typescript"]
[[example]]
name = "generate_plugin_api_types"
required-features = ["typescript"]
@@ -0,0 +1,6 @@
fn main() {
print!(
"{}",
server_api::normalized_typescript(server_api::plugin_api_typescript())
);
}
+81
View File
@@ -9,7 +9,12 @@ pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, Web
pub mod authorization;
mod drive;
mod notification;
mod plugin;
mod plugin_types;
pub use notification::*;
pub use plugin::*;
#[cfg(feature = "typescript")]
pub use plugin_types::plugin_api_typescript;
mod ticket_browse;
pub use ticket_browse::*;
pub mod repository_openapi_typescript;
@@ -205,6 +210,15 @@ macro_rules! impl_openapi_schema {
}
impl_openapi_schema!(
PluginInstallRequest,
PluginAddArtifactRequest,
PluginRevokeRequest,
PluginUninstallRequest,
PluginListRequest,
PluginUploadResponse,
PluginInstallation,
PluginListResponse,
PluginOperationResponse,
SubjectPermissionsResponse,
SubjectPermissionsMutationResponse,
SubjectPermissionInterruption,
@@ -2890,6 +2904,73 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugin-uploads", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_upload(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[binary] body: BinaryBody,
) -> Result<PluginUploadResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_install(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[body] request: PluginInstallRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
async fn plugin_list(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[query] query: PluginListRequest,
) -> Result<PluginListResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugins/{installation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
async fn plugin_show(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
) -> Result<PluginInstallation, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/{installation_id}/artifacts", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_artifact_add(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
#[body] request: PluginAddArtifactRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/{installation_id}/revoke", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_revoke(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
#[body] request: PluginRevokeRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/plugins/{installation_id}/uninstall", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
async fn plugin_uninstall(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] installation_id: String,
#[body] request: PluginUninstallRequest,
) -> Result<PluginOperationResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
async fn plugin_operation(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] operation_id: String,
) -> Result<PluginOperationResponse, RepositoryApiError>;
// Ordinary operations accept member browser sessions or trusted Worker request
// context. The service resolves Worker grants; no operation accepts an actor or
// grant ID from the caller. Grant management is Workspace-owner-only.
+184
View File
@@ -0,0 +1,184 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginInstallRequest {
pub upload_id: String,
pub alias: String,
pub operation_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginAddArtifactRequest {
pub upload_id: String,
pub expected_last_operation: String,
pub operation_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginRevokeRequest {
pub artifact_digest: String,
pub expected_last_operation: String,
pub reason: String,
pub operation_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginUninstallRequest {
pub expected_last_operation: String,
pub operation_key: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginListRequest {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginSchemaContribution {
pub identity: String,
pub version: String,
pub digest: String,
pub namespace: String,
pub dcdl: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginToolMetadata {
pub name: String,
pub description: String,
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pub input_schema: serde_json::Value,
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pub output_schema: serde_json::Value,
pub error_codes: Vec<String>,
pub external_write: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "UPPERCASE")]
pub enum PluginHttpMethod {
Get,
Head,
Post,
Put,
Patch,
Delete,
Options,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionMetadata {
pub name: String,
pub methods: Vec<PluginHttpMethod>,
pub path_prefixes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginGrantMetadata {
Logging,
Https { slot: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginArtifact {
pub package_id: String,
pub name: String,
pub version: String,
pub description: Option<String>,
pub package_digest: String,
pub artifact_digest: String,
pub artifact_size: u32,
pub schema_version: u32,
pub world: String,
pub required_imports: Vec<String>,
pub tools: Vec<PluginToolMetadata>,
pub connections: Vec<PluginConnectionMetadata>,
pub requested_grants: Vec<PluginGrantMetadata>,
pub config_schema: PluginSchemaContribution,
pub revoked: bool,
pub revocation_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginUploadResponse {
pub upload_id: String,
pub expires_at: String,
pub artifact: PluginArtifact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginInstallation {
pub installation_id: String,
pub alias: String,
pub package_id: String,
pub last_operation: String,
pub uninstalled: bool,
pub artifacts: Vec<PluginArtifact>,
}
/// Catalog pages omit artifact/schema payloads so the item bound also bounds
/// response work. Exact immutable metadata is available from installation show.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginInstallationSummary {
pub installation_id: String,
pub alias: String,
pub package_id: String,
pub last_operation: String,
pub uninstalled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginListResponse {
pub installations: Vec<PluginInstallationSummary>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginOperationKind {
Install,
AddArtifact,
Revoke,
Uninstall,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginOperationResponse {
pub operation_id: String,
pub kind: PluginOperationKind,
pub installation: PluginInstallation,
}
+66
View File
@@ -0,0 +1,66 @@
//! Generated management types share the exact DTOs used by the HTTP declaration.
#[cfg(feature = "typescript")]
pub fn plugin_api_typescript() -> String {
use crate::*;
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
PluginInstallRequest::decl(&config),
PluginAddArtifactRequest::decl(&config),
PluginRevokeRequest::decl(&config),
PluginUninstallRequest::decl(&config),
PluginListRequest::decl(&config),
PluginSchemaContribution::decl(&config),
PluginToolMetadata::decl(&config),
PluginHttpMethod::decl(&config),
PluginConnectionMetadata::decl(&config),
PluginGrantMetadata::decl(&config),
PluginArtifact::decl(&config),
PluginUploadResponse::decl(&config),
PluginInstallation::decl(&config),
PluginInstallationSummary::decl(&config),
PluginListResponse::decl(&config),
PluginOperationKind::decl(&config),
PluginOperationResponse::decl(&config),
];
format!(
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts\n\n{}\n",
declarations.map(|d| format!("export {d}")).join("\n\n")
)
}
#[cfg(test)]
mod tests {
#[test]
fn plugin_routes_use_binary_upload_and_closed_management_contracts() {
let document = crate::canonical_openapi_document().unwrap();
let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap();
let upload = &value["paths"]["/api/w/{workspace_id}/plugin-uploads"]["post"];
assert!(upload["requestBody"]["content"]["application/octet-stream"].is_object());
assert!(upload["requestBody"]["content"]["application/json"].is_null());
for path in [
"plugins",
"plugins/{installation_id}",
"plugin-operations/{operation_id}",
] {
assert!(value["paths"][format!("/api/w/{{workspace_id}}/{path}")]["get"].is_object());
}
assert!(
serde_json::from_value::<crate::PluginInstallRequest>(serde_json::json!({
"upload_id":"x", "alias":"a", "operation_key":"k", "role":"owner"
}))
.is_err()
);
assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/enable"].is_null());
assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/data"].is_null());
}
#[cfg(feature = "typescript")]
#[test]
fn generated_plugin_types_are_current() {
assert_eq!(
crate::normalized_typescript(super::plugin_api_typescript()),
include_str!("../../../web/workspace/src/lib/generated/plugin-api.ts")
);
}
}
@@ -479,7 +479,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into()),
(104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into())
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
@@ -368,10 +368,13 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no
}
// Preserve seeded assignment evidence while rebuilding the actual pre-business
// capture composition. Changing only a receipt leaves schema105 admission fences
// capture composition. Changing only a receipt leaves current admission fences
// and erasure guards in an otherwise legacy fixture.
fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> {
assert_eq!(current_schema_version(conn)?, 105);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations;
DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations;
UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("retained96.db");
prepare_retained_schema(&path, 96);
@@ -1,4 +1,5 @@
-- Canonical composed Workspace Server schema. New databases are created directly at this generation.
-- Plugin catalog schema is composed by store.rs.
-- Ticket creates its standalone schema first; replace those empty tables with the composed authority.
DROP TABLE typed_ticket_event_attributes;
DROP TABLE typed_ticket_event_references;
+3
View File
@@ -12,6 +12,7 @@ pub mod hosts;
pub mod memory_backend;
pub mod memory_staging;
pub mod observation;
pub mod plugin_catalog;
pub mod profile_settings;
pub mod prompt_settings;
pub mod records;
@@ -161,6 +162,8 @@ pub enum Error {
WorkerSourceIdentity(String),
#[error("Workspace signing identity error ({code}): {message}")]
WorkspaceSigningIdentity { code: String, message: String },
#[error("Plugin catalog error: {0}")]
PluginCatalog(#[from] plugin_catalog::PluginCatalogError),
#[error("Workspace Drive error: {0}")]
Drive(#[from] workspace_drive::Error),
#[error("DB execution capacity exceeded")]
@@ -851,11 +851,11 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
};
assert_eq!(current_schema_version(&conn).unwrap(), 98);
drop(conn);
// Compare the complete current composition, not upgraded98 against fresh105.
// Compare the complete current composition, not upgraded98 against fresh current schema.
let upgraded_store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let upgraded = upgraded_store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 105);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
Ok(snapshot(conn))
})
.unwrap();
@@ -0,0 +1,733 @@
//! Workspace catalog authority. The authenticated service checks current read/manage
//! permission before entering these methods; neither a digest nor an actor string is
//! an authorization credential. Blob publication, retention and GC share SQLite's
//! immediate write transaction, including across independent Server connections.
use crate::{Error, Result, store::SqliteWorkspaceStore};
use chrono::{Duration, Utc};
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use server_api::*;
use sha2::{Digest, Sha256};
use std::{
collections::BTreeSet,
fs,
io::{Read, Write},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PluginCatalogError {
#[error("invalid Plugin catalog request")]
Invalid,
#[error("Plugin catalog resource not found")]
NotFound,
#[error("Plugin catalog operation conflicts with observed state")]
Conflict,
#[error("Plugin installation has retained references")]
Referenced,
#[error("Plugin package exceeds size limits")]
TooLarge,
#[error("Plugin storage quota exceeded")]
Quota,
#[error("Plugin artifact storage unavailable")]
Unavailable,
}
impl PluginCatalogError {
pub fn status(self) -> u16 {
match self {
Self::Invalid => 400,
Self::NotFound => 404,
Self::Conflict | Self::Referenced => 409,
Self::TooLarge => 413,
Self::Quota => 429,
Self::Unavailable => 503,
}
}
pub fn code(self) -> &'static str {
match self {
Self::Invalid => "plugin_invalid",
Self::NotFound => "plugin_not_found",
Self::Conflict => "plugin_conflict",
Self::Referenced => "plugin_referenced",
Self::TooLarge => "plugin_too_large",
Self::Quota => "plugin_quota",
Self::Unavailable => "plugin_unavailable",
}
}
}
const WORKSPACE_BYTES: i64 = 1024 * 1024 * 1024;
const ACTOR_UPLOAD_BYTES: i64 = 256 * 1024 * 1024;
const MAX_UPLOADS: i64 = 128;
const MAX_ACTOR_UPLOADS: i64 = 32;
const MAX_INSTALLATIONS: i64 = 128;
const MAX_ARTIFACTS: i64 = 64;
pub(crate) const MIGRATION_NAME: &str =
"Workspace Plugin catalog and immutable artifact references";
pub(crate) fn migrate(conn: &Connection) -> Result<()> {
let version: i64 = conn.query_row(
"SELECT max(version) FROM __yoi_schema_migrations",
[],
|r| r.get(0),
)?;
if version != 105 {
return Err(Error::Store(
"Plugin catalog migration requires schema 105".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(include_str!("plugin_catalog_schema.sql"))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,?1)",
[MIGRATION_NAME],
)?;
verify(&tx)?;
tx.commit()?;
Ok(())
}
pub(crate) fn verify(conn: &Connection) -> Result<()> {
let expected = Connection::open_in_memory()?;
expected.execute_batch(include_str!("plugin_catalog_schema.sql"))?;
let objects = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name")?
.query_map([], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?;
for (name, sql) in objects {
let actual = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name=?1",
[&name],
|r| r.get::<_, String>(0),
)
.optional()?;
if actual.as_deref() != Some(sql.as_str()) {
return Err(Error::Store(
"Plugin catalog storage schema has drifted".into(),
));
}
}
Ok(())
}
fn fail<T>(error: PluginCatalogError) -> Result<T> {
Err(Error::PluginCatalog(error))
}
fn encode<T: Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|_| PluginCatalogError::Unavailable.into())
}
fn decode<T: DeserializeOwned>(value: &str) -> Result<T> {
serde_json::from_str(value).map_err(|_| PluginCatalogError::Unavailable.into())
}
fn hash(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn id(prefix: &str) -> String {
format!("{prefix}-{}", uuid::Uuid::now_v7())
}
fn bounded(value: &str, max: usize) -> bool {
!value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
}
fn digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(hex_digest)
}
fn hex_digest(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn workspace_exists(conn: &Connection, workspace: &str) -> Result<()> {
if !conn.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1)",
[workspace],
|r| r.get::<_, bool>(0),
)? {
return fail(PluginCatalogError::NotFound);
}
Ok(())
}
fn validate_identity(actor: &str, key: &str) -> Result<()> {
if !bounded(actor, 512) || !bounded(key, 128) {
return fail(PluginCatalogError::Invalid);
}
Ok(())
}
fn storage<T>(result: std::io::Result<T>) -> Result<T> {
result.map_err(|_| PluginCatalogError::Unavailable.into())
}
fn blob_path(root: &Path, artifact: &str) -> Result<PathBuf> {
if !digest(artifact) {
return fail(PluginCatalogError::Invalid);
}
Ok(root.join("blobs").join(&artifact[7..]))
}
struct PendingFile(PathBuf);
impl Drop for PendingFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
fn read_blob(root: &Path, artifact: &str, size: u64) -> Result<Vec<u8>> {
let path = blob_path(root, artifact)?;
let metadata = storage(fs::symlink_metadata(&path))?;
if !metadata.is_file()
|| metadata.len() != size
|| size > PluginPackageLimits::default().max_package_size_bytes
{
return fail(PluginCatalogError::Unavailable);
}
let file = storage(fs::File::open(path))?;
let mut bytes = Vec::new();
storage(file.take(size + 1).read_to_end(&mut bytes))?;
if bytes.len() as u64 != size || format!("sha256:{}", hash(&bytes)) != artifact {
return fail(PluginCatalogError::Unavailable);
}
Ok(bytes)
}
// Caller holds SQLite write ownership until the upload reference commits. GC
// cannot unlink a candidate between publication and the first durable reference.
fn publish_blob(root: &Path, artifact: &str, bytes: &[u8]) -> Result<()> {
let path = blob_path(root, artifact)?;
storage(fs::create_dir_all(root.join("blobs")))?;
if path.exists() {
read_blob(root, artifact, bytes.len() as u64)?;
return Ok(());
}
let pending = PendingFile(root.join("blobs").join(id(".pending")));
let mut file = storage(
fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&pending.0),
)?;
storage(file.write_all(bytes))?;
storage(file.sync_all())?;
match fs::hard_link(&pending.0, &path) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
read_blob(root, artifact, bytes.len() as u64)?;
}
Err(_) => return fail(PluginCatalogError::Unavailable),
}
storage(fs::File::open(root.join("blobs")))?
.sync_all()
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Unavailable))?;
Ok(())
}
fn artifact_metadata(
package: &plugin::MaterializedPluginPackage,
canonical: &plugin::CanonicalPluginArtifact,
) -> Result<PluginArtifact> {
let m = &package.package.manifest;
let schema_digest = format!("sha256:{}", hash(m.config_schema.as_bytes()));
Ok(PluginArtifact {
package_id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
package_digest: canonical.package_digest.clone(),
artifact_digest: canonical.artifact_digest.clone(),
artifact_size: u32::try_from(canonical.artifact_size)
.map_err(|_| PluginCatalogError::TooLarge)?,
schema_version: m.schema_version,
world: m.runtime.world.clone(),
required_imports: m.runtime.required_imports.clone(),
tools: m
.tools
.iter()
.map(|t| PluginToolMetadata {
name: t.name.clone(),
description: t.description.clone(),
input_schema: t.input_schema.clone(),
output_schema: t.output_schema.clone(),
error_codes: t.error_codes.clone(),
external_write: t.external_write,
})
.collect(),
connections: decode(&encode(&m.connections)?)?,
requested_grants: decode(&encode(&m.requested_grants)?)?,
config_schema: PluginSchemaContribution {
identity: format!("{}/config", m.id),
version: m.version.clone(),
namespace: format!("{}/config/{}/{}", m.id, m.version, schema_digest),
digest: schema_digest,
dcdl: m.config_schema.clone(),
},
revoked: false,
revocation_reason: None,
})
}
fn load_artifact(
conn: &Connection,
workspace: &str,
installation: &str,
artifact: &str,
) -> Result<PluginArtifact> {
let row=conn.query_row("SELECT metadata_json,revoked,revocation_reason FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND artifact_digest=?3",params![workspace,installation,artifact],|r|Ok((r.get::<_,String>(0)?,r.get::<_,bool>(1)?,r.get::<_,Option<String>>(2)?))).optional()?;
let Some((json, revoked, reason)) = row else {
return fail(PluginCatalogError::NotFound);
};
let mut result: PluginArtifact = decode(&json)?;
result.revoked = revoked;
result.revocation_reason = reason;
Ok(result)
}
fn load_installation(
conn: &Connection,
workspace: &str,
installation: &str,
) -> Result<PluginInstallation> {
let row=conn.query_row("SELECT alias,package_id,last_operation,uninstalled FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,bool>(3)?))).optional()?;
let Some((alias, package_id, last_operation, uninstalled)) = row else {
return fail(PluginCatalogError::NotFound);
};
let digests=conn.prepare("SELECT artifact_digest FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 ORDER BY artifact_digest")?.query_map(params![workspace,installation],|r|r.get::<_,String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
let artifacts = digests
.iter()
.map(|d| load_artifact(conn, workspace, installation, d))
.collect::<Result<Vec<_>>>()?;
Ok(PluginInstallation {
installation_id: installation.into(),
alias,
package_id,
last_operation,
uninstalled,
artifacts,
})
}
fn compare_installation(
conn: &Connection,
workspace: &str,
installation: &str,
expected: &str,
) -> Result<PluginInstallation> {
let current = load_installation(conn, workspace, installation)?;
if current.uninstalled {
return fail(PluginCatalogError::NotFound);
}
if current.last_operation != expected {
return fail(PluginCatalogError::Conflict);
}
Ok(current)
}
fn load_upload(
conn: &Connection,
workspace: &str,
actor: &str,
upload: &str,
) -> Result<(PluginArtifact, i64)> {
let row=conn.query_row("SELECT metadata_json,expires_at FROM plugin_uploads WHERE workspace_id=?1 AND actor=?2 AND upload_id=?3",params![workspace,actor,upload],|r|Ok((r.get::<_,String>(0)?,r.get::<_,i64>(1)?))).optional()?;
let Some((json, expires)) = row else {
return fail(PluginCatalogError::NotFound);
};
Ok((decode(&json)?, expires))
}
fn kind_name(kind: PluginOperationKind) -> &'static str {
match kind {
PluginOperationKind::Install => "install",
PluginOperationKind::AddArtifact => "add_artifact",
PluginOperationKind::Revoke => "revoke",
PluginOperationKind::Uninstall => "uninstall",
}
}
fn replay(
conn: &Connection,
workspace: &str,
actor: &str,
kind: PluginOperationKind,
key: &str,
fingerprint: &str,
) -> Result<Option<PluginOperationResponse>> {
let saved=conn.query_row("SELECT fingerprint,result_json FROM plugin_operations WHERE workspace_id=?1 AND actor=?2 AND kind=?3 AND operation_key=?4",params![workspace,actor,kind_name(kind),key],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()?;
if let Some((old, json)) = saved {
if old != fingerprint {
return fail(PluginCatalogError::Conflict);
}
return Ok(Some(decode(&json)?));
}
Ok(None)
}
fn save_operation(
tx: &Transaction<'_>,
workspace: &str,
actor: &str,
key: &str,
fingerprint: &str,
kind: PluginOperationKind,
operation: &str,
installation: &str,
) -> Result<PluginOperationResponse> {
tx.execute("UPDATE plugin_installations SET last_operation=?3 WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation,operation])?;
let result = PluginOperationResponse {
operation_id: operation.into(),
kind,
installation: load_installation(tx, workspace, installation)?,
};
tx.execute("INSERT INTO plugin_operations(workspace_id,operation_id,actor,kind,operation_key,fingerprint,result_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,operation,actor,kind_name(kind),key,fingerprint,encode(&result)?])?;
Ok(result)
}
fn add_artifact(
tx: &Transaction<'_>,
workspace: &str,
installation: &str,
artifact: &PluginArtifact,
) -> Result<()> {
let existing=tx.query_row("SELECT artifact_digest,package_digest,revoked FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2 AND version=?3",params![workspace,installation,artifact.version],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?))).optional()?;
if let Some((old, package, revoked)) = existing {
if old != artifact.artifact_digest || package != artifact.package_digest || revoked {
return fail(PluginCatalogError::Conflict);
}
return Ok(());
}
let count: i64 = tx.query_row(
"SELECT count(*) FROM plugin_artifacts WHERE workspace_id=?1 AND installation_id=?2",
params![workspace, installation],
|r| r.get(0),
)?;
if count >= MAX_ARTIFACTS {
return fail(PluginCatalogError::Quota);
}
check_workspace_bytes(tx, workspace, artifact.artifact_size as i64)?;
tx.execute("INSERT INTO plugin_artifacts(workspace_id,installation_id,artifact_digest,package_digest,version,artifact_size,metadata_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,installation,artifact.artifact_digest,artifact.package_digest,artifact.version,artifact.artifact_size as i64,encode(artifact)?])?;
Ok(())
}
fn check_workspace_bytes(conn: &Connection, workspace: &str, additional: i64) -> Result<()> {
let bytes:i64=conn.query_row("SELECT (SELECT coalesce(sum(a.artifact_size),0) FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id,installation_id) WHERE a.workspace_id=?1 AND i.uninstalled=0) + (SELECT coalesce(sum(artifact_size),0) FROM plugin_uploads WHERE workspace_id=?1 AND expires_at>?2)",params![workspace,Utc::now().timestamp()],|r|r.get(0))?;
if bytes + additional > WORKSPACE_BYTES {
return fail(PluginCatalogError::Quota);
}
Ok(())
}
impl SqliteWorkspaceStore {
pub fn plugin_upload(
&self,
root: &Path,
workspace: &str,
actor: &str,
bytes: &[u8],
) -> Result<PluginUploadResponse> {
if !bounded(actor, 512) {
return fail(PluginCatalogError::Invalid);
}
let limits = PluginPackageLimits::default();
if bytes.len() as u64 > limits.max_package_size_bytes {
return fail(PluginCatalogError::TooLarge);
}
let package = plugin::read_plugin_package_bytes(bytes, PluginSourceKind::Explicit, &limits)
.map_err(|e| {
if e.kind == plugin::PluginDiagnosticKind::Bounds {
PluginCatalogError::TooLarge
} else {
PluginCatalogError::Invalid
}
})?;
let canonical = plugin::pack_plugin_package(&package, &limits)
.map_err(|_| PluginCatalogError::Invalid)?;
let artifact = artifact_metadata(&package, &canonical)?;
self.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
workspace_exists(&tx,workspace)?;
let now=Utc::now();
let (count,own,own_bytes):(i64,i64,i64)=tx.query_row("SELECT count(*),coalesce(sum(actor=?2),0),coalesce(sum(CASE WHEN actor=?2 THEN artifact_size ELSE 0 END),0) FROM plugin_uploads WHERE workspace_id=?1 AND expires_at>?3",params![workspace,actor,now.timestamp()],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
if count>=MAX_UPLOADS || own>=MAX_ACTOR_UPLOADS || own_bytes+canonical.artifact_size as i64>ACTOR_UPLOAD_BYTES {return fail(PluginCatalogError::Quota);}
check_workspace_bytes(&tx,workspace,canonical.artifact_size as i64)?;
publish_blob(root,&canonical.artifact_digest,&canonical.bytes)?;
let upload_id=id("pu"); let expires=now+Duration::hours(24);
tx.execute("INSERT INTO plugin_uploads(workspace_id,upload_id,actor,expires_at,artifact_digest,artifact_size,metadata_json) VALUES(?1,?2,?3,?4,?5,?6,?7)",params![workspace,upload_id,actor,expires.timestamp(),artifact.artifact_digest,artifact.artifact_size as i64,encode(&artifact)?])?;
tx.commit()?;
Ok(PluginUploadResponse{upload_id,expires_at:expires.to_rfc3339_opts(chrono::SecondsFormat::Secs,true),artifact:artifact.clone()})
})
}
pub fn plugin_install(
&self,
workspace: &str,
actor: &str,
request: &PluginInstallRequest,
) -> Result<PluginOperationResponse> {
validate_identity(actor, &request.operation_key)?;
if !bounded(&request.alias, 128) || request.alias.trim() != request.alias {
return fail(PluginCatalogError::Invalid);
}
self.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let (artifact,expires)=load_upload(&tx,workspace,actor,&request.upload_id)?;
let fingerprint=hash(encode(&(request,&artifact.package_digest,&artifact.artifact_digest))?.as_bytes());
if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Install,&request.operation_key,&fingerprint)? {return Ok(saved);}
if expires<=Utc::now().timestamp() {return fail(PluginCatalogError::NotFound);}
let (count,alias):(i64,bool)=tx.query_row("SELECT count(*),coalesce(max(alias=?2),0) FROM plugin_installations WHERE workspace_id=?1 AND uninstalled=0",params![workspace,request.alias],|r|Ok((r.get(0)?,r.get(1)?)))?;
if alias {return fail(PluginCatalogError::Conflict);}
if count>=MAX_INSTALLATIONS {return fail(PluginCatalogError::Quota);}
let installation=id("pi"); let operation=id("po");
tx.execute("INSERT INTO plugin_installations(workspace_id,installation_id,alias,package_id,last_operation) VALUES(?1,?2,?3,?4,?5)",params![workspace,installation,request.alias,artifact.package_id,operation])?;
add_artifact(&tx,workspace,&installation,&artifact)?;
let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Install,&operation,&installation)?;
tx.commit()?; Ok(result)
})
}
pub fn plugin_add_artifact(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &PluginAddArtifactRequest,
) -> Result<PluginOperationResponse> {
validate_identity(actor, &request.operation_key)?;
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let (artifact, expires) = load_upload(&tx, workspace, actor, &request.upload_id)?;
let fingerprint = hash(
encode(&(
installation,
request,
&artifact.package_digest,
&artifact.artifact_digest,
))?
.as_bytes(),
);
if let Some(saved) = replay(
&tx,
workspace,
actor,
PluginOperationKind::AddArtifact,
&request.operation_key,
&fingerprint,
)? {
return Ok(saved);
}
if expires <= Utc::now().timestamp() {
return fail(PluginCatalogError::NotFound);
}
let current = compare_installation(
&tx,
workspace,
installation,
&request.expected_last_operation,
)?;
if current.package_id != artifact.package_id {
return fail(PluginCatalogError::Conflict);
}
add_artifact(&tx, workspace, installation, &artifact)?;
let result = save_operation(
&tx,
workspace,
actor,
&request.operation_key,
&fingerprint,
PluginOperationKind::AddArtifact,
&id("po"),
installation,
)?;
tx.commit()?;
Ok(result)
})
}
pub fn plugin_revoke(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &PluginRevokeRequest,
) -> Result<PluginOperationResponse> {
validate_identity(actor, &request.operation_key)?;
if !digest(&request.artifact_digest) || !bounded(&request.reason, 1024) {
return fail(PluginCatalogError::Invalid);
}
self.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let fingerprint=hash(encode(&(installation,request))?.as_bytes());
if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Revoke,&request.operation_key,&fingerprint)? {return Ok(saved);}
compare_installation(&tx,workspace,installation,&request.expected_last_operation)?;
let artifact=load_artifact(&tx,workspace,installation,&request.artifact_digest)?;
if artifact.revoked {return fail(PluginCatalogError::Conflict);}
tx.execute("UPDATE plugin_artifacts SET revoked=1,revocation_reason=?4 WHERE workspace_id=?1 AND installation_id=?2 AND artifact_digest=?3",params![workspace,installation,request.artifact_digest,request.reason])?;
let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Revoke,&id("po"),installation)?;
tx.commit()?; Ok(result)
})
}
pub fn plugin_uninstall(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &PluginUninstallRequest,
) -> Result<PluginOperationResponse> {
validate_identity(actor, &request.operation_key)?;
self.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let fingerprint=hash(encode(&(installation,request))?.as_bytes());
if let Some(saved)=replay(&tx,workspace,actor,PluginOperationKind::Uninstall,&request.operation_key,&fingerprint)? {return Ok(saved);}
compare_installation(&tx,workspace,installation,&request.expected_last_operation)?;
let referenced:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM plugin_references WHERE workspace_id=?1 AND installation_id=?2)",params![workspace,installation],|r|r.get(0))?;
if referenced {return fail(PluginCatalogError::Referenced);}
tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id=?1 AND installation_id=?2",params![workspace,installation])?;
let result=save_operation(&tx,workspace,actor,&request.operation_key,&fingerprint,PluginOperationKind::Uninstall,&id("po"),installation)?;
tx.commit()?; Ok(result)
})
}
pub fn plugin_show(&self, workspace: &str, installation: &str) -> Result<PluginInstallation> {
self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
let result = load_installation(&tx, workspace, installation)?;
tx.commit()?;
Ok(result)
})
}
pub fn plugin_operation(
&self,
workspace: &str,
operation: &str,
) -> Result<PluginOperationResponse> {
self.with_conn(|conn| {
let json=conn.query_row("SELECT result_json FROM plugin_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace,operation],|r|r.get::<_,String>(0)).optional()?;
match json {Some(json)=>decode(&json),None=>fail(PluginCatalogError::NotFound)}
})
}
pub fn plugin_list(
&self,
workspace: &str,
request: &PluginListRequest,
) -> Result<PluginListResponse> {
let limit = request.limit.unwrap_or(50);
if limit == 0 || limit > 100 {
return fail(PluginCatalogError::Invalid);
}
self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
let conn = &tx;
workspace_exists(conn,workspace)?;
let after=if let Some(cursor)=&request.cursor {
// Server generated operation identity is an opaque cursor scoped by
// a persisted membership check, never interpreted as an arbitrary offset.
let value=conn.query_row("SELECT json_extract(result_json,'$.installation.installation_id') FROM plugin_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace,cursor],|r|r.get::<_,String>(0)).optional()?;
value.ok_or(PluginCatalogError::Invalid)?
} else {String::new()};
let mut installations=conn.prepare("SELECT installation_id,alias,package_id,last_operation,uninstalled FROM plugin_installations WHERE workspace_id=?1 AND installation_id>?2 ORDER BY installation_id LIMIT ?3")?.query_map(params![workspace,after,limit+1],|r|Ok(PluginInstallationSummary{installation_id:r.get(0)?,alias:r.get(1)?,package_id:r.get(2)?,last_operation:r.get(3)?,uninstalled:r.get(4)?}))?.collect::<rusqlite::Result<Vec<_>>>()?;
let more=installations.len()>limit as usize;
installations.truncate(limit as usize);
let next_cursor=if more {installations.last().map(|i|i.last_operation.clone())} else {None};
tx.commit()?;
Ok(PluginListResponse{installations,next_cursor})
})
}
pub fn plugin_artifact_bytes(
&self,
root: &Path,
workspace: &str,
installation: &str,
artifact_digest: &str,
) -> Result<Vec<u8>> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let current = load_installation(&tx, workspace, installation)?;
if current.uninstalled {
return fail(PluginCatalogError::NotFound);
}
let artifact = load_artifact(&tx, workspace, installation, artifact_digest)?;
if artifact.revoked {
return fail(PluginCatalogError::Conflict);
}
let bytes = read_blob(root, artifact_digest, u64::from(artifact.artifact_size))?;
tx.commit()?;
Ok(bytes)
})
}
pub fn plugin_gc(&self, root: &Path) -> Result<u64> {
self.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let retained=tx.prepare("SELECT a.artifact_digest FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id,installation_id) WHERE i.uninstalled=0 UNION SELECT artifact_digest FROM plugin_uploads WHERE expires_at>?1")?.query_map([Utc::now().timestamp()],|r|r.get::<_,String>(0))?.collect::<rusqlite::Result<BTreeSet<_>>>()?;
let entries=match fs::read_dir(root.join("blobs")) {Ok(entries)=>entries,Err(e) if e.kind()==std::io::ErrorKind::NotFound=>return Ok(0),Err(_)=>return fail(PluginCatalogError::Unavailable)};
let mut removed=0;
for entry in entries {
let entry=storage(entry)?; let name=entry.file_name(); let Some(name)=name.to_str() else {continue;};
if (hex_digest(name) || name.starts_with(".pending-")) && !retained.contains(&format!("sha256:{name}")) {
storage(fs::remove_file(entry.path()))?; removed+=1;
}
}
storage(fs::File::open(root.join("blobs")))?.sync_all().map_err(|_|Error::PluginCatalog(PluginCatalogError::Unavailable))?;
tx.commit()?; Ok(removed)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginReferenceKind {
Config,
Pending,
Active,
Restorable,
}
impl PluginReferenceKind {
fn name(self) -> &'static str {
match self {
Self::Config => "config",
Self::Pending => "pending",
Self::Active => "active",
Self::Restorable => "restorable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginReference {
pub kind: PluginReferenceKind,
pub owner: String,
pub installation_id: String,
pub expected_last_operation: String,
pub package_digest: String,
pub artifact_digest: String,
}
/// Called inside the config/plan owner's transaction. The same write transaction
/// must commit that owner's content CAS and its reference changes. This function
/// does not persist a second copy of config or policy.
pub fn plugin_artifact_admit(
tx: &Transaction<'_>,
workspace: &str,
installation: &str,
expected_last_operation: &str,
package_digest: &str,
artifact_digest: &str,
) -> Result<PluginArtifact> {
compare_installation(tx, workspace, installation, expected_last_operation)?;
let artifact = load_artifact(tx, workspace, installation, artifact_digest)?;
if artifact.revoked || artifact.package_digest != package_digest {
return fail(PluginCatalogError::Conflict);
}
Ok(artifact)
}
pub fn plugin_reference_put(
tx: &Transaction<'_>,
workspace: &str,
reference: &PluginReference,
) -> Result<()> {
if !bounded(&reference.owner, 512) {
return fail(PluginCatalogError::Invalid);
}
plugin_artifact_admit(
tx,
workspace,
&reference.installation_id,
&reference.expected_last_operation,
&reference.package_digest,
&reference.artifact_digest,
)?;
tx.execute("INSERT INTO plugin_references(workspace_id,kind,owner,installation_id,artifact_digest) VALUES(?1,?2,?3,?4,?5) ON CONFLICT DO NOTHING",params![workspace,reference.kind.name(),reference.owner,reference.installation_id,reference.artifact_digest])?;
Ok(())
}
pub fn plugin_reference_remove(
tx: &Transaction<'_>,
workspace: &str,
kind: PluginReferenceKind,
owner: &str,
installation: &str,
artifact_digest: &str,
) -> Result<()> {
tx.execute("DELETE FROM plugin_references WHERE workspace_id=?1 AND kind=?2 AND owner=?3 AND installation_id=?4 AND artifact_digest=?5",params![workspace,kind.name(),owner,installation,artifact_digest])?;
Ok(())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,81 @@
//! Exercise production migration/open against retained SQLite fixtures, including
//! the oldest retained transition, marker failure rollback and real FK/CHECKs.
use super::*;
fn catalog_schema(conn: &Connection) -> Vec<(String, String)> {
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name LIKE 'plugin_%' ORDER BY name").unwrap().query_map([],|r|Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<Vec<_>>>().unwrap()
}
#[test]
fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105() {
let dir = tempfile::tempdir().unwrap();
let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap();
let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap();
for version in [50, 105] {
let path = dir.path().join(format!("v{version}.db"));
if version == 50 {
tests::prepare_schema_v50(&path, Some("workspace-a"));
} else {
tests::prepare_retained_schema(&path, version);
}
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
let before = workspace_schema_migration_history(&conn).unwrap();
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('plugin-owner','user','plugin-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('plugin-workspace','plugin-owner','Plugins','active','1','1');").unwrap();
drop(conn);
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
migrated.with_conn(|c| {
assert_eq!(catalog_schema(c),canonical);
let after=workspace_schema_migration_history(c)?;
assert!(after.starts_with(&before));
assert_eq!(after.last().unwrap(),&(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 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());
c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','i','alias','p','operation',0)",[])?;
assert!(c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','other','alias','p','operation',0)",[]).is_err());
assert!(c.execute("INSERT INTO plugin_installations VALUES('plugin-workspace','other','other','p','operation',2)",[]).is_err());
assert!(c.execute("INSERT INTO plugin_references VALUES('plugin-workspace','active','owner','i','unknown')",[]).is_err());
Ok(())
}).unwrap();
}
}
#[test]
fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_changing_applied() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("retained.db");
tests::prepare_retained_schema(&path, 105);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
let before = workspace_schema_migration_history(&conn).unwrap();
conn.execute_batch("CREATE TRIGGER interrupt_plugin_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=106 BEGIN SELECT RAISE(ABORT,'injected plugin migration interruption'); END;").unwrap();
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 105);
assert_eq!(workspace_schema_migration_history(&conn).unwrap(), before);
assert!(catalog_schema(&conn).is_empty());
conn.execute_batch("DROP TRIGGER interrupt_plugin_migration")
.unwrap();
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
migrated
.with_conn(|c| {
assert_eq!(current_schema_version(c)?, 106);
crate::plugin_catalog::verify(c)?;
Ok(())
})
.unwrap();
}
#[test]
fn plugin_catalog_startup_rejects_drifted_reference_fence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fresh.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|c| {
c.execute_batch("DROP TRIGGER plugin_reference_live")?;
Ok(())
})
.unwrap();
drop(store);
assert!(
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted"))
);
}
@@ -0,0 +1,911 @@
//! Real SQLite plus disk-component tests. Only authentication/HTTP and execution
//! are outside this suite; packages use the checked-in real Rust PDK Component.
use super::*;
use crate::store::{ControlPlaneStore, WorkspaceRecord};
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
store: SqliteWorkspaceStore,
bytes: Vec<u8>,
}
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-01T00:00:00Z".into(),
updated_at: "2026-10-01T00:00:00Z".into(),
})
.await
.unwrap();
}
Self {
dir,
store,
bytes: package("0.1.0", ""),
}
}
fn root(&self) -> PathBuf {
self.dir.path().join("artifacts")
}
fn upload(&self) -> PluginUploadResponse {
self.store
.plugin_upload(&self.root(), "a", "actor", &self.bytes)
.unwrap()
}
fn install(&self, upload: &PluginUploadResponse, alias: &str) -> PluginOperationResponse {
self.store
.plugin_install(
"a",
"actor",
&PluginInstallRequest {
upload_id: upload.upload_id.clone(),
alias: alias.into(),
operation_key: alias.into(),
},
)
.unwrap()
}
fn expire(&self) {
self.store
.with_conn(|c| {
c.execute("UPDATE plugin_uploads SET expires_at=0", [])?;
Ok(())
})
.unwrap();
}
fn count(&self, table: &str) -> i64 {
self.store
.with_conn(|c| {
Ok(c.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))?)
})
.unwrap()
}
}
fn package(version: &str, extra: &str) -> Vec<u8> {
let dir = tempdir().unwrap();
let manifest =
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""));
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
fs::write(
dir.path().join("plugin.component.wasm"),
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
)
.unwrap();
if !extra.is_empty() {
fs::write(dir.path().join("extra.txt"), extra).unwrap();
}
let parsed = plugin::read_plugin_directory(
dir.path(),
PluginSourceKind::Explicit,
&PluginPackageLimits::default(),
)
.unwrap();
plugin::pack_plugin_package(&parsed, &PluginPackageLimits::default())
.unwrap()
.bytes
}
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 reference(result: &PluginOperationResponse, kind: PluginReferenceKind) -> PluginReference {
let artifact = &result.installation.artifacts[0];
PluginReference {
kind,
owner: "owner/config-or-plan".into(),
installation_id: result.installation.installation_id.clone(),
expected_last_operation: result.operation_id.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
}
}
fn uninstall_request(result: &PluginOperationResponse, key: &str) -> PluginUninstallRequest {
PluginUninstallRequest {
expected_last_operation: result.operation_id.clone(),
operation_key: key.into(),
}
}
#[tokio::test]
async fn validated_upload_is_not_install_and_exact_published_bytes_survive_restart() {
let f = Fixture::new().await;
let upload = f.upload();
assert_eq!(f.count("plugin_installations"), 0);
let installed = f.install(&upload, "echo");
let installation = &installed.installation.installation_id;
let artifact = &upload.artifact;
assert_ne!(artifact.package_digest, artifact.artifact_digest);
assert_eq!(
artifact.config_schema.digest,
format!("sha256:{}", hash(artifact.config_schema.dcdl.as_bytes()))
);
let fetched = f
.store
.plugin_artifact_bytes(&f.root(), "a", installation, &artifact.artifact_digest)
.unwrap();
assert_eq!(fetched, f.bytes);
assert_eq!(fetched.len() as u64, u64::from(artifact.artifact_size));
plugin::verify_plugin_artifact(
&fetched,
&artifact.package_digest,
&artifact.artifact_digest,
u64::from(artifact.artifact_size),
&PluginPackageLimits::default(),
)
.unwrap();
let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
assert_eq!(
reopened.plugin_show("a", installation).unwrap(),
installed.installation
);
assert_eq!(
reopened
.plugin_artifact_bytes(&f.root(), "a", installation, &artifact.artifact_digest)
.unwrap(),
fetched
);
error(
reopened.plugin_artifact_bytes(&f.root(), "b", installation, &artifact.artifact_digest),
PluginCatalogError::NotFound,
);
error(
reopened.plugin_operation("b", &installed.operation_id),
PluginCatalogError::NotFound,
);
error(
reopened.plugin_show("b", installation),
PluginCatalogError::NotFound,
);
}
#[tokio::test]
async fn replay_returns_original_receipt_not_latest_catalog_and_survives_uninstall() {
let f = Fixture::new().await;
let upload = f.upload();
let first = f.install(&upload, "echo");
let mut request = PluginInstallRequest {
upload_id: upload.upload_id.clone(),
alias: "echo".into(),
operation_key: "echo".into(),
};
assert_eq!(
f.store.plugin_install("a", "actor", &request).unwrap(),
first
);
request.alias = "changed".into();
error(
f.store.plugin_install("a", "actor", &request),
PluginCatalogError::Conflict,
);
request.alias = "echo".into();
error(
f.store.plugin_install("a", "other-actor", &request),
PluginCatalogError::NotFound,
);
let next = f
.store
.plugin_upload(&f.root(), "a", "actor", &package("0.2.0", ""))
.unwrap();
let added = f
.store
.plugin_add_artifact(
"a",
"actor",
&first.installation.installation_id,
&PluginAddArtifactRequest {
upload_id: next.upload_id,
expected_last_operation: first.operation_id.clone(),
operation_key: "add".into(),
},
)
.unwrap();
assert_eq!(
f.store.plugin_install("a", "actor", &request).unwrap(),
first
);
error(
f.store.plugin_uninstall(
"a",
"actor",
&first.installation.installation_id,
&uninstall_request(&first, "remove"),
),
PluginCatalogError::Conflict,
);
let uninstalled = f
.store
.plugin_uninstall(
"a",
"actor",
&first.installation.installation_id,
&uninstall_request(&added, "remove"),
)
.unwrap();
assert!(uninstalled.installation.uninstalled);
f.expire();
f.store.plugin_gc(&f.root()).unwrap();
assert_eq!(
f.store.plugin_install("a", "actor", &request).unwrap(),
first
);
assert_eq!(
f.store
.plugin_uninstall(
"a",
"actor",
&first.installation.installation_id,
&uninstall_request(&added, "remove")
)
.unwrap(),
uninstalled
);
assert_eq!(
f.store.plugin_operation("a", &first.operation_id).unwrap(),
first
);
assert_eq!(f.count("plugin_operations"), 3);
assert_eq!(f.count("plugin_artifacts"), 2);
}
#[tokio::test]
async fn version_collision_and_revoke_are_not_overwrite_or_reactivation() {
let f = Fixture::new().await;
let upload = f.upload();
let first = f.install(&upload, "echo");
let installation = &first.installation.installation_id;
let collision = f
.store
.plugin_upload(
&f.root(),
"a",
"actor",
&package("0.1.0", "different bytes"),
)
.unwrap();
error(
f.store.plugin_add_artifact(
"a",
"actor",
installation,
&PluginAddArtifactRequest {
upload_id: collision.upload_id,
expected_last_operation: first.operation_id.clone(),
operation_key: "collide".into(),
},
),
PluginCatalogError::Conflict,
);
let revoked = f
.store
.plugin_revoke(
"a",
"actor",
installation,
&PluginRevokeRequest {
artifact_digest: upload.artifact.artifact_digest.clone(),
expected_last_operation: first.operation_id.clone(),
reason: "withdrawn".into(),
operation_key: "revoke".into(),
},
)
.unwrap();
assert!(revoked.installation.artifacts[0].revoked);
let add = PluginAddArtifactRequest {
upload_id: upload.upload_id.clone(),
expected_last_operation: revoked.operation_id.clone(),
operation_key: "reactivate".into(),
};
error(
f.store
.plugin_add_artifact("a", "actor", installation, &add),
PluginCatalogError::Conflict,
);
error(
f.store.plugin_artifact_bytes(
&f.root(),
"a",
installation,
&upload.artifact.artifact_digest,
),
PluginCatalogError::Conflict,
);
let mut pin = reference(&revoked, PluginReferenceKind::Restorable);
f.store
.with_conn_mut(|c| {
let tx = c.transaction()?;
error(
plugin_reference_put(&tx, "a", &pin),
PluginCatalogError::Conflict,
);
Ok(())
})
.unwrap();
let other = f.install(&upload, "new-installation");
assert_ne!(other.installation.installation_id, *installation);
pin.installation_id = other.installation.installation_id.clone();
pin.expected_last_operation = other.operation_id;
f.store
.with_conn_mut(|c| {
let tx = c.transaction()?;
plugin_reference_put(&tx, "a", &pin)?;
tx.commit()?;
Ok(())
})
.unwrap();
let rejected=f.store.with_conn(|c|{c.execute("UPDATE plugin_artifacts SET revoked=0,revocation_reason=NULL WHERE workspace_id='a' AND installation_id=?1",[installation])?;Ok(())});
assert!(matches!(rejected, Err(Error::Sqlite(_))));
}
#[tokio::test]
async fn references_compose_with_owner_transaction_rollback_and_fence_all_lifetimes() {
let f = Fixture::new().await;
let upload = f.upload();
let first = f.install(&upload, "echo");
let installation = &first.installation.installation_id;
f.store
.with_conn_mut(|c| {
c.execute(
"CREATE TABLE fixture_config_commits (content_digest TEXT PRIMARY KEY)",
[],
)?;
let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute(
"INSERT INTO fixture_config_commits VALUES ('uncommitted')",
[],
)?;
plugin_reference_put(&tx, "a", &reference(&first, PluginReferenceKind::Config))?;
tx.rollback()?;
Ok(())
})
.unwrap();
assert_eq!(f.count("plugin_references"), 0);
assert_eq!(f.count("fixture_config_commits"), 0);
for kind in [
PluginReferenceKind::Config,
PluginReferenceKind::Pending,
PluginReferenceKind::Active,
PluginReferenceKind::Restorable,
] {
f.store
.with_conn_mut(|c| {
let tx = c.transaction_with_behavior(TransactionBehavior::Immediate)?;
plugin_reference_put(&tx, "a", &reference(&first, kind))?;
tx.commit()?;
Ok(())
})
.unwrap();
error(
f.store.plugin_uninstall(
"a",
"actor",
installation,
&uninstall_request(&first, "remove"),
),
PluginCatalogError::Referenced,
);
}
let mut stale = reference(&first, PluginReferenceKind::Config);
stale.expected_last_operation = "stale".into();
f.store
.with_conn_mut(|c| {
let tx = c.transaction()?;
error(
plugin_reference_put(&tx, "a", &stale),
PluginCatalogError::Conflict,
);
stale.expected_last_operation = first.operation_id.clone();
stale.package_digest = "0".repeat(64);
error(
plugin_reference_put(&tx, "a", &stale),
PluginCatalogError::Conflict,
);
error(
plugin_reference_put(&tx, "b", &reference(&first, PluginReferenceKind::Config)),
PluginCatalogError::NotFound,
);
Ok(())
})
.unwrap();
let revoked = f
.store
.plugin_revoke(
"a",
"actor",
installation,
&PluginRevokeRequest {
artifact_digest: upload.artifact.artifact_digest.clone(),
expected_last_operation: first.operation_id.clone(),
operation_key: "revoke".into(),
reason: "withdrawn".into(),
},
)
.unwrap();
assert_eq!(f.count("plugin_references"), 4);
error(
f.store.plugin_uninstall(
"a",
"actor",
installation,
&uninstall_request(&revoked, "remove"),
),
PluginCatalogError::Referenced,
);
f.store
.with_conn_mut(|c| {
let tx = c.transaction()?;
for kind in [
PluginReferenceKind::Config,
PluginReferenceKind::Pending,
PluginReferenceKind::Active,
PluginReferenceKind::Restorable,
] {
plugin_reference_remove(
&tx,
"a",
kind,
"owner/config-or-plan",
installation,
&upload.artifact.artifact_digest,
)?;
}
tx.commit()?;
Ok(())
})
.unwrap();
let removed = f
.store
.plugin_uninstall(
"a",
"actor",
installation,
&uninstall_request(&revoked, "remove"),
)
.unwrap();
f.store
.with_conn_mut(|c| {
let tx = c.transaction()?;
let mut pin = reference(&removed, PluginReferenceKind::Pending);
pin.expected_last_operation = removed.operation_id.clone();
error(
plugin_reference_put(&tx, "a", &pin),
PluginCatalogError::NotFound,
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn database_failure_rolls_back_domain_and_receipt_then_exact_retry_succeeds() {
let f = Fixture::new().await;
let upload = f.upload();
f.store.with_conn(|c|{c.execute_batch("CREATE TRIGGER fail_plugin_receipt BEFORE INSERT ON plugin_operations BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;Ok(())}).unwrap();
let request = PluginInstallRequest {
upload_id: upload.upload_id,
alias: "echo".into(),
operation_key: "install".into(),
};
assert!(matches!(
f.store.plugin_install("a", "actor", &request),
Err(Error::Sqlite(_))
));
assert_eq!(f.count("plugin_installations"), 0);
assert_eq!(f.count("plugin_artifacts"), 0);
assert_eq!(f.count("plugin_operations"), 0);
f.store
.with_conn(|c| {
c.execute_batch("DROP TRIGGER fail_plugin_receipt")?;
Ok(())
})
.unwrap();
let installed = f.store.plugin_install("a", "actor", &request).unwrap();
assert_eq!(
f.store.plugin_install("a", "actor", &request).unwrap(),
installed
);
}
#[tokio::test]
async fn upload_and_publish_failures_leave_no_authority_and_restart_gc_recovers_orphans() {
let f = Fixture::new().await;
for bytes in [&b"not a zip"[..], &f.bytes[..f.bytes.len() - 2]] {
error(
f.store.plugin_upload(&f.root(), "a", "actor", bytes),
PluginCatalogError::Invalid,
);
}
error(
f.store.plugin_upload(
&f.root(),
"a",
"actor",
&vec![0; PluginPackageLimits::default().max_package_size_bytes as usize + 1],
),
PluginCatalogError::TooLarge,
);
assert_eq!(f.count("plugin_uploads"), 0);
fs::write(f.dir.path().join("blocked"), "not a directory").unwrap();
error(
f.store
.plugin_upload(&f.dir.path().join("blocked"), "a", "actor", &f.bytes),
PluginCatalogError::Unavailable,
);
f.store.with_conn(|c|{c.execute_batch("CREATE TRIGGER fail_upload_commit BEFORE INSERT ON plugin_uploads BEGIN SELECT RAISE(ABORT,'injected upload DB failure'); END;")?;Ok(())}).unwrap();
assert!(matches!(
f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes),
Err(Error::Sqlite(_))
));
assert_eq!(f.count("plugin_uploads"), 0);
assert_eq!(fs::read_dir(f.root().join("blobs")).unwrap().count(), 1);
fs::write(f.root().join("blobs/.pending-interrupted"), "partial").unwrap();
let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
assert_eq!(reopened.plugin_gc(&f.root()).unwrap(), 2);
assert_eq!(fs::read_dir(f.root().join("blobs")).unwrap().count(), 0);
f.store
.with_conn(|c| {
c.execute_batch("DROP TRIGGER fail_upload_commit")?;
Ok(())
})
.unwrap();
let upload = f.upload();
let first = f.install(&upload, "echo");
fs::write(
blob_path(&f.root(), &upload.artifact.artifact_digest).unwrap(),
"corrupt",
)
.unwrap();
error(
f.store.plugin_artifact_bytes(
&f.root(),
"a",
&first.installation.installation_id,
&upload.artifact.artifact_digest,
),
PluginCatalogError::Unavailable,
);
error(
f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes),
PluginCatalogError::Unavailable,
);
}
#[tokio::test]
async fn upload_actor_workspace_expiry_and_quotas_are_enforced_without_partial_rows() {
let f = Fixture::new().await;
let upload = f.upload();
let request = PluginInstallRequest {
upload_id: upload.upload_id.clone(),
alias: "echo".into(),
operation_key: "install".into(),
};
error(
f.store.plugin_install("a", "other", &request),
PluginCatalogError::NotFound,
);
error(
f.store.plugin_install("b", "actor", &request),
PluginCatalogError::NotFound,
);
f.expire();
error(
f.store.plugin_install("a", "actor", &request),
PluginCatalogError::NotFound,
);
assert_eq!(f.count("plugin_installations"), 0);
for _ in 0..MAX_ACTOR_UPLOADS {
f.upload();
}
error(
f.store.plugin_upload(&f.root(), "a", "actor", &f.bytes),
PluginCatalogError::Quota,
);
f.store
.plugin_upload(&f.root(), "a", "other", &f.bytes)
.unwrap();
f.store
.plugin_upload(&f.root(), "b", "actor", &f.bytes)
.unwrap();
f.expire();
f.upload();
assert_eq!(f.count("plugin_operations"), 0);
}
#[tokio::test]
async fn global_gc_keeps_other_workspace_blob_and_historical_receipts_after_last_reference() {
let f = Fixture::new().await;
let a_upload = f.upload();
let a = f.install(&a_upload, "echo");
let b_upload = f
.store
.plugin_upload(&f.root(), "b", "actor", &f.bytes)
.unwrap();
let b = f
.store
.plugin_install(
"b",
"actor",
&PluginInstallRequest {
upload_id: b_upload.upload_id,
alias: "echo".into(),
operation_key: "install".into(),
},
)
.unwrap();
f.store
.plugin_uninstall(
"a",
"actor",
&a.installation.installation_id,
&uninstall_request(&a, "remove"),
)
.unwrap();
f.expire();
assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 0);
assert_eq!(
f.store
.plugin_artifact_bytes(
&f.root(),
"b",
&b.installation.installation_id,
&a_upload.artifact.artifact_digest
)
.unwrap(),
f.bytes
);
f.store
.plugin_uninstall(
"b",
"actor",
&b.installation.installation_id,
&uninstall_request(&b, "remove"),
)
.unwrap();
assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 1);
assert_eq!(f.store.plugin_operation("a", &a.operation_id).unwrap(), a);
assert_eq!(f.count("plugin_artifacts"), 2);
}
#[tokio::test]
async fn independent_connections_cannot_uninstall_or_gc_across_a_reference_transaction() {
let f = Fixture::new().await;
let upload = f.upload();
let first = f.install(&upload, "echo");
f.expire();
let second = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
second
.with_conn(|c| {
c.busy_timeout(std::time::Duration::ZERO)?;
Ok(())
})
.unwrap();
f.store.with_conn_mut(|c|{
let tx=c.transaction_with_behavior(TransactionBehavior::Immediate)?;
plugin_reference_put(&tx,"a",&reference(&first,PluginReferenceKind::Pending))?;
let denied=second.plugin_uninstall("a","actor",&first.installation.installation_id,&uninstall_request(&first,"remove"));
assert!(matches!(denied,Err(Error::Sqlite(rusqlite::Error::SqliteFailure(e,_))) if e.code==rusqlite::ErrorCode::DatabaseBusy));
assert!(matches!(second.plugin_gc(&f.root()),Err(Error::Sqlite(rusqlite::Error::SqliteFailure(e,_))) if e.code==rusqlite::ErrorCode::DatabaseBusy));
assert!(blob_path(&f.root(),&upload.artifact.artifact_digest)?.exists());
tx.commit()?;Ok(())
}).unwrap();
error(
second.plugin_uninstall(
"a",
"actor",
&first.installation.installation_id,
&uninstall_request(&first, "remove"),
),
PluginCatalogError::Referenced,
);
assert_eq!(second.plugin_gc(&f.root()).unwrap(), 0);
// Reverse ordering: after removal commits, a stale plan cannot recreate a pin.
f.store
.with_conn_mut(|c| {
let tx = c.transaction()?;
plugin_reference_remove(
&tx,
"a",
PluginReferenceKind::Pending,
"owner/config-or-plan",
&first.installation.installation_id,
&upload.artifact.artifact_digest,
)?;
tx.commit()?;
Ok(())
})
.unwrap();
second
.plugin_uninstall(
"a",
"actor",
&first.installation.installation_id,
&uninstall_request(&first, "remove"),
)
.unwrap();
f.store
.with_conn_mut(|c| {
let tx = c.transaction()?;
error(
plugin_reference_put(&tx, "a", &reference(&first, PluginReferenceKind::Active)),
PluginCatalogError::NotFound,
);
Ok(())
})
.unwrap();
assert_eq!(f.store.plugin_gc(&f.root()).unwrap(), 1);
}
#[tokio::test]
async fn bounded_opaque_list_cursor_is_workspace_bound_and_survives_catalog_changes() {
let f = Fixture::new().await;
let upload = f.upload();
let first = f.install(&upload, "one");
f.install(&upload, "two");
let page = f
.store
.plugin_list(
"a",
&PluginListRequest {
cursor: None,
limit: Some(1),
},
)
.unwrap();
assert_eq!(page.installations.len(), 1);
assert!(page.next_cursor.is_some());
f.store
.plugin_uninstall(
"a",
"actor",
&first.installation.installation_id,
&uninstall_request(&first, "remove"),
)
.unwrap();
let next = f
.store
.plugin_list(
"a",
&PluginListRequest {
cursor: page.next_cursor.clone(),
limit: Some(1),
},
)
.unwrap();
assert_eq!(next.installations.len(), 1);
assert!(next.next_cursor.is_none());
error(
f.store.plugin_list(
"b",
&PluginListRequest {
cursor: page.next_cursor,
limit: Some(1),
},
),
PluginCatalogError::Invalid,
);
error(
f.store.plugin_list(
"a",
&PluginListRequest {
cursor: None,
limit: Some(101),
},
),
PluginCatalogError::Invalid,
);
error(
f.store.plugin_list(
"a",
&PluginListRequest {
cursor: Some("nonsense".into()),
limit: None,
},
),
PluginCatalogError::Invalid,
);
}
#[tokio::test]
async fn destructive_mutations_roll_back_when_their_atomic_receipt_cannot_commit() {
let f = Fixture::new().await;
let upload = f.upload();
let installed = f.install(&upload, "echo");
let installation = &installed.installation.installation_id;
f.store.with_conn(|c| { c.execute_batch("CREATE TRIGGER fail_receipt BEFORE INSERT ON plugin_operations BEGIN SELECT RAISE(ABORT,'receipt storage failure'); END;")?; Ok(()) }).unwrap();
let revoke = PluginRevokeRequest {
artifact_digest: upload.artifact.artifact_digest,
expected_last_operation: installed.operation_id.clone(),
reason: "withdrawn".into(),
operation_key: "revoke".into(),
};
assert!(matches!(
f.store.plugin_revoke("a", "actor", installation, &revoke),
Err(Error::Sqlite(_))
));
assert_eq!(
f.store.plugin_show("a", installation).unwrap(),
installed.installation
);
let uninstall = uninstall_request(&installed, "remove");
assert!(matches!(
f.store
.plugin_uninstall("a", "actor", installation, &uninstall),
Err(Error::Sqlite(_))
));
assert_eq!(
f.store.plugin_show("a", installation).unwrap(),
installed.installation
);
assert_eq!(f.count("plugin_operations"), 1);
f.store
.with_conn(|c| {
c.execute_batch("DROP TRIGGER fail_receipt")?;
Ok(())
})
.unwrap();
let revoked = f
.store
.plugin_revoke("a", "actor", installation, &revoke)
.unwrap();
assert_eq!(
f.store
.plugin_revoke("a", "actor", installation, &revoke)
.unwrap(),
revoked
);
error(
f.store
.plugin_uninstall("a", "actor", installation, &uninstall),
PluginCatalogError::Conflict,
);
let mut changed = revoke.clone();
changed.reason = "changed reason".into();
error(
f.store.plugin_revoke("a", "actor", installation, &changed),
PluginCatalogError::Conflict,
);
}
#[tokio::test]
async fn upload_workspace_quota_counts_all_actors_and_installation_quota_is_atomic() {
let f = Fixture::new().await;
let upload = f.upload();
// Clone real immutable upload references to the same published bytes to put
// accounting precisely at its global boundary without allocating duplicate blobs.
f.store.with_conn(|c| {
for n in 1..MAX_UPLOADS {
c.execute("INSERT INTO plugin_uploads SELECT workspace_id,?1,?2,expires_at,artifact_digest,artifact_size,metadata_json FROM plugin_uploads WHERE upload_id=?3",params![format!("upload-{n}"),format!("actor-{n}"),upload.upload_id])?;
}
Ok(())
}).unwrap();
error(
f.store
.plugin_upload(&f.root(), "a", "fresh-actor", &f.bytes),
PluginCatalogError::Quota,
);
assert_eq!(f.count("plugin_uploads"), MAX_UPLOADS);
for n in 0..MAX_INSTALLATIONS {
f.install(&upload, &format!("install-{n}"));
}
let request = PluginInstallRequest {
upload_id: upload.upload_id,
alias: "excess".into(),
operation_key: "excess".into(),
};
error(
f.store.plugin_install("a", "actor", &request),
PluginCatalogError::Quota,
);
assert_eq!(f.count("plugin_installations"), MAX_INSTALLATIONS);
assert_eq!(f.count("plugin_operations"), MAX_INSTALLATIONS);
}
@@ -0,0 +1,65 @@
CREATE TABLE plugin_installations (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
installation_id TEXT NOT NULL,
alias TEXT NOT NULL CHECK(length(alias) BETWEEN 1 AND 128),
package_id TEXT NOT NULL,
last_operation TEXT NOT NULL,
uninstalled INTEGER NOT NULL DEFAULT 0 CHECK(uninstalled IN (0,1)),
PRIMARY KEY(workspace_id, installation_id)
);
CREATE UNIQUE INDEX plugin_live_alias ON plugin_installations(workspace_id, alias) WHERE uninstalled=0;
CREATE TABLE plugin_artifacts (
workspace_id TEXT NOT NULL,
installation_id TEXT NOT NULL,
artifact_digest TEXT NOT NULL CHECK(length(artifact_digest)=71 AND substr(artifact_digest,1,7)='sha256:' AND substr(artifact_digest,8) NOT GLOB '*[^0-9a-f]*'),
package_digest TEXT NOT NULL CHECK(length(package_digest)=71 AND substr(package_digest,1,7)='sha256:' AND substr(package_digest,8) NOT GLOB '*[^0-9a-f]*'),
version TEXT NOT NULL,
artifact_size INTEGER NOT NULL CHECK(artifact_size > 0),
metadata_json TEXT NOT NULL CHECK(json_valid(metadata_json)),
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0,1)),
revocation_reason TEXT,
CHECK((revoked=0 AND revocation_reason IS NULL) OR (revoked=1 AND revocation_reason IS NOT NULL)),
PRIMARY KEY(workspace_id, installation_id, artifact_digest),
UNIQUE(workspace_id, installation_id, version),
FOREIGN KEY(workspace_id, installation_id) REFERENCES plugin_installations(workspace_id, installation_id) ON DELETE CASCADE
);
CREATE TABLE plugin_uploads (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
upload_id TEXT NOT NULL,
actor TEXT NOT NULL,
expires_at INTEGER NOT NULL,
artifact_digest TEXT NOT NULL CHECK(length(artifact_digest)=71 AND substr(artifact_digest,1,7)='sha256:' AND substr(artifact_digest,8) NOT GLOB '*[^0-9a-f]*'),
artifact_size INTEGER NOT NULL CHECK(artifact_size > 0),
metadata_json TEXT NOT NULL CHECK(json_valid(metadata_json)),
PRIMARY KEY(workspace_id, upload_id)
);
CREATE INDEX plugin_upload_expiry ON plugin_uploads(expires_at);
CREATE TABLE plugin_operations (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
operation_id TEXT NOT NULL,
actor TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('install','add_artifact','revoke','uninstall')),
operation_key TEXT NOT NULL CHECK(length(operation_key) BETWEEN 1 AND 128),
fingerprint TEXT NOT NULL CHECK(length(fingerprint)=64),
result_json TEXT NOT NULL CHECK(json_valid(result_json)),
PRIMARY KEY(workspace_id, operation_id),
UNIQUE(workspace_id, actor, kind, operation_key)
);
CREATE TABLE plugin_references (
workspace_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('config','pending','active','restorable')),
owner TEXT NOT NULL CHECK(length(owner) BETWEEN 1 AND 512),
installation_id TEXT NOT NULL,
artifact_digest TEXT NOT NULL,
PRIMARY KEY(workspace_id, kind, owner, installation_id, artifact_digest),
FOREIGN KEY(workspace_id, installation_id, artifact_digest) REFERENCES plugin_artifacts(workspace_id, installation_id, artifact_digest) ON DELETE RESTRICT
);
CREATE TRIGGER plugin_revocation_irreversible BEFORE UPDATE OF revoked ON plugin_artifacts
WHEN OLD.revoked=1 AND NEW.revoked<>1 BEGIN SELECT RAISE(ABORT,'plugin revocation is irreversible'); END;
CREATE TRIGGER plugin_reference_live BEFORE INSERT ON plugin_references
WHEN NOT EXISTS(SELECT 1 FROM plugin_artifacts a JOIN plugin_installations i USING(workspace_id, installation_id)
WHERE a.workspace_id=NEW.workspace_id AND a.installation_id=NEW.installation_id AND a.artifact_digest=NEW.artifact_digest AND a.revoked=0 AND i.uninstalled=0)
BEGIN SELECT RAISE(ABORT,'plugin reference target unavailable'); END;
CREATE TRIGGER plugin_uninstall_referenced BEFORE UPDATE OF uninstalled ON plugin_installations
WHEN NEW.uninstalled=1 AND EXISTS(SELECT 1 FROM plugin_references r WHERE r.workspace_id=NEW.workspace_id AND r.installation_id=NEW.installation_id)
BEGIN SELECT RAISE(ABORT,'plugin installation referenced'); END;
@@ -100,6 +100,10 @@ mod schema_convergence {
version: 105,
name: "Durable Subject deletion snapshots and Job admission fences".into(),
},
WorkspaceSchemaMigrationStep {
version: 106,
name: crate::plugin_catalog::MIGRATION_NAME.into(),
},
]
);
assert_eq!(
@@ -116,6 +120,7 @@ mod schema_convergence {
let mut expected = history.clone();
expected.push((104, "Converge Passkey results and Job input retention".into()));
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected);
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
@@ -145,7 +150,7 @@ mod schema_convergence {
assert_eq!(feature_rows(conn, job_variant), rows);
assert_eq!(
workspace_schema_migration_history(conn)?.len(),
history.len() + 2
history.len() + plan.migrations.len()
);
Ok(())
})
@@ -183,7 +188,7 @@ mod schema_convergence {
upgraded
.with_conn(|conn| {
assert_eq!(feature_rows(conn, job_variant), rows);
assert_eq!(current_schema_version(conn)?, 105);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
Ok(())
})
.unwrap();
+104 -4
View File
@@ -46,6 +46,8 @@ use worker_protocol::*;
mod drive;
#[path = "server_drive_grants.rs"]
mod drive_grants;
#[path = "server_plugin.rs"]
mod plugin;
#[path = "server_workspace_config.rs"]
mod workspace_config;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -3418,11 +3420,12 @@ async fn authorize_scoped_workspace_request(
.map_err(|error| {
runtime_request_proof_rejection(&method, &request_path, workspace_id, &error)
})?;
if is_workspace_config_editor_path(&request_path) {
if is_workspace_config_editor_path(&request_path) || plugin::is_catalog_path(&request_path)
{
return Err(repository_api_rejection(
&request_path,
StatusCode::FORBIDDEN,
"Runtime configuration editor access denied",
"Runtime user-management access denied",
));
}
request.extensions_mut().insert(source);
@@ -3576,11 +3579,12 @@ async fn authorize_workspace_api_request(
);
}
};
if is_workspace_config_editor_path(&request_path) {
if is_workspace_config_editor_path(&request_path) || plugin::is_catalog_path(&request_path)
{
return repository_api_rejection(
&request_path,
StatusCode::FORBIDDEN,
"Runtime configuration editor access denied",
"Runtime user-management access denied",
);
}
request.extensions_mut().insert(source);
@@ -4358,6 +4362,8 @@ impl WorkspaceApi {
runtime_subscription_broker
.register_embedded_runtime(embedded_runtime_id, embedded_subscription_runtime);
let database_path = config.database_path.clone();
let plugin_root =
ServerConfig::server_data_root_for_data_dir(&config.data_root).join("plugin-artifacts");
let executor = store.db_executor();
let config_executor = executor.clone();
let (config_store, repository_secrets) = executor
@@ -4367,6 +4373,7 @@ impl WorkspaceApi {
.open_store(&database_path)?
.with_db_executor(config_executor),
);
config_store.plugin_gc(&plugin_root)?;
let repository_secrets = Arc::new(RepositorySecretService::open(
config_store.clone(),
&database_path,
@@ -7603,6 +7610,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::current_worker_workdir_catalog(
service.clone(),
))
.merge(plugin::router(service.clone()))
.merge(server_api::server_api_axum::current_worker_workspace_config_get(service.clone()))
.merge(server_api::server_api_axum::current_worker_workspace_config_attach(service.clone()))
.merge(
@@ -13318,6 +13326,94 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn plugin_upload(
&self,
actor: RequestActor,
workspace_id: String,
body: server_api::BinaryBody,
) -> std::result::Result<server_api::PluginUploadResponse, server_api::RepositoryApiError> {
let api = self.workspace_api()?;
plugin::upload(api, actor, workspace_id, body).await
}
async fn plugin_install(
&self,
actor: RequestActor,
workspace_id: String,
request: server_api::PluginInstallRequest,
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?;
plugin::install(api, actor, workspace_id, request).await
}
async fn plugin_list(
&self,
actor: RequestActor,
workspace_id: String,
query: server_api::PluginListRequest,
) -> std::result::Result<server_api::PluginListResponse, server_api::RepositoryApiError> {
let api = self.workspace_api()?;
plugin::list(api, actor, workspace_id, query).await
}
async fn plugin_show(
&self,
actor: RequestActor,
workspace_id: String,
installation_id: String,
) -> std::result::Result<server_api::PluginInstallation, server_api::RepositoryApiError> {
let api = self.workspace_api()?;
plugin::show(api, actor, workspace_id, installation_id).await
}
async fn plugin_artifact_add(
&self,
actor: RequestActor,
workspace_id: String,
installation_id: String,
request: server_api::PluginAddArtifactRequest,
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?;
plugin::artifact_add(api, actor, workspace_id, installation_id, request).await
}
async fn plugin_revoke(
&self,
actor: RequestActor,
workspace_id: String,
installation_id: String,
request: server_api::PluginRevokeRequest,
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?;
plugin::revoke(api, actor, workspace_id, installation_id, request).await
}
async fn plugin_uninstall(
&self,
actor: RequestActor,
workspace_id: String,
installation_id: String,
request: server_api::PluginUninstallRequest,
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?;
plugin::uninstall(api, actor, workspace_id, installation_id, request).await
}
async fn plugin_operation(
&self,
actor: RequestActor,
workspace_id: String,
operation_id: String,
) -> std::result::Result<server_api::PluginOperationResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?;
plugin::operation(api, actor, workspace_id, operation_id).await
}
async fn drive_root(
&self,
context: server_api::ServerRequestContext,
@@ -38064,6 +38160,10 @@ mod tests {
include!("server_ticket_case_signed_tests.rs");
}
mod plugin_tests {
include!("server_plugin_tests.rs");
}
mod ticket_tags_tests {
include!("server_ticket_tags_tests.rs");
}
@@ -0,0 +1,226 @@
//! Human catalog management is separate from Runtime artifact delivery and execution grants.
//! The outer dispatcher authenticates each request. Mutations additionally require the
//! Workspace owner account; authenticated readers follow the existing Workspace read policy.
//! Neither model text nor a Runtime workspace.request proof can manufacture a human actor.
use super::*;
use server_api::*;
type CatalogResult<T> = std::result::Result<T, RepositoryApiError>;
pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
Router::new()
.merge(
server_api_axum::plugin_upload(service.clone()).layer(DefaultBodyLimit::max(
manifest::plugin::PluginPackageLimits::default().max_package_size_bytes as usize,
)),
)
.merge(server_api_axum::plugin_install(service.clone()))
.merge(server_api_axum::plugin_list(service.clone()))
.merge(server_api_axum::plugin_show(service.clone()))
.merge(server_api_axum::plugin_artifact_add(service.clone()))
.merge(server_api_axum::plugin_revoke(service.clone()))
.merge(server_api_axum::plugin_uninstall(service.clone()))
.merge(server_api_axum::plugin_operation(service))
.layer(middleware::from_fn(normalize_rejections))
}
async fn normalize_rejections(request: Request, next: Next) -> Response {
let response = next.run(request).await;
// The management contract classifies malformed DTOs/queries as invalid=400,
// including Axum's JSON data/media rejections. Preserve the common envelope
// without reflecting attacker-supplied JSON values or transport diagnostics.
if matches!(
response.status(),
StatusCode::BAD_REQUEST
| StatusCode::UNPROCESSABLE_ENTITY
| StatusCode::UNSUPPORTED_MEDIA_TYPE
) {
(
StatusCode::BAD_REQUEST,
Json(RepositoryApiError::new(
400,
"plugin_invalid",
"Invalid Plugin catalog request",
vec![],
)),
)
.into_response()
} else {
response
}
}
pub(super) fn is_catalog_path(path: &str) -> bool {
path.strip_prefix("/api/w/")
.and_then(|p| p.split_once('/'))
.is_some_and(|(_, resource)| {
matches!(resource, "plugins" | "plugin-uploads")
|| resource.starts_with("plugins/")
|| resource.starts_with("plugin-operations/")
})
}
fn failure(error: Error) -> RepositoryApiError {
let (status, code) = match error {
Error::PluginCatalog(error) => (error.status(), error.code()),
Error::WorkspacePermissionDenied(_) => (403, "access_denied"),
Error::WorkspaceIdMismatch => (404, "not_found"),
Error::DbCapacityExceeded => (503, "busy"),
// A storage failure may be a lost acknowledgement. Do not assert rollback
// or encourage a fresh operation key; the caller can reconcile its receipt.
_ => (503, "catalog_outcome_unknown"),
};
RepositoryApiError::new(status, code, format!("Plugin catalog: {code}"), vec![])
}
async fn authorize(
api: &WorkspaceApi,
workspace: &str,
actor: &RequestActor,
manage: bool,
) -> CatalogResult<()> {
validate_workspace_scope(api, workspace).map_err(|e| failure(e.error))?;
if manage {
require_workspace_owner(api, workspace, actor, "Plugin management")
.await
.map_err(|e| failure(e.error))?;
}
Ok(())
}
fn actor_identity(actor: &RequestActor) -> String {
// Account alone is not a caller: two users of the same management account
// must not consume one another's temporary uploads or operation keys.
serde_json::to_string(&(&actor.account_id, &actor.user_id))
.expect("string tuple is serializable")
}
pub(super) async fn upload(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
body: BinaryBody,
) -> CatalogResult<PluginUploadResponse> {
authorize(api, &workspace, &actor, true).await?;
let root =
ServerConfig::server_data_root_for_data_dir(&api.config.data_root).join("plugin-artifacts");
let store = api.config_store.clone();
let actor = actor_identity(&actor);
let executor = store.db_executor();
executor
.run(move || store.plugin_upload(&root, &workspace, &actor, body.as_ref()))
.await
.map_err(failure)
}
pub(super) async fn install(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
request: PluginInstallRequest,
) -> CatalogResult<PluginOperationResponse> {
authorize(api, &workspace, &actor, true).await?;
let store = api.config_store.clone();
let actor = actor_identity(&actor);
let executor = store.db_executor();
executor
.run(move || store.plugin_install(&workspace, &actor, &request))
.await
.map_err(failure)
}
pub(super) async fn list(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
query: PluginListRequest,
) -> CatalogResult<PluginListResponse> {
authorize(api, &workspace, &actor, false).await?;
let store = api.config_store.clone();
let executor = store.db_executor();
executor
.run(move || store.plugin_list(&workspace, &query))
.await
.map_err(failure)
}
pub(super) async fn show(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
installation_id: String,
) -> CatalogResult<PluginInstallation> {
authorize(api, &workspace, &actor, false).await?;
let store = api.config_store.clone();
let executor = store.db_executor();
executor
.run(move || store.plugin_show(&workspace, &installation_id))
.await
.map_err(failure)
}
pub(super) async fn artifact_add(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
installation_id: String,
request: PluginAddArtifactRequest,
) -> CatalogResult<PluginOperationResponse> {
authorize(api, &workspace, &actor, true).await?;
let store = api.config_store.clone();
let actor = actor_identity(&actor);
let executor = store.db_executor();
executor
.run(move || store.plugin_add_artifact(&workspace, &actor, &installation_id, &request))
.await
.map_err(failure)
}
pub(super) async fn revoke(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
installation_id: String,
request: PluginRevokeRequest,
) -> CatalogResult<PluginOperationResponse> {
authorize(api, &workspace, &actor, true).await?;
let store = api.config_store.clone();
let actor = actor_identity(&actor);
let executor = store.db_executor();
executor
.run(move || store.plugin_revoke(&workspace, &actor, &installation_id, &request))
.await
.map_err(failure)
}
pub(super) async fn uninstall(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
installation_id: String,
request: PluginUninstallRequest,
) -> CatalogResult<PluginOperationResponse> {
authorize(api, &workspace, &actor, true).await?;
let store = api.config_store.clone();
let actor = actor_identity(&actor);
let executor = store.db_executor();
executor
.run(move || store.plugin_uninstall(&workspace, &actor, &installation_id, &request))
.await
.map_err(failure)
}
pub(super) async fn operation(
api: &WorkspaceApi,
actor: RequestActor,
workspace: String,
operation_id: String,
) -> CatalogResult<PluginOperationResponse> {
authorize(api, &workspace, &actor, false).await?;
let store = api.config_store.clone();
let executor = store.db_executor();
executor
.run(move || store.plugin_operation(&workspace, &operation_id))
.await
.map_err(failure)
}
@@ -0,0 +1,414 @@
use super::*;
// Generated routes -> real token resolver and Workspace admission -> real catalog
// SQLite/blob storage. The unrelated Worker executor is the existing test backend.
fn package_bytes(version: &str) -> Vec<u8> {
use manifest::plugin::*;
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("plugin.toml"),
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml")
.replace("0.1.0", version),
)
.unwrap();
fs::write(
dir.path().join("plugin.component.wasm"),
include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
)
.unwrap();
let limits = PluginPackageLimits::default();
pack_plugin_package(
&read_plugin_directory(dir.path(), PluginSourceKind::Explicit, &limits).unwrap(),
&limits,
)
.unwrap()
.bytes
}
async fn request(
app: Router,
token: Option<&str>,
method: &str,
path: &str,
body: Vec<u8>,
binary: bool,
expected: StatusCode,
) -> Value {
let mut req = Request::builder().method(method).uri(path).header(
CONTENT_TYPE,
if binary {
"application/octet-stream"
} else {
"application/json"
},
);
if let Some(token) = token {
req = req.header("authorization", format!("Bearer {token}"));
}
let response = app
.oneshot(req.body(Body::from(body)).unwrap())
.await
.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), 32 * 1024 * 1024)
.await
.unwrap();
assert_eq!(
status,
expected,
"{method} {path}: {}",
String::from_utf8_lossy(&bytes)
);
serde_json::from_slice(&bytes).unwrap()
}
async fn json(app: Router, token: &str, path: &str, body: Value, expected: StatusCode) -> Value {
request(
app,
Some(token),
"POST",
path,
serde_json::to_vec(&body).unwrap(),
false,
expected,
)
.await
}
async fn get(app: Router, token: Option<&str>, path: &str, expected: StatusCode) -> Value {
request(app, token, "GET", path, vec![], false, expected).await
}
#[tokio::test]
async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-owner").await;
let reader = seed_test_api_token(api.store.as_ref(), "plugin-reader");
let app = build_router(api.clone());
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
let catalog = format!("{prefix}/plugins");
let upload_path = format!("{prefix}/plugin-uploads");
get(app.clone(), None, &catalog, StatusCode::UNAUTHORIZED).await;
get(app.clone(), Some(&reader), &catalog, StatusCode::OK).await;
request(
app.clone(),
Some(&reader),
"POST",
&upload_path,
package_bytes("0.1.0"),
true,
StatusCode::FORBIDDEN,
)
.await;
let upload = request(
app.clone(),
Some(&owner),
"POST",
&upload_path,
package_bytes("0.1.0"),
true,
StatusCode::OK,
)
.await;
assert_ne!(
upload["artifact"]["package_digest"],
upload["artifact"]["artifact_digest"]
);
let install = serde_json::json!({"upload_id":upload["upload_id"],"alias":"primary","operation_key":"install"});
let original = json(
app.clone(),
&owner,
&catalog,
install.clone(),
StatusCode::OK,
)
.await;
assert_eq!(
json(
app.clone(),
&owner,
&catalog,
install.clone(),
StatusCode::OK
)
.await,
original
);
let mut changed = install.clone();
changed["alias"] = "other".into();
json(app.clone(), &owner, &catalog, changed, StatusCode::CONFLICT).await;
let id = original["installation"]["installation_id"]
.as_str()
.unwrap();
let detail_path = format!("{catalog}/{id}");
let observed = get(app.clone(), Some(&reader), &detail_path, StatusCode::OK).await;
assert_eq!(observed["last_operation"], original["operation_id"]);
get(
app.clone(),
Some(&reader),
&format!(
"{prefix}/plugin-operations/{}",
original["operation_id"].as_str().unwrap()
),
StatusCode::OK,
)
.await;
get(
app.clone(),
Some(&owner),
&format!("/api/w/another/plugins/{id}"),
StatusCode::NOT_FOUND,
)
.await;
get(
app.clone(),
Some(&owner),
&format!(
"{prefix}/plugins/{}",
upload["artifact"]["artifact_digest"].as_str().unwrap()
),
StatusCode::NOT_FOUND,
)
.await;
let next_upload = request(
app.clone(),
Some(&owner),
"POST",
&upload_path,
package_bytes("0.2.0"),
true,
StatusCode::OK,
)
.await;
let add = json(app.clone(), &owner, &format!("{detail_path}/artifacts"), serde_json::json!({"upload_id":next_upload["upload_id"],"expected_last_operation":observed["last_operation"],"operation_key":"add"}), StatusCode::OK).await;
json(app.clone(), &owner, &format!("{detail_path}/uninstall"), serde_json::json!({"expected_last_operation":observed["last_operation"],"operation_key":"stale-delete"}), StatusCode::CONFLICT).await;
let revoke = json(app.clone(), &owner, &format!("{detail_path}/revoke"), serde_json::json!({"expected_last_operation":add["operation_id"],"operation_key":"revoke","artifact_digest":upload["artifact"]["artifact_digest"],"reason":"withdrawn"}), StatusCode::OK).await;
assert!(
revoke["installation"]["artifacts"]
.as_array()
.unwrap()
.iter()
.any(|a| a["revoked"] == true)
);
let removed = json(app.clone(), &owner, &format!("{detail_path}/uninstall"), serde_json::json!({"expected_last_operation":revoke["operation_id"],"operation_key":"remove"}), StatusCode::OK).await;
assert_eq!(removed["installation"]["uninstalled"], true);
// Historical receipts remain historical after catalog movement.
assert_eq!(
json(
app.clone(),
&owner,
&catalog,
install.clone(),
StatusCode::OK
)
.await,
original
);
api.config_store
.with_conn(|conn| {
conn.execute(
"UPDATE api_tokens SET revoked_at='2026-01-01' WHERE token_hash=?1",
[crate::auth::token_hash(&owner)],
)?;
Ok(())
})
.unwrap();
json(
app.clone(),
&owner,
&catalog,
install,
StatusCode::UNAUTHORIZED,
)
.await;
shutdown_workspace_feature_storage(&api).await.unwrap();
}
#[tokio::test]
async fn catalog_rejects_invalid_uploads_self_reported_roles_and_runtime_proofs() {
let dir = tempfile::tempdir().unwrap();
let mut api = test_api(dir.path()).await;
let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-input").await;
let identity = RuntimeIdentityMaterial::generate("runtime-plugin-input").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-plugin-input");
let app = build_router(api.clone());
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
request(
app.clone(),
Some(&token),
"POST",
&format!("{prefix}/plugin-uploads"),
b"partial zip".to_vec(),
true,
StatusCode::BAD_REQUEST,
)
.await;
let max = manifest::plugin::PluginPackageLimits::default().max_package_size_bytes as usize;
request(
app.clone(),
Some(&token),
"POST",
&format!("{prefix}/plugin-uploads"),
vec![0; max + 1],
true,
StatusCode::PAYLOAD_TOO_LARGE,
)
.await;
json(app.clone(), &token, &format!("{prefix}/plugins"), serde_json::json!({"upload_id":"missing","alias":"bad","operation_key":"bad","role":"owner"}), StatusCode::BAD_REQUEST).await;
let response = app
.clone()
.oneshot(runtime_source_request(
&identity,
None,
"GET",
&format!("{prefix}/plugins"),
vec![],
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
get(
app,
Some(&token),
&format!("{prefix}/plugins"),
StatusCode::OK
)
.await["installations"],
serde_json::json!([])
);
shutdown_workspace_feature_storage(&api).await.unwrap();
}
#[tokio::test]
async fn generated_client_and_interrupted_binary_transport_use_real_catalog_authority() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-transport").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let app = build_router(api.clone());
let task = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let mut stream = tokio::net::TcpStream::connect(address).await.unwrap();
let header = format!(
"POST /api/w/{TEST_WORKSPACE_ID}/plugin-uploads HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/octet-stream\r\nContent-Length: 2000\r\nConnection: close\r\n\r\npartial"
);
stream.write_all(header.as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
let mut response = Vec::new();
tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.read_to_end(&mut response),
)
.await
.unwrap()
.unwrap();
assert_eq!(
api.config_store
.with_conn(|conn| Ok(conn.query_row(
"SELECT count(*) FROM plugin_uploads",
[],
|r| r.get::<_, u64>(0)
)?))
.unwrap(),
0
);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {token}").parse().unwrap(),
);
let client = server_api::ServerApiClient::builder(format!("http://{address}"))
.unwrap()
.client(
reqwest::Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap(),
)
.build()
.unwrap();
let upload = client
.plugin_upload(TEST_WORKSPACE_ID.into(), package_bytes("0.1.0").into())
.await
.unwrap();
let installed = client
.plugin_install(
TEST_WORKSPACE_ID.into(),
server_api::PluginInstallRequest {
upload_id: upload.upload_id,
alias: "transport".into(),
operation_key: "transport-install".into(),
},
)
.await
.unwrap();
let observed = client
.plugin_show(
TEST_WORKSPACE_ID.into(),
installed.installation.installation_id,
)
.await
.unwrap();
assert_eq!(observed.last_operation, installed.operation_id);
assert_eq!(observed.artifacts[0], upload.artifact);
task.abort();
let _ = task.await;
shutdown_workspace_feature_storage(&api).await.unwrap();
}
#[tokio::test]
async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let token = seed_test_workspace_owner_token(api.store.as_ref(), "plugin-restart").await;
let app = build_router(api.clone());
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
let upload = request(
app.clone(),
Some(&token),
"POST",
&format!("{prefix}/plugin-uploads"),
package_bytes("0.1.0"),
true,
StatusCode::OK,
)
.await;
let installed = json(app.clone(),&token,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"restart","operation_key":"restart-install"}),StatusCode::OK).await;
let root = ServerConfig::server_data_root_for_data_dir(&api.config.data_root)
.join("plugin-artifacts/blobs");
let orphan = root.join("f".repeat(64));
let partial = root.join(".pending-interrupted-restart");
fs::write(&orphan, b"published without committed reference").unwrap();
fs::write(&partial, b"partial").unwrap();
drop(app);
shutdown_workspace_feature_storage(&api).await.unwrap();
drop(api);
let reopened = test_api(dir.path()).await;
assert!(!orphan.exists());
assert!(!partial.exists());
let observed = reopened
.config_store
.plugin_show(
TEST_WORKSPACE_ID,
installed["installation"]["installation_id"]
.as_str()
.unwrap(),
)
.unwrap();
assert_eq!(observed.last_operation, installed["operation_id"]);
let bytes = reopened
.config_store
.plugin_artifact_bytes(
root.parent().unwrap(),
TEST_WORKSPACE_ID,
&observed.installation_id,
&observed.artifacts[0].artifact_digest,
)
.unwrap();
assert_eq!(bytes, package_bytes("0.1.0"));
shutdown_workspace_feature_storage(&reopened).await.unwrap();
}
+164 -4
View File
@@ -127,7 +127,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 105;
const LATEST_SCHEMA_VERSION: i64 = 106;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -460,6 +460,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Durable Subject deletion snapshots and Job admission fences",
apply: subject_deletion_store::migrate,
},
Migration {
version: 106,
name: crate::plugin_catalog::MIGRATION_NAME,
apply: crate::plugin_catalog::migrate,
},
];
// Both schema-103 identities shipped. Keep the alternate implementation and accept
@@ -1334,6 +1339,64 @@ pub struct HostingAdminInitialBearer {
#[async_trait]
pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
fn plugin_upload(
&self,
root: &Path,
workspace: &str,
actor: &str,
bytes: &[u8],
) -> Result<server_api::PluginUploadResponse>;
fn plugin_install(
&self,
workspace: &str,
actor: &str,
request: &server_api::PluginInstallRequest,
) -> Result<server_api::PluginOperationResponse>;
fn plugin_add_artifact(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &server_api::PluginAddArtifactRequest,
) -> Result<server_api::PluginOperationResponse>;
fn plugin_revoke(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &server_api::PluginRevokeRequest,
) -> Result<server_api::PluginOperationResponse>;
fn plugin_uninstall(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &server_api::PluginUninstallRequest,
) -> Result<server_api::PluginOperationResponse>;
fn plugin_show(
&self,
workspace: &str,
installation: &str,
) -> Result<server_api::PluginInstallation>;
fn plugin_list(
&self,
workspace: &str,
request: &server_api::PluginListRequest,
) -> Result<server_api::PluginListResponse>;
fn plugin_operation(
&self,
workspace: &str,
operation: &str,
) -> Result<server_api::PluginOperationResponse>;
fn plugin_artifact_bytes(
&self,
root: &Path,
workspace: &str,
installation: &str,
artifact_digest: &str,
) -> Result<Vec<u8>>;
fn plugin_gc(&self, root: &Path) -> Result<u64>;
/// Bounded owner for whole synchronous operations from async callers. Move owned inputs
/// and services into one closure covering connection/mutex waits, source authorization,
/// receipts, CAS, audit, commit and ordered publication. Do not split checks around awaits
@@ -4670,6 +4733,90 @@ fn insert_workspace_runtime_binding_audit(
#[async_trait]
impl ControlPlaneStore for SqliteWorkspaceStore {
fn plugin_upload(
&self,
root: &Path,
workspace: &str,
actor: &str,
bytes: &[u8],
) -> Result<server_api::PluginUploadResponse> {
SqliteWorkspaceStore::plugin_upload(self, root, workspace, actor, bytes)
}
fn plugin_install(
&self,
workspace: &str,
actor: &str,
request: &server_api::PluginInstallRequest,
) -> Result<server_api::PluginOperationResponse> {
SqliteWorkspaceStore::plugin_install(self, workspace, actor, request)
}
fn plugin_add_artifact(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &server_api::PluginAddArtifactRequest,
) -> Result<server_api::PluginOperationResponse> {
SqliteWorkspaceStore::plugin_add_artifact(self, workspace, actor, installation, request)
}
fn plugin_revoke(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &server_api::PluginRevokeRequest,
) -> Result<server_api::PluginOperationResponse> {
SqliteWorkspaceStore::plugin_revoke(self, workspace, actor, installation, request)
}
fn plugin_uninstall(
&self,
workspace: &str,
actor: &str,
installation: &str,
request: &server_api::PluginUninstallRequest,
) -> Result<server_api::PluginOperationResponse> {
SqliteWorkspaceStore::plugin_uninstall(self, workspace, actor, installation, request)
}
fn plugin_show(
&self,
workspace: &str,
installation: &str,
) -> Result<server_api::PluginInstallation> {
SqliteWorkspaceStore::plugin_show(self, workspace, installation)
}
fn plugin_list(
&self,
workspace: &str,
request: &server_api::PluginListRequest,
) -> Result<server_api::PluginListResponse> {
SqliteWorkspaceStore::plugin_list(self, workspace, request)
}
fn plugin_operation(
&self,
workspace: &str,
operation: &str,
) -> Result<server_api::PluginOperationResponse> {
SqliteWorkspaceStore::plugin_operation(self, workspace, operation)
}
fn plugin_artifact_bytes(
&self,
root: &Path,
workspace: &str,
installation: &str,
artifact_digest: &str,
) -> Result<Vec<u8>> {
SqliteWorkspaceStore::plugin_artifact_bytes(
self,
root,
workspace,
installation,
artifact_digest,
)
}
fn plugin_gc(&self, root: &Path) -> Result<u64> {
SqliteWorkspaceStore::plugin_gc(self, root)
}
fn db_executor(&self) -> crate::db_executor::DbExecutor {
self.db_executor.clone()
}
@@ -15639,6 +15786,10 @@ fn create_workspace_schema_structure_from_sql(
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(
&schema_sql
.replace(
"-- Plugin catalog schema is composed by store.rs.",
include_str!("plugin_catalog_schema.sql"),
)
.replace(
"-- Ticket-owned assignment schema is composed by store.rs.",
ticket::TICKET_ASSIGNMENT_SCHEMA_SQL,
@@ -16648,6 +16799,7 @@ fn apply_migrations_with_secret_source(
}
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
crate::plugin_catalog::verify(conn)?;
verify_workspace_runtime_binding_schema(conn)?;
verify_workspace_runtime_verification_schema(conn)?;
verify_workspace_deletion_schema(conn)?;
@@ -19092,7 +19244,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
fixture.commit().unwrap();
}
fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) {
pub(super) fn prepare_schema_v50(path: &Path, workspace_id: Option<&str>) {
prepare_schema_v52(path);
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
@@ -19341,6 +19493,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".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:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
]
);
@@ -19456,6 +19609,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(103, "Passkey metadata and atomic addition results".to_string()),
(104, "Converge Passkey results and Job input retention".to_string()),
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -22742,7 +22896,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into()),
(104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into())]);
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
@@ -26936,6 +27091,7 @@ INSERT INTO worker_registry (
.unwrap();
store.with_conn(|conn| {
// 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;")?;
// 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%'")?
.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -26947,7 +27103,7 @@ INSERT INTO worker_registry (
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=105;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=106;
CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(())
}).unwrap();
drop(store);
@@ -28259,3 +28415,7 @@ mod repository_key_migration_tests;
#[cfg(test)]
#[path = "frozen_runtime_binding_tests.rs"]
mod frozen_runtime_binding_tests;
#[cfg(test)]
#[path = "plugin_catalog/migration_tests.rs"]
mod plugin_catalog_migration_tests;
@@ -16,6 +16,11 @@ use crate::{Error, Result};
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
/// excluded so retries and audit remain available after the Workspace row is gone.
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"plugin_references",
"plugin_operations",
"plugin_uploads",
"plugin_artifacts",
"plugin_installations",
"artifacts",
"audit_events",
"backend_job_deliveries",
@@ -1283,6 +1288,84 @@ mod tests {
}
}
#[test]
fn workspace_purge_removes_plugin_references_and_catalog_without_cross_workspace_loss() {
let (store, owner, workspace_id) = setup();
for workspace in ["workspace-a", "workspace-b"] {
store.with_conn(|conn| {
let digest = format!("sha256:{}", "a".repeat(64));
conn.execute("INSERT INTO plugin_installations VALUES(?1,'installation','alias','package','operation',0)", [workspace])?;
conn.execute("INSERT INTO plugin_artifacts VALUES(?1,'installation',?2,?2,'1',1,'{}',0,NULL)", params![workspace,digest])?;
conn.execute("INSERT INTO plugin_uploads VALUES(?1,'upload','actor',9999999999,?2,1,'{}')", params![workspace,digest])?;
conn.execute("INSERT INTO plugin_operations VALUES(?1,'operation','actor','install','key',?2,'{}')", params![workspace,"f".repeat(64)])?;
for kind in ["config","pending","active","restorable"] {
conn.execute("INSERT INTO plugin_references VALUES(?1,?2,'owner','installation',?3)", params![workspace,kind,digest])?;
}
Ok(())
}).unwrap();
}
let counts = |workspace: &str| {
store
.with_conn(|conn| {
[
"plugin_references",
"plugin_operations",
"plugin_uploads",
"plugin_artifacts",
"plugin_installations",
]
.map(|table| {
Ok(conn.query_row(
&format!("SELECT count(*) FROM {table} WHERE workspace_id=?1"),
[workspace],
|r| r.get::<_, i64>(0),
)?)
})
.into_iter()
.collect::<Result<Vec<_>>>()
})
.unwrap()
};
let other = counts("workspace-b");
assert_eq!(counts("workspace-a"), vec![4, 1, 1, 1, 1]);
let preflight = store
.workspace_deletion_preflight(&owner, &workspace_id)
.unwrap();
let operation = store
.reserve_workspace_deletion(
&owner,
&workspace_id,
&WorkspaceDeletionRequest {
operation_id: "purge-plugins".into(),
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: "Alpha".into(),
},
)
.unwrap()
.operation;
assert_eq!(
store
.finalize_workspace_deletion(&operation.operation_id)
.unwrap()
.state,
WorkspaceDeletionState::Succeeded
);
assert_eq!(counts("workspace-a"), vec![0; 5]);
assert_eq!(counts("workspace-b"), other);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))?,
0
);
Ok(())
})
.unwrap();
}
#[test]
fn workspace_purge_removes_subject_deletion_snapshots_and_preserves_other_workspace() {
let (store, owner, workspace_id) = setup();
+10 -6
View File
@@ -30,6 +30,7 @@ pub(crate) enum CliCommand {
Ticket,
Objective,
Plugin,
PluginCatalog,
Mcp,
Session,
Subject,
@@ -49,7 +50,8 @@ impl CliCommand {
CliCommand::WorkerCleanup => "yoi worker management",
CliCommand::Ticket => "yoi ticket",
CliCommand::Objective => "yoi objective",
CliCommand::Plugin => "yoi plugin",
CliCommand::Plugin => "yoi plugin authoring",
CliCommand::PluginCatalog => "yoi plugin catalog",
CliCommand::Mcp => "yoi mcp",
CliCommand::Session => "yoi session",
CliCommand::Subject => "yoi subject",
@@ -65,7 +67,7 @@ impl CliCommand {
| CliCommand::Panel
| CliCommand::Ticket
| CliCommand::Objective => CliConnectionRequirement::ConnectionAware,
CliCommand::Login => CliConnectionRequirement::BackendOnly,
CliCommand::Login | CliCommand::PluginCatalog => CliConnectionRequirement::BackendOnly,
CliCommand::Keys
| CliCommand::SetupModel
| CliCommand::Serve
@@ -317,10 +319,12 @@ mod tests {
command.display_name()
);
}
assert_eq!(
CliCommand::Login.connection_requirement(),
CliConnectionRequirement::BackendOnly
);
for command in [CliCommand::Login, CliCommand::PluginCatalog] {
assert_eq!(
command.connection_requirement(),
CliConnectionRequirement::BackendOnly
);
}
for command in [
CliCommand::Keys,
+173 -14
View File
@@ -44,6 +44,11 @@ enum Mode {
WorkersHelp,
Mcp(mcp_cli::McpCliCommand),
Plugin(plugin_cli::PluginCliCommand),
PluginCatalog {
command: plugin_cli::PluginCatalogCommand,
args: plugin_cli::PluginCliArgs,
target: client::ResolvedTarget,
},
Objective {
cli: objective_cli::ObjectiveCli,
target: client::ResolvedTarget,
@@ -184,6 +189,19 @@ async fn run(mode: Mode) -> ExitCode {
ExitCode::FAILURE
}
},
Mode::PluginCatalog {
command,
args,
target,
} => {
let output = plugin_cli::run_catalog(command, args, target).await;
print!("{}", output.stdout);
if output.success {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
Mode::Objective { cli, target } => {
match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await {
Ok(Ok(output)) => {
@@ -612,9 +630,38 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
)?));
}
"plugin" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
let plugin_cli = parse_plugin_args(&args[1..])?;
return Ok(Mode::Plugin(plugin_cli));
let cli = parse_plugin_args(&args[1..])?;
if let plugin_cli::PluginCliCommand::Catalog { command, args } = cli {
if target_selection.explicit_local {
return Err(ParseError("yoi plugin catalog requires a Backend Workspace target and cannot use --local".into()));
}
let target = resolve_tui_target(
connection_resolver,
CliCommand::PluginCatalog,
&target_selection,
&current_dir()?,
)?
.resolve()
.map_err(|error| ParseError(error.to_string()))?;
if matches!(target, client::ResolvedTarget::Standalone) {
return Err(ParseError(
"yoi plugin catalog requires a Backend Workspace target".into(),
));
}
return Ok(Mode::PluginCatalog {
command,
args,
target,
});
}
if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() {
return Err(backend_target_option_error_for_local_command(
CliCommand::Plugin,
"--backend/--workspace-id",
));
}
// Explicit-path authoring has no connection or Workspace dependency.
return Ok(Mode::Plugin(cli));
}
"login" => {
if target_selection.explicit_local {
@@ -1738,7 +1785,8 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
"yoi plugin requires `new`, `check`, or `pack`".to_string(),
"yoi plugin requires an authoring or catalog command; see `yoi plugin --help`"
.to_string(),
));
};
match subcommand.as_str() {
@@ -1790,6 +1838,16 @@ fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, Pa
)),
}
}
"upload" | "install" | "list" | "show" | "add-artifact" | "revoke" | "uninstall"
| "operation" => {
if rest
.iter()
.any(|arg| matches!(arg.as_str(), "--help" | "-h"))
{
return Err(ParseError(plugin_usage().to_string()));
}
plugin_cli::parse_catalog_args(subcommand, rest).map_err(ParseError)
}
"--help" | "-h" => Err(ParseError(plugin_usage().to_string())),
other => Err(ParseError(format!(
"unknown yoi plugin subcommand `{other}`"
@@ -1847,7 +1905,7 @@ fn parse_plugin_pack_args(
}
fn plugin_usage() -> &'static str {
"usage: yoi plugin new <rust-component-tool|rust-component-https-tool> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]"
"usage: yoi plugin new <rust-component-tool|rust-component-https-tool> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n\nCatalog commands require a Backend Workspace target (select --backend/--workspace-id before `plugin`):\n yoi [TARGET] plugin upload <package> [--json]\n yoi [TARGET] plugin install --upload-id <id> --alias <alias> --operation-key <key> [--json]\n yoi [TARGET] plugin list [--cursor <cursor>] [--limit <1..100>] [--json]\n yoi [TARGET] plugin show <installation-id> [--json]\n yoi [TARGET] plugin add-artifact <installation-id> --upload-id <id> --expected-last-operation-id <id> --operation-key <key> [--json]\n yoi [TARGET] plugin revoke <installation-id> --artifact-digest <digest> --reason <reason> --expected-last-operation-id <id> --operation-key <key> [--json]\n yoi [TARGET] plugin uninstall <installation-id> --expected-last-operation-id <id> --operation-key <key> [--json]\n yoi [TARGET] plugin operation <operation-id> [--json]\nUpload/install only register artifacts; they do not enable or start Workers. Use the observed last_operation from show; a replay receipt is not a fresh observation. Reconcile uncertain mutations before retrying with the same key and exact input."
}
fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
@@ -2083,7 +2141,7 @@ Host commands:
worker delete/prune Safely clean up legacy Worker metadata only
ticket <COMMAND> Manage Tickets through a Backend target
objective <COMMAND> Manage Objectives through a Backend target
plugin <COMMAND> Author/check/pack explicit Plugin packages
plugin <COMMAND> Author packages or manage Backend Plugin catalog
mcp <COMMAND> Inspect configured MCP servers
session <COMMAND> Inspect/prune Standalone session logs
--local subject <COMMAND> Explicitly create/list local Subjects (create <ROLE>, list)
@@ -2514,11 +2572,9 @@ backend = "shared"
assert!(TOP_LEVEL_HELP.contains("default_connection = \"local\""));
assert!(TOP_LEVEL_HELP.contains("yoi-server"));
assert!(TOP_LEVEL_HELP.contains("yoi-runtime"));
assert!(
TOP_LEVEL_HELP.contains(
"plugin <COMMAND> Author/check/pack explicit Plugin packages"
)
);
assert!(TOP_LEVEL_HELP.contains(
"plugin <COMMAND> Author packages or manage Backend Plugin catalog"
));
assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins"));
assert!(TOP_LEVEL_HELP.contains("yoi workspace create"));
assert!(!TOP_LEVEL_HELP.contains("yoi workspace serve"));
@@ -3808,10 +3864,8 @@ backend = "shared"
}
#[test]
fn plugin_cli_rejects_ambient_catalog_commands_and_options() {
fn plugin_cli_rejects_ambient_catalog_options() {
for args in [
vec!["plugin", "list"],
vec!["plugin", "show", "project:echo"],
vec!["plugin", "check", "plugin", "--workspace=/tmp/ws"],
vec!["plugin", "check", "plugin", "--profile", "project:inspect"],
] {
@@ -3831,6 +3885,111 @@ backend = "shared"
}
}
#[test]
fn plugin_authoring_never_resolves_a_connection_or_workspace() {
struct NoConnection;
impl CliConnectionResolver for NoConnection {
fn resolve_connection(
&self,
_: CliCommand,
_: cli_connection::CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> {
panic!("offline Plugin authoring must not resolve a connection")
}
}
for args in [
vec!["plugin", "new", "rust-component-tool", "destination"],
vec!["plugin", "check", "explicit.yoi-plugin"],
vec![
"--local",
"plugin",
"pack",
"explicit",
"--output=explicit.yoi-plugin",
],
] {
let argv = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert!(matches!(
parse_args_slice_with_connection_resolver(&argv, &NoConnection).unwrap(),
Mode::Plugin(_)
));
}
}
#[test]
fn plugin_catalog_uses_backend_workspace_selection_and_rejects_local() {
let resolver = DefaultBackendCliConnectionResolver {
backend_url: "http://selected.example",
};
for args in [
vec!["plugin", "list"],
vec![
"--backend",
"http://selected.example",
"plugin",
"show",
"installation",
],
vec![
"--workspace-id",
"explicit-workspace",
"plugin",
"operation",
"operation",
],
] {
let argv = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
match parse_args_slice_with_connection_resolver(&argv, &resolver).unwrap() {
Mode::PluginCatalog {
target:
client::ResolvedTarget::Backend {
base_url,
workspace_id,
},
..
} => {
assert_eq!(base_url, "http://selected.example");
assert_eq!(
workspace_id,
if args.contains(&"--workspace-id") {
"explicit-workspace"
} else {
"workspace-from-backend"
}
);
}
other => panic!("expected Backend Plugin catalog mode, got {other:?}"),
}
}
let argv = ["--local", "plugin", "list"].map(str::to_string);
assert!(
parse_args_slice_with_connection_resolver(&argv, &resolver)
.unwrap_err()
.0
.contains("requires a Backend")
);
}
#[test]
fn plugin_explicit_workspace_does_not_discover_repository_and_offline_backend_is_not_local() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://selected.example",
};
let argv = ["--workspace-id", "explicit", "plugin", "list"].map(str::to_string);
// This resolver has no repository discovery implementation.
assert!(matches!(
parse_args_slice_with_connection_resolver(&argv, &resolver).unwrap(),
Mode::PluginCatalog { .. }
));
let argv = ["plugin", "list"].map(str::to_string);
assert!(
parse_args_slice_with_connection_resolver(&argv, &OfflineBackendCliConnectionResolver)
.unwrap_err()
.0
.contains("Backend is offline")
);
}
#[test]
fn parse_mcp_commands() {
match parse_args_from(["mcp", "list", "--workspace=/tmp/ws", "--json"]).unwrap() {
+774 -1
View File
@@ -1,7 +1,7 @@
use std::error::Error;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};
use manifest::plugin::{
@@ -38,6 +38,360 @@ pub(crate) enum PluginCliCommand {
output: Option<PathBuf>,
args: PluginCliArgs,
},
Catalog {
command: PluginCatalogCommand,
args: PluginCliArgs,
},
}
#[derive(Clone, Debug)]
pub(crate) enum PluginCatalogCommand {
Upload(PathBuf),
Install(server_api::PluginInstallRequest),
List(server_api::PluginListRequest),
Show(String),
AddArtifact {
installation_id: String,
request: server_api::PluginAddArtifactRequest,
},
Revoke {
installation_id: String,
request: server_api::PluginRevokeRequest,
},
Uninstall {
installation_id: String,
request: server_api::PluginUninstallRequest,
},
Operation(String),
}
pub(crate) fn parse_catalog_args(
subcommand: &str,
args: &[String],
) -> std::result::Result<PluginCliCommand, String> {
let allowed: &[&str] = match subcommand {
"upload" | "show" | "operation" => &[],
"install" => &["--upload-id", "--alias", "--operation-key"],
"list" => &["--cursor", "--limit"],
"add-artifact" => &[
"--upload-id",
"--expected-last-operation-id",
"--operation-key",
],
"revoke" => &[
"--artifact-digest",
"--reason",
"--expected-last-operation-id",
"--operation-key",
],
"uninstall" => &["--expected-last-operation-id", "--operation-key"],
_ => return Err(format!("unknown yoi plugin subcommand `{subcommand}`")),
};
let mut options = std::collections::BTreeMap::new();
let mut positional = Vec::new();
let mut json = false;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if arg == "--json" {
json = true;
} else if arg.starts_with('-') {
let (name, inline) = arg
.split_once('=')
.map_or((arg.as_str(), None), |(k, v)| (k, Some(v)));
if !allowed.contains(&name) {
return Err(format!("unknown yoi plugin {subcommand} option `{name}`"));
}
let value = match inline {
Some(value) => value,
None => {
index += 1;
args.get(index)
.map(String::as_str)
.ok_or_else(|| format!("{name} requires a value"))?
}
};
if value.trim().is_empty() || value.starts_with('-') {
return Err(format!("{name} requires a nonempty value"));
}
if options
.insert(name.to_string(), value.to_string())
.is_some()
{
return Err(format!("{name} must not be provided more than once"));
}
} else {
positional.push(arg.clone());
}
index += 1;
}
let required = |name: &str| -> std::result::Result<String, String> {
options
.get(name)
.cloned()
.ok_or_else(|| format!("yoi plugin {subcommand} requires {name}"))
};
let one_positional = || -> std::result::Result<String, String> {
match positional.as_slice() {
[value] if !value.trim().is_empty() => Ok(value.clone()),
_ => Err(format!(
"yoi plugin {subcommand} requires exactly one {}",
if subcommand == "upload" {
"package path"
} else if subcommand == "operation" {
"operation ID"
} else {
"installation ID"
}
)),
}
};
if matches!(subcommand, "install" | "list") && !positional.is_empty() {
return Err(format!(
"yoi plugin {subcommand} accepts no positional arguments"
));
}
let command = match subcommand {
"upload" => PluginCatalogCommand::Upload(PathBuf::from(one_positional()?)),
"install" => PluginCatalogCommand::Install(server_api::PluginInstallRequest {
upload_id: required("--upload-id")?,
alias: required("--alias")?,
operation_key: required("--operation-key")?,
}),
"list" => {
let limit = options
.get("--limit")
.map(|value| {
value
.parse::<u32>()
.ok()
.filter(|n| (1..=100).contains(n))
.ok_or_else(|| "--limit must be between 1 and 100".to_string())
})
.transpose()?;
PluginCatalogCommand::List(server_api::PluginListRequest {
cursor: options.get("--cursor").cloned(),
limit,
})
}
"show" => PluginCatalogCommand::Show(one_positional()?),
"add-artifact" => PluginCatalogCommand::AddArtifact {
installation_id: one_positional()?,
request: server_api::PluginAddArtifactRequest {
upload_id: required("--upload-id")?,
expected_last_operation: required("--expected-last-operation-id")?,
operation_key: required("--operation-key")?,
},
},
"revoke" => PluginCatalogCommand::Revoke {
installation_id: one_positional()?,
request: server_api::PluginRevokeRequest {
artifact_digest: required("--artifact-digest")?,
reason: required("--reason")?,
expected_last_operation: required("--expected-last-operation-id")?,
operation_key: required("--operation-key")?,
},
},
"uninstall" => PluginCatalogCommand::Uninstall {
installation_id: one_positional()?,
request: server_api::PluginUninstallRequest {
expected_last_operation: required("--expected-last-operation-id")?,
operation_key: required("--operation-key")?,
},
},
"operation" => PluginCatalogCommand::Operation(one_positional()?),
_ => unreachable!("validated subcommand"),
};
Ok(PluginCliCommand::Catalog {
command,
args: PluginCliArgs { json },
})
}
// Use the shared credential resolver without exposing token storage or adding a
// second login path. The generated client owns path encoding and response bounds.
struct PluginAuthorizer(client::BackendApiClient);
impl server_api::client_support::RequestAuthorizer for PluginAuthorizer {
fn authorize(
&self,
request: server_api::client_support::AuthorizerRequest<'_>,
) -> std::result::Result<
reqwest::header::HeaderMap,
server_api::client_support::AuthorizationError,
> {
let prepared = self
.0
.request(request.method.clone(), request.path_and_query)
.and_then(|builder| builder.build().map_err(client::BackendApiClientError::Http))
.map_err(|_| server_api::client_support::AuthorizationError::new())?;
Ok(prepared.headers().clone())
}
}
#[derive(Debug, Serialize)]
struct CatalogFailure {
status: Option<u16>,
error: String,
message: String,
diagnostics: Vec<server_api::Diagnostic>,
}
impl CatalogFailure {
fn local(error: impl std::fmt::Display) -> Self {
Self {
status: None,
error: "plugin_cli".into(),
message: error.to_string(),
diagnostics: Vec::new(),
}
}
}
impl From<server_api::client_support::ClientError<server_api::RepositoryApiError>>
for CatalogFailure
{
fn from(
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> Self {
match error {
server_api::client_support::ClientError::Public { status, error } => Self {
status: Some(status.as_u16()),
error: error.error,
message: error.message,
diagnostics: error.diagnostics,
},
server_api::client_support::ClientError::Failure(error) => Self::local(error),
}
}
}
pub(crate) struct CatalogOutput {
pub success: bool,
pub stdout: String,
}
pub(crate) async fn run_catalog(
command: PluginCatalogCommand,
args: PluginCliArgs,
target: client::ResolvedTarget,
) -> CatalogOutput {
let result = async {
let client::ResolvedTarget::Backend { base_url, workspace_id } = target else {
return Err(CatalogFailure::local("Plugin catalog requires a Backend Workspace target; use --backend and --workspace-id"));
};
let backend = client::BackendApiClient::from_stored_token(&base_url).map_err(CatalogFailure::local)?;
// No redirect may forward a bearer credential to another origin.
let http = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
.build().map_err(CatalogFailure::local)?;
let api = server_api::ServerApiClient::builder(backend.origin().as_str())
.map_err(CatalogFailure::local)?.client(http).authorizer(PluginAuthorizer(backend))
.response_body_limit(16 * 1024 * 1024).build().map_err(CatalogFailure::local)?;
execute_catalog(command, &api, workspace_id).await
}.await;
catalog_output(result, args.json)
}
fn catalog_output(
result: std::result::Result<serde_json::Value, CatalogFailure>,
json: bool,
) -> CatalogOutput {
let (success, value) = match result {
Ok(value) => (true, value),
Err(error) => (
false,
serde_json::json!({
"status": "failed", "diagnostic": error,
"reconciliation": "A failed response does not prove a mutation was not committed. Reconcile the saved operation; retry only with the same operation key and exact input. Do not refresh expected-last-operation-id for a replay."
}),
),
};
let rendered = serde_json::to_string_pretty(&value).expect("JSON value is serializable");
let stdout = if json {
format!("{rendered}\n")
} else {
format!(
"plugin catalog: {}\n{rendered}\n",
if success {
"backend response (registration does not enable or start a Worker)"
} else {
"failed"
}
)
};
CatalogOutput { success, stdout }
}
async fn execute_catalog<A: server_api::client_support::RequestAuthorizer>(
command: PluginCatalogCommand,
api: &server_api::ServerApiClient<A>,
workspace_id: String,
) -> std::result::Result<serde_json::Value, CatalogFailure> {
let value = match command {
PluginCatalogCommand::Upload(input) => {
let bytes = read_upload(&input).map_err(CatalogFailure::local)?;
serde_json::to_value(api.plugin_upload(workspace_id, bytes.into()).await?)
}
PluginCatalogCommand::Install(request) => {
serde_json::to_value(api.plugin_install(workspace_id, request).await?)
}
PluginCatalogCommand::List(query) => {
serde_json::to_value(api.plugin_list(workspace_id, query).await?)
}
PluginCatalogCommand::Show(id) => {
serde_json::to_value(api.plugin_show(workspace_id, id).await?)
}
PluginCatalogCommand::AddArtifact {
installation_id,
request,
} => serde_json::to_value(
api.plugin_artifact_add(workspace_id, installation_id, request)
.await?,
),
PluginCatalogCommand::Revoke {
installation_id,
request,
} => serde_json::to_value(
api.plugin_revoke(workspace_id, installation_id, request)
.await?,
),
PluginCatalogCommand::Uninstall {
installation_id,
request,
} => serde_json::to_value(
api.plugin_uninstall(workspace_id, installation_id, request)
.await?,
),
PluginCatalogCommand::Operation(id) => {
serde_json::to_value(api.plugin_operation(workspace_id, id).await?)
}
};
value.map_err(CatalogFailure::local)
}
fn read_upload(input: &Path) -> Result<Vec<u8>> {
let limit = PluginPackageLimits::default().max_package_size_bytes;
let file = fs::File::open(input)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(
"Plugin upload requires an explicit .yoi-plugin regular file; pack directories first"
.into(),
);
}
if metadata.len() > limit {
return Err(
format!("Plugin upload exceeds the shared package limit of {limit} bytes").into(),
);
}
let mut bytes = Vec::new();
file.take(limit + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(
format!("Plugin upload exceeds the shared package limit of {limit} bytes").into(),
);
}
Ok(bytes)
}
pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
@@ -62,6 +416,9 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
output,
args,
} => render_pack(&input, output.as_deref(), &args)?,
PluginCliCommand::Catalog { .. } => {
return Err("Plugin catalog requires Backend dispatch".into());
}
};
print!("{rendered}");
Ok(())
@@ -571,6 +928,422 @@ mod tests {
use super::*;
use tempfile::tempdir;
fn catalog_command(subcommand: &str, args: &[&str]) -> PluginCatalogCommand {
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
match parse_catalog_args(subcommand, &args).unwrap() {
PluginCliCommand::Catalog { command, .. } => command,
_ => panic!("expected catalog command"),
}
}
#[test]
fn catalog_parser_requires_explicit_mutation_keys_and_observed_operations() {
for (subcommand, args, missing) in [
(
"install",
vec!["--upload-id", "upload", "--alias", "alias"],
"--operation-key",
),
(
"add-artifact",
vec![
"installation",
"--upload-id",
"upload",
"--operation-key",
"key",
],
"--expected-last-operation-id",
),
(
"revoke",
vec![
"installation",
"--artifact-digest",
"sha256:artifact",
"--reason",
"reason",
"--operation-key",
"key",
],
"--expected-last-operation-id",
),
(
"uninstall",
vec!["installation", "--operation-key", "key"],
"--expected-last-operation-id",
),
(
"uninstall",
vec!["installation", "--expected-last-operation-id", "observed"],
"--operation-key",
),
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert!(
parse_catalog_args(subcommand, &args)
.unwrap_err()
.contains(missing)
);
}
for args in [
vec![
"installation",
"--operation-key=",
"--expected-last-operation-id=observed",
],
vec![
"installation",
"--operation-key=key",
"--operation-key=other",
"--expected-last-operation-id=observed",
],
vec![
"installation",
"--operation-key=key",
"--expected-last-operation-id= ",
],
vec![
"installation",
"--operation-key=key",
"--expected-last-operation-id=observed",
"--operator",
],
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert!(parse_catalog_args("uninstall", &args).is_err());
}
for limit in ["0", "101", "-1", "not-a-number"] {
assert!(parse_catalog_args("list", &[format!("--limit={limit}")]).is_err());
}
assert!(parse_catalog_args("list", &["extra".into()]).is_err());
assert!(parse_catalog_args("show", &[]).is_err());
assert!(parse_catalog_args("operation", &["one".into(), "two".into()]).is_err());
}
#[test]
fn upload_reads_explicit_regular_bytes_with_the_shared_package_bound() {
let dir = tempdir().unwrap();
let input = dir.path().join("package.yoi-plugin");
let bytes = b"exact uploaded bytes\0";
fs::write(&input, bytes).unwrap();
assert_eq!(read_upload(&input).unwrap(), bytes);
assert!(
read_upload(dir.path())
.unwrap_err()
.to_string()
.contains("regular file")
);
let file = fs::File::create(&input).unwrap();
file.set_len(PluginPackageLimits::default().max_package_size_bytes + 1)
.unwrap();
assert!(
read_upload(&input)
.unwrap_err()
.to_string()
.contains("shared package limit")
);
}
// The real argv parser, generated HTTP client and output conversion are connected
// here. Only the remote Server and credential issuance are replaced; this does
// not attest Server authentication, catalog persistence or domain validation.
mod catalog_http {
use super::*;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
struct TestAuthorizer;
impl server_api::client_support::RequestAuthorizer for TestAuthorizer {
fn authorize(
&self,
_: server_api::client_support::AuthorizerRequest<'_>,
) -> std::result::Result<
reqwest::header::HeaderMap,
server_api::client_support::AuthorizationError,
> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
"Bearer test-only".parse().unwrap(),
);
Ok(headers)
}
}
async fn server(
status: u16,
response: serde_json::Value,
) -> (String, tokio::task::JoinHandle<(String, Vec<u8>)>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let origin = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
tokio::time::timeout(Duration::from_secs(10), async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let (headers, body_start, content_len) = loop {
let mut buf = [0; 4096];
let len = stream.read(&mut buf).await.unwrap();
assert_ne!(len, 0, "request ended before headers");
request.extend_from_slice(&buf[..len]);
if let Some(end) = request.windows(4).position(|b| b == b"\r\n\r\n") {
let headers = String::from_utf8(request[..end].to_vec()).unwrap();
let content_len = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().unwrap())
}).unwrap_or(0);
break (headers, end + 4, content_len);
}
};
while request.len() - body_start < content_len {
let mut buf = [0; 4096];
let len = stream.read(&mut buf).await.unwrap();
assert_ne!(len, 0, "request ended before body");
request.extend_from_slice(&buf[..len]);
}
let body = serde_json::to_vec(&response).unwrap();
stream.write_all(format!("HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()).await.unwrap();
stream.write_all(&body).await.unwrap();
(headers, request[body_start..].to_vec())
}).await.expect("bounded mock Server request")
});
(origin, task)
}
fn installation() -> serde_json::Value {
serde_json::json!({"installation_id":"inst/one", "alias":"alias", "package_id":"example", "last_operation":"saved-operation", "uninstalled":false, "artifacts":[]})
}
#[tokio::test]
async fn catalog_commands_send_exact_workspace_paths_query_and_caller_mutation_inputs() {
let dir = tempdir().unwrap();
let package = dir.path().join("explicit.yoi-plugin");
fs::write(&package, b"exact\0archive bytes").unwrap();
let upload_path = package.to_str().unwrap();
let operation = |kind| serde_json::json!({"operation_id":"saved-operation", "kind":kind, "installation":installation()});
let mut summary = installation();
summary.as_object_mut().unwrap().remove("artifacts");
let upload = serde_json::json!({"upload_id":"upload", "expires_at":"2026-10-30T00:00:00Z", "artifact":{
"package_id":"example", "name":"Example", "version":"1.0.0", "description":null,
"package_digest":"sha256:logical", "artifact_digest":"sha256:archive", "artifact_size":19,
"schema_version":2, "world":"yoi:plugin/tool-plugin@2.0.0", "required_imports":[], "tools":[],
"connections":[], "requested_grants":[], "config_schema":{"identity":"example", "version":"1", "digest":"sha256:schema", "namespace":"example", "dcdl":"schema"},
"revoked":false, "revocation_reason":null
}});
let cases = vec![
(
"upload",
vec![upload_path],
"POST",
"/api/w/ws%20space/plugin-uploads",
None,
upload,
),
(
"install",
vec![
"--upload-id=upload",
"--alias=alias",
"--operation-key=exact-key",
],
"POST",
"/api/w/ws%20space/plugins",
Some(
serde_json::json!({"upload_id":"upload", "alias":"alias", "operation_key":"exact-key"}),
),
operation("install"),
),
(
"list",
vec!["--cursor=next /+=", "--limit=7"],
"GET",
"/api/w/ws%20space/plugins",
None,
serde_json::json!({"installations":[summary], "next_cursor":"opaque-next"}),
),
(
"show",
vec!["inst/one"],
"GET",
"/api/w/ws%20space/plugins/inst%2Fone",
None,
installation(),
),
(
"add-artifact",
vec![
"inst/one",
"--upload-id=upload",
"--expected-last-operation-id=observed-operation",
"--operation-key=exact-key",
],
"POST",
"/api/w/ws%20space/plugins/inst%2Fone/artifacts",
Some(
serde_json::json!({"upload_id":"upload", "expected_last_operation":"observed-operation", "operation_key":"exact-key"}),
),
operation("add_artifact"),
),
(
"revoke",
vec![
"inst/one",
"--artifact-digest=sha256:archive",
"--reason=explicit reason",
"--expected-last-operation-id=observed-operation",
"--operation-key=exact-key",
],
"POST",
"/api/w/ws%20space/plugins/inst%2Fone/revoke",
Some(
serde_json::json!({"artifact_digest":"sha256:archive", "reason":"explicit reason", "expected_last_operation":"observed-operation", "operation_key":"exact-key"}),
),
operation("revoke"),
),
(
"uninstall",
vec![
"inst/one",
"--expected-last-operation-id=observed-operation",
"--operation-key=exact-key",
],
"POST",
"/api/w/ws%20space/plugins/inst%2Fone/uninstall",
Some(
serde_json::json!({"expected_last_operation":"observed-operation", "operation_key":"exact-key"}),
),
operation("uninstall"),
),
(
"operation",
vec!["saved/operation"],
"GET",
"/api/w/ws%20space/plugin-operations/saved%2Foperation",
None,
operation("install"),
),
];
for (subcommand, args, method, path, expected_body, response) in cases {
let (origin, captured) = server(200, response.clone()).await;
let api = server_api::ServerApiClient::builder(origin.as_str())
.unwrap()
.authorizer(TestAuthorizer)
.build()
.unwrap();
let result =
execute_catalog(catalog_command(subcommand, &args), &api, "ws space".into())
.await
.unwrap();
assert_eq!(
result, response,
"{subcommand} output must retain authoritative metadata"
);
let output = catalog_output(Ok(result), true);
assert!(output.success);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output.stdout).unwrap(),
response
);
let (headers, body) = captured.await.unwrap();
let mut start = headers.lines().next().unwrap().split_whitespace();
assert_eq!(start.next(), Some(method));
let url =
reqwest::Url::parse(&format!("{origin}{}", start.next().unwrap())).unwrap();
assert_eq!(url.path(), path, "{subcommand}");
assert!(
headers
.to_ascii_lowercase()
.contains("authorization: bearer test-only")
);
if subcommand == "list" {
let query = url
.query_pairs()
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(query.get("cursor").unwrap(), "next /+=");
assert_eq!(query.get("limit").unwrap(), "7");
}
if subcommand == "upload" {
assert_eq!(body, b"exact\0archive bytes");
assert!(headers.contains("application/octet-stream"));
} else if let Some(expected) = expected_body {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
expected
);
} else {
assert!(body.is_empty());
}
}
}
#[tokio::test]
async fn catalog_rejections_keep_concrete_diagnostics_without_retry() {
for (status, code) in [
(401, "unauthenticated"),
(403, "forbidden"),
(409, "plugin_operation_conflict"),
(413, "plugin_upload_too_large"),
] {
let diagnostics = serde_json::json!([{"code":"catalog_rejected", "severity":"error", "message":"safe blocker detail"}]);
let response = serde_json::json!({"error":code,"message":"concrete rejection detail","diagnostics":diagnostics});
let (origin, captured) = server(status, response).await;
let api = server_api::ServerApiClient::builder(origin.as_str())
.unwrap()
.authorizer(TestAuthorizer)
.build()
.unwrap();
let result = execute_catalog(
catalog_command(
"uninstall",
&[
"inst",
"--expected-last-operation-id=observed",
"--operation-key=exact",
],
),
&api,
"ws".into(),
)
.await;
let output = catalog_output(result, true);
assert!(!output.success);
let report: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert_eq!(report["diagnostic"]["status"], status);
assert_eq!(report["diagnostic"]["error"], code);
assert_eq!(report["diagnostic"]["message"], "concrete rejection detail");
assert_eq!(report["diagnostic"]["diagnostics"], diagnostics);
assert!(
report["reconciliation"]
.as_str()
.unwrap()
.contains("same operation key and exact input")
);
let (_, body) = captured.await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["expected_last_operation"], "observed");
assert_eq!(body["operation_key"], "exact");
}
}
}
#[tokio::test]
async fn catalog_standalone_target_is_rejected_with_json_diagnostic() {
let output = run_catalog(
catalog_command("list", &[]),
PluginCliArgs { json: true },
client::ResolvedTarget::Standalone,
)
.await;
assert!(!output.success);
let report: serde_json::Value = serde_json::from_str(&output.stdout).unwrap();
assert!(
report["diagnostic"]["message"]
.as_str()
.unwrap()
.contains("Backend Workspace target")
);
}
fn built_tool_package(destination: &Path) {
render_new(
"rust-component-tool",