feat: stage exact Plugin Tools through shared Worker composition

Extract the unchanged canonical plan model for typed Runtime snapshots without a Server dependency cycle. Bind Tool consumer identities to persisted Session/call IDs, use the owner-issued context and current publication authorization, and register through FeatureInstallPlan with cooperative shutdown.

Validation so far: root cargo check --locked, portable model 6 tests/doctest, Worker actual Component adapter 7 focused tests, Server plan 9 tests via shared isolation. Full product launch owner/pin lifecycle and Remote/Embedded vertical integration remain a subsequent coherent change.
This commit is contained in:
2026-10-30 04:42:53 +09:00
parent 3a13301f75
commit 165153c63c
42 changed files with 2123 additions and 424 deletions
+1
View File
@@ -15,6 +15,7 @@ agen = { workspace = true, features = ["codex"] }
session-store = { workspace = true }
secrets = { workspace = true }
manifest = { workspace = true }
yoi-plugin-runtime = { path = "../plugin-runtime" }
job = { workspace = true }
mcp = { workspace = true }
protocol = { workspace = true, features = ["json-schema"] }
+12
View File
@@ -1544,6 +1544,18 @@ where
} else {
(FeatureRegistryBuilder::new(), None)
};
match worker.plugin_tools() {
Some(tools) => {
feature_registry.add_module(tools);
}
None if !worker.manifest().plugins.is_empty() => {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Plugin selection requires a persisted plan and current Host preparation",
));
}
None => {}
}
feature_registry
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
if let Some(binding) = backend_job.as_ref() {
+1
View File
@@ -17,6 +17,7 @@ pub mod job;
mod metadata_lifecycle;
pub mod model_client;
mod paste_artifact_tool;
pub mod plugin;
pub mod profile_manifest;
pub mod prompt;
#[cfg(test)]
+389
View File
@@ -0,0 +1,389 @@
//! Host-selected Plugin Tools use the ordinary Feature install transaction and
//! Agen execution/history path. This module neither selects a Plugin nor grants
//! access: callers bind exact persisted pins and current owner adapters before
//! controller publication. Verified bytes and compiled Components are not leases.
use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureId, FeatureInstallContext, FeatureInstallError,
FeatureModule, FeatureRuntimeKind, HookDeclaration, ToolContribution, ToolDeclaration,
};
use crate::hook::{
Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerStopping, WorkerStoppingContext,
};
use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
};
use async_trait::async_trait;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Mutex},
};
use tokio::sync::Semaphore;
use yoi_plugin_runtime::{
CompiledTool, Executor, Limits,
delivery::{ArtifactCache, ArtifactExpectation, ArtifactSource},
host::{CancellationToken, Grants, Host, InvocationContext, Operation},
owner::{OwnerHost, PluginHostOwner},
};
/// Upper bounds projected by the trusted launch owner from one persisted pin.
/// Never construct this from model input, current Profile, cache inventory, or
/// unvalidated delivery metadata. The expectation must match the persisted pin.
pub struct PluginToolPin {
pub workspace_id: String,
pub installation_id: String,
pub plan_id: String,
pub subject: String,
pub request: server_api::PluginArtifactDeliveryRequest,
pub expectation: ArtifactExpectation,
pub tools: Vec<String>,
pub config: Value,
pub grants: Grants,
}
#[derive(Debug, thiserror::Error)]
pub enum PluginPreparationError {
#[error("invalid Plugin launch snapshot")]
InvalidSnapshot,
#[error("Plugin artifact delivery refused or unavailable")]
Delivery,
#[error("Plugin Component or execution policy is unsupported")]
Component,
}
struct ActiveCall {
cancellation: CancellationToken,
host: Mutex<Option<Arc<OwnerHost>>>,
}
impl ActiveCall {
async fn cancel(&self) -> Result<(), ToolError> {
self.cancellation.cancel();
let host = self.host.lock().expect("Plugin call lock").clone();
if let Some(host) = host {
host.cancel()
.await
.map_err(|_| failure("Plugin cancellation outcome requires reconciliation"))?;
}
Ok(())
}
}
#[derive(Default)]
struct Calls {
stopped: bool,
active: BTreeMap<String, Arc<ActiveCall>>,
}
struct CallLease {
key: String,
state: Arc<Mutex<Calls>>,
call: Arc<ActiveCall>,
}
impl Drop for CallLease {
fn drop(&mut self) {
self.call.cancellation.cancel();
self.state
.lock()
.expect("Plugin calls lock")
.active
.remove(&self.key);
}
}
/// Fully materialized Tool contributions. Preparation publishes nothing; the
/// existing Feature planner stages all names, schemas and hooks atomically.
#[derive(Clone)]
pub struct PluginToolsFeature {
tools: Vec<(ToolMeta, Arc<PluginTool>)>,
calls: Arc<Mutex<Calls>>,
}
impl PluginToolsFeature {
pub async fn prepare(
pins: Vec<PluginToolPin>,
source: &dyn ArtifactSource,
cache: &ArtifactCache,
owner: Arc<dyn PluginHostOwner>,
session_id: &str,
limits: Limits,
) -> Result<Self, PluginPreparationError> {
use PluginPreparationError::*;
if session_id.is_empty() || pins.len() > 128 {
return Err(InvalidSnapshot);
}
let executor = Arc::new(Executor::new(limits).map_err(|_| Component)?);
let calls = Arc::new(Mutex::new(Calls::default()));
let capacity = Arc::new(Semaphore::new(16));
let mut names = BTreeSet::new();
let mut installations = BTreeSet::new();
let mut tools = Vec::new();
for pin in pins {
let descriptor = pin.expectation.descriptor();
if pin.workspace_id.is_empty()
|| pin.installation_id.is_empty()
|| pin.plan_id.is_empty()
|| pin.plan_id != pin.request.plan_id
|| pin.installation_id != pin.request.installation_id
|| descriptor.artifact_digest != pin.request.artifact_digest
|| !pin.config.is_object()
|| !installations.insert((pin.workspace_id.clone(), pin.installation_id.clone()))
|| pin.tools.iter().collect::<BTreeSet<_>>().len() != pin.tools.len()
{
return Err(InvalidSnapshot);
}
let config_json = serde_json::to_string(&pin.config).map_err(|_| InvalidSnapshot)?;
if config_json.len() > executor.limits().config_bytes {
return Err(InvalidSnapshot);
}
let artifact = cache
.resolve(source, &pin.workspace_id, &pin.request, &pin.expectation)
.await
.map_err(|_| Delivery)?;
let compiled = Arc::new(
executor
.compile(
artifact.bytes(),
&descriptor.package_digest,
&descriptor.artifact_digest,
u64::from(descriptor.artifact_size),
)
.map_err(|_| Component)?,
);
for name in &pin.tools {
let declaration = compiled
.tools()
.iter()
.find(|tool| &tool.name == name)
.ok_or(InvalidSnapshot)?;
let public_name = format!("{}__{}", pin.installation_id, name);
if public_name.len() > 128 || !names.insert(public_name.clone()) {
return Err(InvalidSnapshot);
}
let meta = ToolMeta::new(public_name)
.description(declaration.description.clone())
.input_schema(declaration.input_schema.clone())
.origin(ToolOrigin {
kind: "plugin".into(),
plugin_id: descriptor.package_id.clone(),
plugin_ref: pin.installation_id.clone(),
source: "workspace".into(),
digest: descriptor.package_digest.clone(),
package_version: descriptor.version.clone(),
package_api_version: descriptor.schema_version,
surface: "tool".into(),
});
let tool = Arc::new(PluginTool {
executor: executor.clone(),
compiled: compiled.clone(),
owner: owner.clone(),
workspace: pin.workspace_id.clone(),
installation: pin.installation_id.clone(),
plan: pin.plan_id.clone(),
subject: pin.subject.clone(),
grants: pin.grants.clone(),
tool_name: name.clone(),
config_json: config_json.clone(),
session_id: session_id.into(),
calls: calls.clone(),
capacity: capacity.clone(),
});
tools.push((meta, tool));
}
}
Ok(Self { tools, calls })
}
}
impl FeatureModule for PluginToolsFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor =
FeatureDescriptor::builtin("plugin-tools", "Workspace Plugin Tools").with_hook(
HookDeclaration::new("stop", FeatureHookPoint::WorkerStopping),
);
descriptor.id = FeatureId::new("plugin:tools").expect("fixed Feature identity");
descriptor.runtime = FeatureRuntimeKind::ExternalPlugin;
for (meta, _) in &self.tools {
descriptor
.tools
.push(ToolDeclaration::new(&meta.name, &meta.description));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
for (meta, tool) in &self.tools {
let meta = meta.clone();
let tool = tool.clone();
let name = meta.name.clone();
let definition: ToolDefinition = Arc::new(move || (meta.clone(), tool.clone()));
context
.tools()
.register(ToolContribution::new(name, definition))?;
}
context.hooks().add_worker_stopping(
"stop",
HookExecutionPolicy::fail_closed(),
self.clone(),
)
}
}
#[async_trait]
impl Hook<WorkerStopping> for PluginToolsFeature {
async fn call(&self, _: &WorkerStoppingContext) -> Result<(), HookError> {
let active = {
let mut calls = self.calls.lock().expect("Plugin calls lock");
calls.stopped = true;
calls.active.values().cloned().collect::<Vec<_>>()
};
let mut failed = false;
for call in active {
if call.cancel().await.is_err() {
failed = true;
}
}
if failed {
return Err(HookError::new(
HookErrorCategory::Dependency,
"Plugin cancellation requires reconciliation",
));
}
Ok(())
}
}
struct PluginTool {
executor: Arc<Executor>,
compiled: Arc<CompiledTool>,
owner: Arc<dyn PluginHostOwner>,
workspace: String,
installation: String,
plan: String,
subject: String,
grants: Grants,
tool_name: String,
config_json: String,
session_id: String,
calls: Arc<Mutex<Calls>>,
capacity: Arc<Semaphore>,
}
fn failure(message: &str) -> ToolError {
ToolError::Internal(message.into())
}
impl PluginTool {
fn start(&self, context: &ToolExecutionContext) -> Result<CallLease, ToolError> {
let key = context.execution_id();
let mut calls = self.calls.lock().expect("Plugin calls lock");
if calls.stopped || calls.active.contains_key(&key) {
return Err(failure("Plugin Tool is stopping or already running"));
}
let call = Arc::new(ActiveCall {
cancellation: CancellationToken::new(),
host: Mutex::new(None),
});
calls.active.insert(key.clone(), call.clone());
Ok(CallLease {
key,
state: self.calls.clone(),
call,
})
}
}
#[async_trait]
impl Tool for PluginTool {
async fn execute(
&self,
input_json: &str,
context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
if context.call_id.is_empty() || context.call_id == "direct" {
return Err(failure("Plugin Tool requires committed Tool-call identity"));
}
let _capacity = self
.capacity
.clone()
.try_acquire_owned()
.map_err(|_| failure("Plugin invocation capacity exceeded"))?;
let lease = self.start(&context)?;
// The persisted Session and provider call ID survive Runtime restoration;
// the engine-local batch ID identifies only cancellation of a live attempt.
let id_bytes = serde_json::to_vec(&(&self.session_id, &context.call_id))
.expect("identity serialization");
let hex: String = Sha256::digest(&id_bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let call_id = format!("tool-{hex}");
let invocation = InvocationContext {
workspace: self.workspace.clone(),
installation: self.installation.clone(),
plan: self.plan.clone(),
subject: self.subject.clone(),
invocation_id: String::new(),
artifact: self.compiled.identity().clone(),
tool_name: self.tool_name.clone(),
grants: self.grants.clone(),
deadline: tokio::time::Instant::now() + self.executor.limits().wall_time,
cancellation: lease.call.cancellation.clone(),
};
let host = Arc::new(
OwnerHost::begin(
self.owner.clone(),
invocation,
server_api::PluginConsumer::ToolCall {
tool_call_id: call_id,
tool_name: self.tool_name.clone(),
},
)
.await
.map_err(|_| failure("Plugin live admission refused or unavailable"))?,
);
*lease.call.host.lock().expect("Plugin call lock") = Some(host.clone());
if lease.call.cancellation.is_cancelled() {
host.cancel()
.await
.map_err(|_| failure("Plugin cancellation requires reconciliation"))?;
return Err(failure("Plugin invocation cancelled"));
}
let output = self
.executor
.invoke(
&self.compiled,
host.context().clone(),
host.clone(),
&self.tool_name,
input_json,
&self.config_json,
)
.await
.map_err(|_| failure("Plugin invocation failed; no automatic retry"))?
.output;
// Fence again at the adapter publication boundary, not just on guest return.
host.authorize(host.context(), Operation::Publish)
.await
.map_err(|_| failure("Plugin result publication refused"))?;
let content = match output.structured_json {
Some(structured) => Some(serde_json::json!({
"content": output.content,
"structured": serde_json::from_str::<Value>(&structured).map_err(|_| failure("Plugin output is invalid"))?,
}).to_string()),
None => output.content,
};
Ok(ToolOutput {
summary: output.summary,
content,
attachments: Vec::new(),
})
}
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
let call = self
.calls
.lock()
.expect("Plugin calls lock")
.active
.get(&context.execution_id())
.cloned();
if let Some(call) = call {
call.cancel().await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests;
+676
View File
@@ -0,0 +1,676 @@
//! Component coverage through canonical T-791 packing, real ArtifactCache/Executor,
//! Feature installation and the Engine Tool registry. Only ArtifactSource and
//! PluginHostOwner are scripted: this is not a full Server/HTTPS vertical test,
//! nor proof of owner-side durable storage. Cancellation checks the exact owner
//! Cancel request and acknowledgement, not an inferred effect rollback.
//! Run with `cargo test -p worker --lib plugin::tests::`; no guest build is needed.
use super::*;
use crate::feature::FeatureRegistryBuilder;
use crate::hook::{HookInvocationContext, HookRegistry, HookRegistryBuilder};
use agen::Engine;
use agen::llm_client::client::LlmClient;
use agen::llm_client::{ClientError, Request, ResponseStream};
use agen::state::Mutable;
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
use serde_json::json;
use server_api::{
PluginArtifact, PluginArtifactDeliveryMetadata, PluginArtifactDeliveryRequest, PluginConsumer,
PluginEffectObservation, PluginHostError, PluginHostErrorCode, PluginHostOperation,
PluginHostRequest, PluginHostResponse, PluginSchemaContribution,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use yoi_plugin_runtime::delivery::{ArtifactStream, CacheLimits, DeliveryError};
const MANIFEST: &str =
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml");
const COMPONENT: &[u8] =
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm");
const TOOL_A: &str = "installation_a__example_echo";
const TOOL_C: &str = "installation_c__example_echo";
const INPUT: &str = r#"{"text":"hello from a Component"}"#;
struct Source {
artifact: PluginArtifact,
bytes: Vec<u8>,
fetches: AtomicUsize,
authorizations: AtomicUsize,
}
#[async_trait]
impl ArtifactSource for Source {
fn server_authority_identity(&self) -> &str {
"component-fixture-owner"
}
async fn authorize(
&self,
request: &PluginArtifactDeliveryRequest,
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
self.authorizations.fetch_add(1, Ordering::SeqCst);
assert_eq!(request.artifact_digest, self.artifact.artifact_digest);
Ok(PluginArtifactDeliveryMetadata {
workspace_id: "workspace-a".into(),
request: request.clone(),
artifact: self.artifact.clone(),
})
}
async fn fetch(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<ArtifactStream, DeliveryError> {
self.fetches.fetch_add(1, Ordering::SeqCst);
Ok(Box::pin(std::io::Cursor::new(self.bytes.clone())))
}
}
struct Fixture {
_directory: tempfile::TempDir,
source: Source,
cache: ArtifactCache,
}
impl Fixture {
fn new() -> Self {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("plugin.toml"), MANIFEST).unwrap();
std::fs::write(directory.path().join("plugin.component.wasm"), COMPONENT).unwrap();
let limits = PluginPackageLimits::default();
let package =
plugin::read_plugin_directory(directory.path(), PluginSourceKind::Explicit, &limits)
.unwrap();
let packed = plugin::pack_plugin_package(&package, &limits).unwrap();
let m = package.package.manifest;
let schema_hex: String = Sha256::digest(m.config_schema.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let schema_digest = format!("sha256:{schema_hex}");
let artifact = PluginArtifact {
package_id: m.id.clone(),
name: m.name,
version: m.version.clone(),
description: m.description,
package_digest: packed.package_digest,
artifact_digest: packed.artifact_digest,
artifact_size: packed.artifact_size.try_into().unwrap(),
schema_version: m.schema_version,
world: m.runtime.world,
required_imports: m.runtime.required_imports,
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
connections: vec![],
requested_grants: vec![],
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,
},
revoked: false,
revocation_reason: None,
};
let cache =
ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap();
Self {
_directory: directory,
source: Source {
artifact,
bytes: packed.bytes,
fetches: AtomicUsize::new(0),
authorizations: AtomicUsize::new(0),
},
cache,
}
}
async fn prepare(
&self,
installations: &[&str],
owner: Arc<Owner>,
session: &str,
) -> PluginToolsFeature {
let pins = installations
.iter()
.map(|installation| PluginToolPin {
workspace_id: "workspace-a".into(),
installation_id: (*installation).into(),
plan_id: "persisted-plan".into(),
subject: "worker-a".into(),
request: PluginArtifactDeliveryRequest {
plan_id: "persisted-plan".into(),
plan_fingerprint: "persisted-fingerprint".into(),
installation_id: (*installation).into(),
artifact_digest: self.source.artifact.artifact_digest.clone(),
},
expectation: ArtifactExpectation::new(self.source.artifact.clone()),
tools: vec!["example_echo".into()],
config: json!({}),
grants: Grants::default(),
})
.collect();
PluginToolsFeature::prepare(
pins,
&self.source,
&self.cache,
owner,
session,
Limits::default(),
)
.await
.unwrap()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Behavior {
Allow,
DenyBegin,
DenyAdmit,
DenyLatePublish,
HoldAdmit,
}
struct Owner {
behavior: Behavior,
requests: Mutex<Vec<PluginHostRequest>>,
entered: Semaphore,
}
impl Owner {
fn new(behavior: Behavior) -> Arc<Self> {
Arc::new(Self {
behavior,
requests: Mutex::new(Vec::new()),
entered: Semaphore::new(0),
})
}
fn requests(&self) -> Vec<PluginHostRequest> {
self.requests.lock().unwrap().clone()
}
fn consumers(&self) -> Vec<PluginConsumer> {
self.requests()
.into_iter()
.filter_map(|request| match request {
PluginHostRequest::Begin { consumer, .. } => Some(consumer),
_ => None,
})
.collect()
}
fn publications(&self) -> usize {
self.requests()
.iter()
.filter(|request| {
matches!(
request,
PluginHostRequest::Authorize {
operation: PluginHostOperation::Publish,
..
}
)
})
.count()
}
}
fn denied() -> PluginHostError {
PluginHostError {
code: PluginHostErrorCode::Denied,
effect: PluginEffectObservation::NotDispatched,
diagnostic_id: "component-fixture-denial".into(),
}
}
#[async_trait]
impl PluginHostOwner for Owner {
async fn call(
&self,
request: PluginHostRequest,
) -> Result<PluginHostResponse, PluginHostError> {
self.requests.lock().unwrap().push(request.clone());
match request {
PluginHostRequest::Begin {
plan_id,
installation_id,
consumer:
PluginConsumer::ToolCall {
tool_call_id,
tool_name,
},
} => {
assert_eq!(plan_id, "persisted-plan");
assert!(matches!(
installation_id.as_str(),
"installation_a" | "installation_c"
));
assert_eq!(tool_name, "example_echo");
let invocation_id = format!("owner-{installation_id}-{tool_call_id}");
// This fixture scripts owner rejection of an already cancelled
// consumer; the production owner persistence belongs to its suite.
let cancelled = self.requests().iter().any(|request| {
matches!(request, PluginHostRequest::Cancel { invocation_id: id } if id == &invocation_id)
});
if self.behavior == Behavior::DenyBegin || cancelled {
return Err(denied());
}
Ok(PluginHostResponse::Invocation {
invocation_id,
expires_at_ms: (SystemTime::now().duration_since(UNIX_EPOCH).unwrap()
+ Duration::from_secs(60))
.as_millis()
.try_into()
.unwrap(),
})
}
PluginHostRequest::Authorize { operation, .. } => {
if operation == PluginHostOperation::Admit {
if self.behavior == Behavior::DenyAdmit {
return Err(denied());
}
if self.behavior == Behavior::HoldAdmit {
self.entered.add_permits(1);
std::future::pending::<()>().await;
}
}
// Executor publication succeeds; only the adapter's final
// publication fence is denied after the guest has returned.
if operation == PluginHostOperation::Publish
&& self.behavior == Behavior::DenyLatePublish
&& self.publications() == 2
{
return Err(denied());
}
Ok(PluginHostResponse::Authorized)
}
PluginHostRequest::Cancel { .. } => Ok(PluginHostResponse::Authorized),
other => panic!("pure Component must not request Host effects: {other:?}"),
}
}
}
#[derive(Clone)]
struct NoLlm;
#[async_trait]
impl LlmClient for NoLlm {
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
panic!("tools are driven through the Engine registry, without an LLM")
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
fn install(feature: PluginToolsFeature) -> (Engine<NoLlm, Mutable, ()>, HookRegistry) {
let mut engine = Engine::new_annotated(NoLlm);
let mut hooks = HookRegistryBuilder::new();
let mut registry = FeatureRegistryBuilder::new();
registry.add_module(feature);
let report = registry.install_into_engine(&mut engine, &mut hooks);
assert!(!report.has_errors(), "{}", report.error_message());
engine.tool_server_handle().flush_pending();
(engine, hooks.build())
}
fn context(call: &str, batch: &str) -> ToolExecutionContext {
ToolExecutionContext::new(call, batch, 0)
}
fn stopping() -> WorkerStoppingContext {
WorkerStoppingContext {
invocation: HookInvocationContext {
workspace_id: Some("workspace-a".into()),
worker_id: "worker-a".into(),
session_id: "session-a".into(),
..Default::default()
},
reason: "component test stop".into(),
}
}
#[tokio::test]
async fn selected_installations_publish_only_namespaced_tools_and_manifest_schema() {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::Allow);
// The same package is available to A/B/C, but only persisted A/C pins are selected.
let feature = fixture
.prepare(
&["installation_a", "installation_c"],
owner.clone(),
"session-a",
)
.await;
let (engine, _) = install(feature);
let tools = engine.tool_server_handle();
let definitions = tools.tool_definitions_sorted();
assert_eq!(
definitions
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
[TOOL_A, TOOL_C]
);
for (installation, name) in [("installation_a", TOOL_A), ("installation_c", TOOL_C)] {
let (meta, _) = tools.get_tool(name).unwrap();
assert_eq!(
meta.input_schema,
fixture.source.artifact.tools[0].input_schema
);
assert_eq!(meta.input_schema["required"], json!(["text"]));
assert_eq!(meta.input_schema["additionalProperties"], false);
let origin = meta.origin.unwrap();
assert_eq!(origin.kind, "plugin");
assert_eq!(origin.plugin_ref, installation);
assert_eq!(origin.plugin_id, fixture.source.artifact.package_id);
assert_eq!(origin.digest, fixture.source.artifact.package_digest);
assert_eq!(origin.surface, "tool");
}
for absent in ["example_echo", "installation_b__example_echo"] {
assert!(tools.get_tool(absent).is_none());
assert!(
tools
.call_tool(absent, INPUT, context("missing", "batch"))
.await
.is_err()
);
}
assert!(
owner.requests().is_empty(),
"registration must not admit a call"
);
assert_eq!(fixture.source.fetches.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn pure_component_returns_normal_tool_output_and_structured_json() {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::Allow);
let feature = fixture
.prepare(&["installation_a"], owner.clone(), "session-a")
.await;
let (engine, _) = install(feature);
let tools = engine.tool_server_handle();
let output = tools
.call_tool(TOOL_A, INPUT, context("call-echo", "batch-a"))
.await
.unwrap();
assert_eq!(output.summary, "example_echo echoed text");
assert!(output.attachments.is_empty());
let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(
output["structured"],
json!({"tool":"example_echo", "text":"hello from a Component"})
);
assert!(output["content"].is_null());
assert_eq!(
owner.publications(),
2,
"executor and adapter each fence publication"
);
assert!(
tools
.call_tool(TOOL_A, r#"{"text":""}"#, context("empty", "batch-a"))
.await
.is_err()
);
assert_eq!(
owner.publications(),
3,
"guest error passes the executor fence but never publishes through the adapter"
);
}
#[tokio::test]
async fn pure_component_owner_denial_applies_to_cold_and_cached_artifacts() {
for behavior in [Behavior::DenyBegin, Behavior::DenyAdmit] {
let fixture = Fixture::new();
for session in ["cold", "cache-hit"] {
let authorizations = fixture.source.authorizations.load(Ordering::SeqCst);
let owner = Owner::new(behavior);
let feature = fixture
.prepare(&["installation_a"], owner.clone(), session)
.await;
let (engine, _) = install(feature);
let result = engine
.tool_server_handle()
.call_tool(TOOL_A, INPUT, context("denied", "batch"))
.await;
assert!(
result.is_err(),
"{session}: pure code is not an execution lease"
);
assert_eq!(owner.consumers().len(), 1);
assert_eq!(owner.publications(), 0);
let admits = owner
.requests()
.iter()
.filter(|request| {
matches!(
request,
PluginHostRequest::Authorize {
operation: PluginHostOperation::Admit,
..
}
)
})
.count();
assert_eq!(admits, usize::from(behavior == Behavior::DenyAdmit));
assert_eq!(
fixture.source.fetches.load(Ordering::SeqCst),
1,
"{session}: second preparation must use the verified cache entry"
);
assert!(fixture.source.authorizations.load(Ordering::SeqCst) > authorizations);
}
}
}
#[tokio::test]
async fn adapter_late_publish_denial_withholds_successful_guest_output() {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::DenyLatePublish);
let feature = fixture
.prepare(&["installation_a"], owner.clone(), "session-a")
.await;
let (engine, _) = install(feature);
let error = engine
.tool_server_handle()
.call_tool(TOOL_A, INPUT, context("late", "batch"))
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("Plugin result publication refused")
);
assert_eq!(
owner.publications(),
2,
"guest/executor fence passed before adapter denial"
);
assert_eq!(
owner.consumers().len(),
1,
"publication refusal must not retry the call"
);
}
#[tokio::test]
async fn consumer_identity_survives_batch_changes_but_isolates_sessions_and_calls() {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::Allow);
// Re-preparation simulates a restored adapter, not just reuse of a live Tool.
for (session, call, batch) in [
("session-a", "provider-call", "batch-before-restore"),
("session-a", "provider-call", "batch-after-restore"),
("session-b", "provider-call", "batch-after-restore"),
("session-a", "different-call", "batch-after-restore"),
] {
let feature = fixture
.prepare(&["installation_a"], owner.clone(), session)
.await;
let (engine, _) = install(feature);
engine
.tool_server_handle()
.call_tool(TOOL_A, INPUT, context(call, batch))
.await
.unwrap();
}
let consumers = owner.consumers();
assert_eq!(consumers[0], consumers[1]);
assert_ne!(consumers[0], consumers[2]);
assert_ne!(consumers[0], consumers[3]);
}
struct FailInstall;
impl FeatureModule for FailInstall {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("late-failure", "Injected install failure")
}
fn install(&self, _: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
Err(FeatureInstallError::Install(
"injected after Plugin staging".into(),
))
}
}
#[tokio::test]
async fn late_install_failure_rolls_back_plugin_tools_and_stop_hook_only() {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::Allow);
let mut engine: Engine<NoLlm, Mutable, ()> = Engine::new_annotated(NoLlm);
let mut hooks = HookRegistryBuilder::new();
let mut existing = FeatureRegistryBuilder::new();
existing.add_module(
fixture
.prepare(&["installation_c"], owner.clone(), "existing")
.await,
);
let report = existing.install_into_engine(&mut engine, &mut hooks);
assert!(!report.has_errors(), "{}", report.error_message());
engine.tool_server_handle().flush_pending();
let candidate = fixture
.prepare(&["installation_a"], owner.clone(), "candidate")
.await;
let mut registry = FeatureRegistryBuilder::new();
registry.add_module(candidate.clone());
registry.add_module(FailInstall);
let report = registry.install_into_engine(&mut engine, &mut hooks);
assert!(report.has_errors());
assert!(
report.plan_error.is_none(),
"failure must happen during installation"
);
assert!(
report
.reports
.iter()
.all(|report| !report.installed && report.installed_tools.is_empty())
);
let tools = engine.tool_server_handle();
tools.flush_pending();
assert!(tools.get_tool(TOOL_C).is_some());
assert!(tools.get_tool(TOOL_A).is_none());
assert!(owner.requests().is_empty());
hooks.build().on_worker_stopping(&stopping()).await.unwrap();
assert!(
tools
.call_tool(TOOL_C, INPUT, context("existing", "batch"))
.await
.is_err(),
"preexisting stop hook must survive rollback"
);
let (retry, _) = install(candidate);
retry
.tool_server_handle()
.call_tool(TOOL_A, INPUT, context("retry", "batch"))
.await
.unwrap();
// A leaked staged stop hook would have stopped the candidate before this call.
assert_eq!(owner.consumers().len(), 1);
}
#[tokio::test]
async fn active_cancel_and_stopping_send_owner_cancel_and_refuse_reentry() {
for stop in [false, true] {
let fixture = Fixture::new();
let owner = Owner::new(Behavior::HoldAdmit);
let feature = fixture
.prepare(&["installation_a"], owner.clone(), "session-a")
.await;
let (engine, hooks) = install(feature);
let tools = engine.tool_server_handle();
let (_, tool) = tools.get_tool(TOOL_A).unwrap();
let call_context = context("active", "batch-a");
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
tokio::join!(
tools.call_tool(TOOL_A, INPUT, call_context.clone()),
async {
owner.entered.acquire().await.unwrap().forget();
if stop {
hooks.on_worker_stopping(&stopping()).await.unwrap();
} else {
tool.cancel_execution(&call_context).await.unwrap();
}
}
)
})
.await
.expect("cancellation must settle the active owner wait");
assert!(result.is_err());
let requests = owner.requests();
let cancels = requests
.iter()
.filter_map(|request| match request {
PluginHostRequest::Cancel { invocation_id } => Some(invocation_id),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
cancels.len(),
1,
"local cancellation must also reach the owner"
);
let consumers = owner.consumers();
let PluginConsumer::ToolCall { tool_call_id, .. } = &consumers[0] else {
panic!("expected a Tool-call consumer")
};
assert_eq!(cancels[0], &format!("owner-installation_a-{tool_call_id}"));
assert_eq!(owner.publications(), 0);
let begins = consumers.len();
// A fresh batch cannot revive the cancelled stable consumer. The fake
// owner denies it; stopping instead fences all new calls locally.
let retry = tokio::time::timeout(
Duration::from_secs(5),
tools.call_tool(TOOL_A, INPUT, context("active", "batch-b")),
)
.await
.expect("cancelled consumer must not reenter admission");
assert!(retry.is_err());
assert_eq!(owner.consumers().len(), begins + usize::from(!stop));
if stop {
assert!(
tools
.call_tool(TOOL_A, INPUT, context("new-call", "batch-b"))
.await
.is_err()
);
assert_eq!(
owner.consumers().len(),
begins,
"stopped Tool must not ask Begin"
);
}
}
}
+30
View File
@@ -4232,6 +4232,21 @@ where
&self.worker().manifest
}
/// Stable Session identity used to correlate restored Plugin Tool calls.
pub fn session_id(&self) -> SessionId {
self.worker().session_id()
}
/// Bind exact Host-prepared contributions without exposing execution or
/// reopening a candidate whose cleanup has begun.
pub fn bind_plugin_tools(
&mut self,
tools: crate::plugin::PluginToolsFeature,
) -> Result<(), WorkerError> {
self.worker_mut()?.bind_plugin_tools(tools);
Ok(())
}
/// Retain the same Workdir resource owner for explicit cleanup, including
/// after discard fails. This does not grant Worker input or execution access.
pub fn workdir_cleanup(&self) -> RestoreCandidateWorkdirCleanup {
@@ -4485,6 +4500,7 @@ pub struct Worker<C: LlmClient, St: Store> {
/// Host-owned projection of Worker sessions explicitly granted to this Worker.
/// The provider reauthorizes every capture and never derives authority from model input.
worker_observation_provider: Option<Arc<dyn WorkerObservationProvider>>,
plugin_tools: Option<crate::plugin::PluginToolsFeature>,
/// Parsed system-prompt template awaiting first-turn materialisation.
/// Retained until the complete initial seed and active metadata are durable.
system_prompt_template: Option<SystemPromptTemplate>,
@@ -4853,6 +4869,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
tracker: None,
task_feature: TaskFeature::new(),
worker_observation_provider: None,
plugin_tools: None,
system_prompt_template: None,
feature_instructions: Vec::new(),
alerter: None,
@@ -5367,6 +5384,16 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
}
}
/// Bind Host-prepared exact Plugin contributions before controller start.
/// This handle is never persisted in a Manifest or inherited by a SubWorker.
pub fn bind_plugin_tools(&mut self, tools: crate::plugin::PluginToolsFeature) {
self.plugin_tools = Some(tools);
}
pub(crate) fn plugin_tools(&self) -> Option<crate::plugin::PluginToolsFeature> {
self.plugin_tools.clone()
}
/// Install enabled feature modules into the Worker host surfaces.
pub fn install_features(
&mut self,
@@ -9579,6 +9606,7 @@ where
tracker: None,
task_feature: TaskFeature::new(),
worker_observation_provider: None,
plugin_tools: None,
system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions,
alerter: None,
@@ -9987,6 +10015,7 @@ where
tracker: None,
task_feature: TaskFeature::new(),
worker_observation_provider: None,
plugin_tools: None,
system_prompt_template: common.system_prompt_template,
feature_instructions: common.feature_instructions,
alerter: None,
@@ -10452,6 +10481,7 @@ where
tracker: None,
task_feature,
worker_observation_provider: None,
plugin_tools: None,
// Restore replays the saved system_prompt verbatim — no
// template re-render on resume.
system_prompt_template: None,