feat: enforce live Plugin Host authority and durable HTTPS effects

This commit is contained in:
2026-10-30 04:07:19 +09:00
parent c65f145376
commit 732fe5c4c1
35 changed files with 7660 additions and 25 deletions
+16 -2
View File
@@ -10,11 +10,13 @@ pub mod authorization;
mod drive;
mod notification;
mod plugin;
pub mod plugin_host;
mod plugin_types;
pub use notification::*;
pub use plugin::*;
pub use plugin_host::*;
#[cfg(feature = "typescript")]
pub use plugin_types::plugin_api_typescript;
pub use plugin_types::{plugin_api_typescript, plugin_host_typescript};
mod ticket_browse;
pub use ticket_browse::*;
pub mod repository_openapi_typescript;
@@ -210,6 +212,9 @@ macro_rules! impl_openapi_schema {
}
impl_openapi_schema!(
PluginHostRequest,
PluginHostResponse,
PluginHostError,
PluginInstallRequest,
PluginAddArtifactRequest,
PluginRevokeRequest,
@@ -577,6 +582,15 @@ pub struct WorkspaceWorkerSessionHistoryResponse {
/// Response loss does not imply rollback.
#[api(reqwest, axum, openapi)]
pub trait ServerApi {
/// Runtime Host owner boundary; human plugin-management authority is not accepted here.
#[post("/api/w/{workspace_id}/plugin-host", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], normalize_body_errors = true)]
async fn plugin_host(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: PluginHostRequest,
) -> Result<PluginHostResponse, PluginHostError>;
#[get("/health", status = 200, error_status = 400)]
async fn health(&self) -> Result<HealthResponse, RepositoryApiError>;
@@ -14968,7 +14982,7 @@ mod openapi_artifact_tests {
// Session observation retains its existing explicit non-OpenAPI boundary independently of
// the Runtime/Worker-source signed operation inventory above.
const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"];
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"];
const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config", "plugin_host"];
const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"];
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
+356
View File
@@ -0,0 +1,356 @@
//! Runtime-to-owner Host protocol. Authentication is transport-bound, never a DTO claim.
use crate::PluginHttpMethod;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginHostRequest {
Begin {
plan_id: String,
installation_id: String,
consumer: PluginConsumer,
},
/// Irreversible cancellation of this authenticated logical invocation.
Cancel {
invocation_id: String,
},
Authorize {
invocation_id: String,
operation: PluginHostOperation,
},
Open {
invocation_id: String,
slot: String,
},
Send {
invocation_id: String,
slot: String,
request: PluginHttpRequest,
},
Log {
invocation_id: String,
entry: PluginLogEntry,
},
}
#[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 PluginConsumer {
ToolCall {
tool_call_id: String,
tool_name: String,
},
/// Reserved consumer identity; this does not enable service/event delivery.
Consumer {
consumer_id: String,
event_id: String,
export_name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginHostOperation {
Admit,
Publish,
Open { slot: String },
Send { slot: String },
Log,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginHttpRequest {
pub method: PluginHttpMethod,
pub path: String,
pub query: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub timeout_ms: u32,
pub effect_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginHttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "data",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginHostResponse {
Invocation {
invocation_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
expires_at_ms: i64,
},
Authorized,
Opened,
Response {
response: PluginHttpResponse,
},
Logged,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginLogEntry {
pub level: PluginLogLevel,
pub event_code: String,
pub message: String,
pub fields: Vec<PluginLogField>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginLogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginLogField {
pub name: String,
pub value: PluginLogScalar,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(
tag = "kind",
content = "value",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum PluginLogScalar {
Text(String),
Boolean(bool),
Signed(
#[serde(with = "decimal_integer")]
#[schemars(with = "String")]
#[cfg_attr(feature = "typescript", ts(type = "string"))]
i64,
),
Unsigned(
#[serde(with = "decimal_integer")]
#[schemars(with = "String")]
#[cfg_attr(feature = "typescript", ts(type = "string"))]
u64,
),
Number(f64),
}
// WIT scalars retain their full 64-bit range without JSON/TypeScript precision loss.
mod decimal_integer {
pub fn serialize<T: std::fmt::Display, S: serde::Serializer>(
value: &T,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: std::str::FromStr + std::fmt::Display,
D: serde::Deserializer<'de>,
{
let text = <String as serde::Deserialize>::deserialize(deserializer)?;
let value: T = text
.parse()
.map_err(|_| serde::de::Error::custom("invalid decimal integer"))?;
if value.to_string() != text {
return Err(serde::de::Error::custom("non-canonical decimal integer"));
}
Ok(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginHostErrorCode {
InvalidInput,
Denied,
NotFound,
Conflict,
Unsupported,
QuotaExceeded,
DeadlineExceeded,
Cancelled,
Unavailable,
Indeterminate,
ReceiptExpired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PluginEffectObservation {
NotDispatched,
ResponseObserved,
Indeterminate,
}
/// Deliberately excludes raw diagnostics, transport bodies and arbitrary messages.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PluginHostError {
pub code: PluginHostErrorCode,
pub effect: PluginEffectObservation,
pub diagnostic_id: String,
}
impl PluginHostError {
pub fn message(&self) -> &'static str {
match self.code {
PluginHostErrorCode::InvalidInput => "invalid plugin Host input",
PluginHostErrorCode::Denied => "plugin Host operation denied",
PluginHostErrorCode::NotFound => "plugin Host target not found",
PluginHostErrorCode::Conflict => "plugin Host operation conflict",
PluginHostErrorCode::Unsupported => "plugin Host operation unsupported",
PluginHostErrorCode::QuotaExceeded => "plugin Host quota exceeded",
PluginHostErrorCode::DeadlineExceeded => "plugin Host deadline exceeded",
PluginHostErrorCode::Cancelled => "plugin Host operation cancelled",
PluginHostErrorCode::Unavailable => "plugin Host unavailable",
PluginHostErrorCode::Indeterminate => "plugin Host outcome indeterminate",
PluginHostErrorCode::ReceiptExpired => "plugin Host receipt expired",
}
}
}
impl std::fmt::Display for PluginHostError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for PluginHostError {}
impl api_macros::HttpError for PluginHostError {
fn status_code(&self) -> u16 {
match self.code {
PluginHostErrorCode::InvalidInput | PluginHostErrorCode::Unsupported => 400,
PluginHostErrorCode::Denied => 403,
PluginHostErrorCode::NotFound => 404,
PluginHostErrorCode::Conflict
| PluginHostErrorCode::Indeterminate
| PluginHostErrorCode::ReceiptExpired => 409,
PluginHostErrorCode::QuotaExceeded => 429,
PluginHostErrorCode::DeadlineExceeded
| PluginHostErrorCode::Cancelled
| PluginHostErrorCode::Unavailable => 503,
}
}
}
impl api_macros::HttpRequestError for PluginHostError {
fn from_request_rejection(status: u16, _message: String) -> Self {
Self {
code: if status == 413 {
PluginHostErrorCode::QuotaExceeded
} else {
PluginHostErrorCode::InvalidInput
},
effect: PluginEffectObservation::NotDispatched,
diagnostic_id: String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_protocol_rejects_identity_claims_and_unknown_fields() {
for value in [
serde_json::json!({"kind":"begin","plan_id":"p","installation_id":"i","consumer":{"kind":"tool_call","tool_call_id":"t","tool_name":"n"},"subject":"owner"}),
serde_json::json!({"kind":"begin","plan_id":"p","installation_id":"i","consumer":{"kind":"tool_call","tool_call_id":"t","tool_name":"n","worker_id":"w"}}),
serde_json::json!({"kind":"authorize","invocation_id":"i","operation":{"kind":"admit","subject":"owner"}}),
serde_json::json!({"kind":"send","invocation_id":"i","slot":"s","request":{"method":"GET","path":"/","query":[],"headers":[],"body":[],"timeout_ms":1,"effect_key":"e","url":"https://untrusted"}}),
] {
assert!(serde_json::from_value::<PluginHostRequest>(value).is_err());
}
assert!(
serde_json::from_value::<PluginHostResponse>(
serde_json::json!({"kind":"authorized","subject":"owner"})
)
.is_err()
);
assert!(serde_json::from_value::<PluginHostError>(serde_json::json!({"code":"denied","effect":"not_dispatched","diagnostic_id":"d","message":"secret"})).is_err());
}
#[test]
fn log_integer_wire_preserves_full_wit_range_without_json_precision_loss() {
for scalar in [
PluginLogScalar::Signed(i64::MIN),
PluginLogScalar::Signed(i64::MAX),
PluginLogScalar::Unsigned(u64::MAX),
] {
let wire = serde_json::to_value(&scalar).unwrap();
assert!(wire["value"].is_string());
assert_eq!(
serde_json::from_value::<PluginLogScalar>(wire).unwrap(),
scalar
);
}
for value in [
serde_json::json!(1),
serde_json::json!("01"),
serde_json::json!("+1"),
serde_json::json!("18446744073709551616"),
] {
assert!(
serde_json::from_value::<PluginLogScalar>(
serde_json::json!({"kind":"unsigned","value":value})
)
.is_err()
);
}
}
#[test]
fn host_route_uses_runtime_context_and_closed_error_schema() {
let document = crate::canonical_openapi_document().unwrap();
let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap();
let route = &value["paths"]["/api/w/{workspace_id}/plugin-host"]["post"];
for status in [200, 400, 401, 403, 404, 409, 413, 429, 503] {
assert!(
route["responses"][status.to_string()].is_object(),
"{status}"
);
}
assert!(
route["security"].is_null(),
"signed Runtime context, not human bearer/browser auth"
);
let params = route["parameters"].as_array().unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0]["name"], "workspace_id");
let error = &value["components"]["schemas"]["PluginHostError"];
assert_eq!(error["additionalProperties"], false);
assert_eq!(error["properties"].as_object().unwrap().len(), 3);
}
}
+44 -1
View File
@@ -1,4 +1,28 @@
//! Generated management types share the exact DTOs used by the HTTP declaration.
//! Generated plugin types share the exact DTOs used by the HTTP declarations.
#[cfg(feature = "typescript")]
pub fn plugin_host_typescript() -> String {
use crate::*;
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
PluginHttpMethod::decl(&config),
PluginConsumer::decl(&config),
PluginHostRequest::decl(&config),
PluginHostOperation::decl(&config),
PluginHttpRequest::decl(&config),
PluginHttpResponse::decl(&config),
PluginHostResponse::decl(&config),
PluginLogEntry::decl(&config),
PluginLogLevel::decl(&config),
PluginLogField::decl(&config),
PluginLogScalar::decl(&config),
PluginHostError::decl(&config),
PluginHostErrorCode::decl(&config),
PluginEffectObservation::decl(&config),
];
declarations.map(|d| format!("export {d}")).join("\n\n")
}
#[cfg(feature = "typescript")]
pub fn plugin_api_typescript() -> String {
use crate::*;
@@ -31,6 +55,25 @@ pub fn plugin_api_typescript() -> String {
#[cfg(test)]
mod tests {
#[cfg(feature = "typescript")]
#[test]
fn host_typescript_preserves_wire_numbers_and_lossless_scalar_strings() {
let text = super::plugin_host_typescript();
for name in [
"PluginHostRequest",
"PluginHostResponse",
"PluginHostError",
"PluginConsumer",
"PluginHttpRequest",
"PluginHttpResponse",
"PluginLogEntry",
] {
assert!(text.contains(&format!("export type {name} =")), "{name}");
}
assert!(text.contains("expires_at_ms: number"));
assert!(text.contains("\"signed\", \"value\": string"));
assert!(text.contains("\"unsigned\", \"value\": string"));
}
#[test]
fn plugin_routes_use_binary_upload_and_closed_management_contracts() {
let document = crate::canonical_openapi_document().unwrap();