merge: integrate orchestration branch

This commit is contained in:
Keisuke Hirata 2026-06-17 14:41:20 +09:00
commit cce36419ac
No known key found for this signature in database
11 changed files with 1230 additions and 61 deletions

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260615-155415-1","ticket_id":"00001KV5W3PHA","kind":"accepted_plan","accepted_plan":{"summary":"Accept Plugin Tool surface registration boundary work. Register enabled Plugin Tool schemas into ToolRegistry with origin metadata, duplicate/schema diagnostics, feature/profile hiding, and runtime-missing safe errors without implementing WASM execution.","branch":"impl/00001KV5W3PHA-plugin-tool-surface","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5W3PHA-plugin-tool-surface","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Dependency `00001KV5R5V2S` is done and current workspace is clean."},"author":"yoi-orchestrator","at":"2026-06-15T15:54:15Z"}

View File

@ -1,8 +1,8 @@
--- ---
title: 'Plugin: register enabled Tool surface from packages' title: 'Plugin: register enabled Tool surface from packages'
state: 'queued' state: 'done'
created_at: '2026-06-15T14:48:59Z' created_at: '2026-06-15T14:48:59Z'
updated_at: '2026-06-15T15:53:32Z' updated_at: '2026-06-15T16:39:58Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['plugin', 'tool-registry', 'model-visible-schema', 'capability-boundary', 'profile-config'] risk_flags: ['plugin', 'tool-registry', 'model-visible-schema', 'capability-boundary', 'profile-config']

View File

@ -13,4 +13,390 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T15:54:15Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により routing が明示的に許可され、Ticket は `queued`
- Ticket body / thread / relations / OrchestrationPlan / Orchestrator workspace state を確認した。
- Outgoing dependency `00001KV5R5V2S``done` で、typed relation blocker は解消済み。
- 本 Ticket は resolved Plugin metadata を入力にした Tool surface registration boundary の実装であり、Plugin code execution / WASM runtime / permission grant enforcement は non-goal として明確。
- capability-boundary / model-visible-schema / tool-registry risk は高いが、acceptance criteria と fail-closed invariants が具体的で、残る不確実性は typed metadata / registry integration tactic に閉じている。
Evidence checked:
- Ticket body/thread: requirements、acceptance criteria、non-goals、related work を確認。
- Ticket relations: depends_on `00001KV5R5V2S` は done。incoming dependency from runtime Ticket `00001KV5W3PHW` は本 Ticket の blocker ではない。
- OrchestrationPlan: 既存 record なし。
- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`1fdb4cd6` 上。
- Visible Pods: implementation child Pod なし。
IntentPacket:
Intent:
- Enabled Plugin package の manifest Tool surface を読み取り、既存 `ToolRegistry` の model-visible schema 経路に安全に登録できる registration boundary を作る。ただし Tool call execution / WASM runtime はまだ実装しない。
Binding decisions / invariants:
- Discovery-only package は Tool schema surface に出さない。explicit enablement が必要。
- Tool registration は existing `ToolRegistry` 原則に従い、feature/profile config disabled なら model-visible schema から消える。
- Plugin Tool metadata に originplugin id/ref、source、digest、version/api、surfaceを保持する。
- Duplicate Tool name は fail closed。builtin Tool / other Plugin Tool との衝突を曖昧に勝たせない。
- Invalid/unsupported input schema は fail closed。
- Runtime executor missing の Tool call は panic せず安全な unavailable/runtime-missing error を返す。
- Plugin code execution / WASM runtime / host API / permission grant enforcement / Service/Ingress/MCP bridge は non-goal。
- Permission declarations/grants を実効権限として扱わない。registration-time shape checks だけ。
Requirements / acceptance criteria:
- Enabled Plugin package の Tool definition が `ToolRegistry` に登録され、model-visible tools に現れる。
- Enablement がない Plugin package の Tool は model-visible tools に現れない。
- Duplicate Tool name / builtin collision は登録されず diagnostic で理由が分かる。
- Invalid input schema は登録されず diagnostic で理由が分かる。
- Registered Plugin Tool metadata から plugin origin / digest / source を追跡できる。
- Feature/profile flag により Plugin Tool surface を非表示にできる。
- Tool call が未実行状態でも panic せず unavailable/runtime-missing error。
- Tests cover enabled registration、no enablement inactive、duplicate Plugin Tool name、builtin collision、invalid schema、origin metadata、disabled feature/profile surface hiding。
Implementation latitude:
- Plugin manifest Tool definition schema の最小 shape は既存 design / resolver の strict parser に合わせてよい。
- Existing ToolRegistry contribution path に合わせた adapter / feature contribution module を追加してよい。
- Runtime-missing handler は minimal stub でよいが、model-visible registration と call failure behavior は明確にテストする。
- Diagnostics の storage/reporting surface は existing resolver diagnostics と整合させる。
Escalate if:
- ToolRegistry API が origin metadata を保持できず public API redesign が必要。
- Feature/profile config で Plugin Tool surface を安全に hide できない。
- JSON schema validation requires broader schema engine choice。
- Runtime-missing Tool stub が model/provider protocol に悪影響を与える。
- Permission grants / WASM executor / host API を実装しないと registration boundary が成立しない。
Validation:
- focused plugin/tool-registry tests。
- relevant `cargo test` / `cargo check`
- `cargo fmt --check`
- `git diff --check`
- `nix build .#yoi` if dependencies, Cargo.lock, package resources, or packaging correctness change。
Critical risks / reviewer focus:
- model-visible schema gating。
- duplicate/builtin collision fail closed。
- no enablement → no schema。
- origin metadata traceability。
- no execution/registration side effects beyond schema contribution。
- unavailable runtime call safety。
- permission declaration vs effective grant separation。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T15:54:24Z from: queued to: inprogress reason: orchestrator_acceptance_dependency_done field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。dependency `00001KV5R5V2S` は done で relation blocker は解消済み。Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV5W3PHA at: 2026-06-15T16:20:05Z -->
## Implementation report
Implementation report from Coder Pod `yoi-coder-00001KV5W3PHA`:
Commit:
- `05a9c522 feat: register plugin tool surfaces`
Changed files:
- `crates/manifest/src/plugin.rs`
- `crates/manifest/src/lib.rs`
- `crates/manifest/src/config.rs`
- `crates/llm-worker/src/tool.rs`
- `crates/pod/src/feature.rs`
- `crates/pod/src/feature/plugin.rs`
- `crates/pod/src/controller.rs`
- `crates/pod/src/pod.rs`
Implemented behavior:
- Added minimal Plugin package `tools` manifest surface parsing:
- `name`
- `description`
- `input_schema`
- Added `feature.plugins` profile/manifest gate, default disabled.
- Added Plugin Tool feature adapter:
- only enabled resolved Plugin packages with enabled Tool surface register schemas;
- package discovery alone does not register Tools;
- disabled profile feature suppresses Plugin Tool schema surface.
- Registered Plugin Tool definitions into existing ToolRegistry/model-visible schema path.
- Added host-side `ToolOrigin` metadata on `ToolMeta`:
- plugin id/ref
- source
- digest
- package version/API
- `surface = "tool"`
- Added runtime-missing stub executor:
- Tool schema is visible when registered;
- actual calls return safe unavailable/runtime-missing error;
- no Plugin code execution or WASM runtime added.
- Added registration-time fail-closed checks:
- duplicate Plugin Tool names;
- collision with already registered builtin Tool names;
- invalid/unsupported input schemas.
- Adjusted feature install ordering so builtin/pending tools are flushed before Plugin feature registration, allowing Plugin collisions against already registered Tool names to be diagnosed instead of reaching later duplicate panic.
Diagnostics behavior:
- Duplicate Tool contributions produce feature install diagnostics and skip duplicate registration.
- Invalid Plugin input schema produces feature install diagnostics and registers no stub for that invalid Tool.
- Runtime calls before executor support return safe unavailable/runtime-missing `ToolError`.
Validation reported by coder:
- Passed: `cargo test -p manifest plugin --no-default-features`
- Passed: `cargo test -p pod plugin::tests --no-default-features`
- Passed: `cargo check -p pod -p manifest -p llm-worker`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
- Attempted broader validation: `cargo test -p pod --no-default-features`
- Failed in existing prompt text assertions unrelated to Plugin Tool surface implementation:
- `prompt::tests::default_subagent_prompt_matches_resource`
- `prompt::tests::subagent_prompt_treats_paths_as_data`
Not run:
- `nix build .#yoi` — no dependency, `Cargo.lock`, resource, or packaging changes.
Residual risks / blockers:
- Plugin executor is intentionally runtime-missing stub; actual WASM/runtime execution remains for later Ticket.
- Input schema validation is intentionally a narrow model-visible shape check, not a full JSON Schema engine. Unsupported composition/reference keywords are rejected fail-closed.
---
<!-- event: review author: yoi-reviewer-00001KV5W3PHA at: 2026-06-15T16:26:34Z status: request_changes -->
## Review: request changes
Review result: request_changes
Blocker:
- Invalid nested Plugin Tool schemas can still become model-visible.
Evidence:
- `crates/pod/src/feature/plugin.rs` validates root-level schema shape and recursively rejects only selected unsupported keywords.
- It does not validate that nested schema nodes are valid JSON Schema objects.
- Example invalid schema that appears to pass current validation:
```toml
input_schema = {
type = "object",
properties = { query = "not-a-schema" },
required = ["query"],
additionalProperties = false
}
```
Why this is a blocker:
- `properties.query = "not-a-schema"` is not a valid schema node.
- Current checks can allow it into ToolRegistry / model-visible tool schema path.
- This violates the acceptance criteria that invalid input schema is rejected with diagnostic and the critical focus on model-visible schema gating.
Required changes:
- Strengthen Plugin Tool schema validation before registration so invalid nested schemas fail closed with diagnostics.
- Add regression tests showing nested invalid schema nodes do not register a Plugin Tool.
- Prefer defining/enforcing an explicit supported subset recursively, for example:
- validate property schemas;
- validate allowed `type` values;
- validate `required` string/unique array;
- validate or reject `items` / `additionalProperties` recursively;
- reject unknown/problematic keywords unless explicitly supported.
Positive evidence checked:
- Plugin tool registration is feature-gated by `feature_config.plugins.enabled`.
- Discovery-only packages do not register tools because registration uses resolved enabled Plugin records/surfaces.
- Registered Plugin Tools preserve `ToolOrigin::Plugin` metadata.
- Builtin collisions and duplicate Plugin Tool names are diagnosed/rejected.
- Runtime execution is a safe unavailable/runtime-missing stub.
- No Plugin code execution, WASM runtime, host API, Service/Ingress/MCP bridge, or permission grant enforcement was added.
- `ToolOrigin` is not exposed directly to model-visible schema.
Validation performed by reviewer:
- Passed: `git diff --check fcae8860..05a9c522`
- Passed: `cargo fmt --check`
- Worktree remained clean.
Validation not run:
- `cargo test` / `cargo check` were not rerun because reviewer scope was read-only.
Conclusion:
- Changes requested. Do not integrate until nested schema validation is fail-closed and covered.
---
<!-- event: implementation_report author: yoi-coder-00001KV5W3PHA at: 2026-06-15T16:30:49Z -->
## Implementation report
Follow-up implementation report after reviewer request_changes:
New commit:
- `3413bae7 fix: reject nested plugin tool schema errors`
Changed file:
- `crates/pod/src/feature/plugin.rs`
Schema validation behavior updated:
- Replaced shallow root-only validation with recursive validation of an explicit supported subset.
- Every schema node must be a JSON object with supported string `type`.
- Supported `type` values:
- `object`
- `array`
- `string`
- `number`
- `integer`
- `boolean`
- `null`
- Supported keywords:
- `type`
- `title`
- `description`
- `properties`
- `required`
- `additionalProperties`
- `items`
- `enum`
Recursive/fail-closed behavior:
- `properties.*` values are recursively validated as schema objects.
- `items` is only allowed on array schemas and recursively validates a schema object.
- `additionalProperties` is only allowed on object schemas and must be boolean or recursively valid schema object.
- Non-object nested schema nodes reject registration.
- Unknown/problematic keywords reject registration.
- Unsupported `type` values reject registration.
- `required` must be an array of unique strings.
- `required` entries must be declared in `properties` when `properties` exists.
- `enum` must be non-empty array with unique entries.
Regression tests added:
- Direct validation rejects:
- `properties.query = "not-a-schema"`
- invalid recursive `items`
- invalid recursive `additionalProperties`
- duplicate `required` entries
- Registration test confirms nested invalid schema does not register a Plugin Tool and emits an `invalid input_schema` diagnostic with nested path.
Validation reported by coder:
- Passed: `cargo test -p pod plugin::tests --no-default-features`
- Passed: `cargo test -p manifest plugin --no-default-features`
- Passed: `cargo check -p pod -p manifest -p llm-worker`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Repository status:
- Child implementation worktree clean after follow-up commit.
---
<!-- event: review author: yoi-reviewer-00001KV5W3PHA-r2 at: 2026-06-15T16:38:37Z status: approve -->
## Review: approve
Review result: approve
Second-pass blocker verification: resolved.
Evidence checked:
- Child worktree:
- `/home/hare/Projects/yoi/.worktree/00001KV5W3PHA-plugin-tool-surface`
- branch `impl/00001KV5W3PHA-plugin-tool-surface`
- HEAD `3413bae7 fix: reject nested plugin tool schema errors`
- Fix diff `05a9c522..3413bae7`.
- Relevant implementation:
- `crates/pod/src/feature/plugin.rs`
- `crates/pod/src/feature.rs`
- `crates/pod/src/controller.rs`
- `crates/llm-worker/src/tool.rs`
- `crates/llm-worker/src/tool_server.rs`
- `crates/manifest/src/plugin.rs`
Blocker-specific findings:
- Nested invalid schemas are now rejected recursively.
- `validate_schema_node` requires every schema node to be a JSON object with supported string `type`.
- `properties` child schemas are recursively validated.
- `items` is only accepted on array schemas and recursively validated.
- `additionalProperties` is only accepted on object schemas and must be boolean or recursively valid schema object.
- `required` must be a unique string array and, when `properties` exists, entries must be declared.
- `enum` must be non-empty and unique.
- Unknown keywords are rejected before registration.
- The prior concrete failure case `properties.query = "not-a-schema"` now fails with path-bearing diagnostic such as `$.properties.query`.
- Regression test `nested_invalid_input_schema_does_not_register_plugin_tool` asserts pending tool registration remains empty and diagnostics include `invalid input_schema`.
- Invalid schemas return `FeatureInstallError::InvalidToolManifest`, recorded as install diagnostic by feature registry.
- No evidence found that invalid nested schemas become model-visible after the fix.
Overall acceptance spot-check:
- Enabled Plugin Tool definitions are wired through normal `FeatureRegistryBuilder` / `ToolRegistry` path.
- Discovery-only or non-tool packages remain inactive through manifest surface filtering.
- Duplicate/builtin collisions are handled by feature registry duplicate checks against registered/pending tool names.
- `ToolOrigin` metadata is preserved on `ToolMeta`.
- Feature/profile disabled plugin support hides Plugin Tool modules through `plugin_tool_features_if_enabled`.
- Runtime-missing calls use `PluginRuntimeMissingTool` and return safe unavailable error.
- No Plugin code execution, WASM runtime, host API, Service/Ingress/MCP bridge, or permission grant enforcement was added.
Validation performed by reviewer:
- Passed: `git diff --check fcae8860..HEAD`
- Passed: `cargo fmt --check`
Validation not rerun by reviewer:
- `cargo test` / `cargo check` were not rerun because reviewer scope was read-only. Coder-reported passing results were inspected as evidence.
Risk assessment:
- Residual risk is mainly integration-level because reviewer did not run cargo tests.
- The fix is focused and directly covers the prior blocker with recursive validation and regression coverage.
Conclusion:
- Approved. No remaining blocker found.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T16:39:57Z -->
## Implementation report
Integration report:
Merged implementation branch into orchestration branch:
- branch: `impl/00001KV5W3PHA-plugin-tool-surface`
- implementation commits:
- `05a9c522 feat: register plugin tool surfaces`
- `3413bae7 fix: reject nested plugin tool schema errors`
- merge commit: `204d0d02 merge: plugin tool surface registration`
Reviewer result:
- Initial review requested changes for nested invalid Plugin Tool schema validation.
- Second-pass review `approve` recorded from `yoi-reviewer-00001KV5W3PHA-r2`.
Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`:
- Passed: `cargo test -p pod plugin::tests --no-default-features`
- Passed: `cargo test -p manifest plugin --no-default-features`
- 15 passed
- Passed: `cargo check -p pod -p manifest -p llm-worker`
- Passed: `cargo fmt --check`
- Passed: `git diff --check`
Notes:
- Broader `cargo test -p pod --no-default-features` was not rerun because coder had already reported existing unrelated prompt text assertion failures outside this change path.
- Plugin Tool registration is feature-gated and enablement-gated.
- Runtime execution remains an unavailable/runtime-missing stub; no Plugin code execution or WASM runtime was added.
- Recursive schema validation now rejects nested invalid schema nodes before model-visible registration.
- Orchestrator worktree is clean after validation.
Cleanup planned:
- Stop related coder/reviewer Pods.
- Remove only child implementation worktree/branch for this Ticket.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T16:39:58Z from: inprogress to: done reason: merged_validated field: state -->
## State changed
Reviewer approved after requested schema validation fix, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch.
--- ---

View File

@ -127,6 +127,31 @@ impl From<String> for ToolOutput {
// ToolMeta - Immutable Meta Information // ToolMeta - Immutable Meta Information
// ============================================================================= // =============================================================================
/// Origin metadata for a registered tool.
///
/// This metadata is intentionally not part of the provider-facing tool schema.
/// It lets host layers audit where a model-visible tool definition came from
/// while keeping execution and permission semantics in the normal Worker path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolOrigin {
/// Origin kind, for example `plugin` or `builtin`.
pub kind: String,
/// Package-local plugin id.
pub plugin_id: String,
/// Source-qualified plugin/package reference when `kind == "plugin"`.
pub plugin_ref: String,
/// Plugin source such as `user`, `project`, or `builtin`.
pub source: String,
/// Resolved package digest.
pub digest: String,
/// Resolved package version.
pub package_version: String,
/// Plugin API/schema version declared by the package.
pub package_api_version: u32,
/// Surface that contributed this tool. Plugin tools use `tool`.
pub surface: String,
}
/// Tool meta information (fixed at registration, immutable) /// Tool meta information (fixed at registration, immutable)
/// ///
/// Generated from `ToolDefinition` factory and does not change after registration with Worker. /// Generated from `ToolDefinition` factory and does not change after registration with Worker.
@ -139,6 +164,8 @@ pub struct ToolMeta {
pub description: String, pub description: String,
/// JSON Schema for arguments /// JSON Schema for arguments
pub input_schema: Value, pub input_schema: Value,
/// Optional host-side origin metadata. This is not exposed to the LLM.
pub origin: Option<ToolOrigin>,
} }
impl ToolMeta { impl ToolMeta {
@ -148,6 +175,7 @@ impl ToolMeta {
name: name.into(), name: name.into(),
description: String::new(), description: String::new(),
input_schema: Value::Object(Default::default()), input_schema: Value::Object(Default::default()),
origin: None,
} }
} }
@ -162,6 +190,12 @@ impl ToolMeta {
self.input_schema = schema; self.input_schema = schema;
self self
} }
/// Set host-side origin metadata.
pub fn origin(mut self, origin: ToolOrigin) -> Self {
self.origin = Some(origin);
self
}
} }
// ============================================================================= // =============================================================================

View File

@ -84,6 +84,8 @@ pub struct FeatureConfigPartial {
pub ticket: Option<TicketFeatureConfigPartial>, pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub ticket_orchestration: Option<FeatureFlagConfigPartial>, pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
} }
impl FeatureConfigPartial { impl FeatureConfigPartial {
@ -99,6 +101,7 @@ impl FeatureConfigPartial {
other.ticket_orchestration, other.ticket_orchestration,
FeatureFlagConfigPartial::merge, FeatureFlagConfigPartial::merge,
), ),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
} }
} }
} }
@ -152,6 +155,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.ticket_orchestration .ticket_orchestration
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
} }
} }
} }
@ -199,6 +206,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
pods: Some(value.pods.into()), pods: Some(value.pods.into()),
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
ticket_orchestration: Some(value.ticket_orchestration.into()), ticket_orchestration: Some(value.ticket_orchestration.into()),
plugins: Some(value.plugins.into()),
} }
} }
} }

View File

@ -107,6 +107,8 @@ pub struct FeatureConfig {
pub ticket: TicketFeatureConfig, pub ticket: TicketFeatureConfig,
#[serde(default)] #[serde(default)]
pub ticket_orchestration: FeatureFlagConfig, pub ticket_orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
} }
impl Default for FeatureConfig { impl Default for FeatureConfig {
@ -118,6 +120,7 @@ impl Default for FeatureConfig {
pods: FeatureFlagConfig::disabled(), pods: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(), ticket_orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
} }
} }
} }

View File

@ -188,14 +188,19 @@ pub struct PluginPackageManifest {
pub runtime: Option<PluginRuntimeManifest>, pub runtime: Option<PluginRuntimeManifest>,
#[serde(default)] #[serde(default)]
pub hooks: Vec<PluginHookManifest>, pub hooks: Vec<PluginHookManifest>,
#[serde(default)]
pub tools: Vec<PluginToolManifest>,
} }
impl PluginPackageManifest { impl PluginPackageManifest {
fn declared_surfaces(&self) -> BTreeSet<PluginSurface> { pub fn declared_surfaces(&self) -> BTreeSet<PluginSurface> {
let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect(); let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect();
if !self.hooks.is_empty() { if !self.hooks.is_empty() {
surfaces.insert(PluginSurface::Hook); surfaces.insert(PluginSurface::Hook);
} }
if !self.tools.is_empty() {
surfaces.insert(PluginSurface::Tool);
}
if self.runtime.is_some() { if self.runtime.is_some() {
surfaces.insert(PluginSurface::Wasm); surfaces.insert(PluginSurface::Wasm);
} }
@ -218,6 +223,14 @@ pub struct PluginHookManifest {
pub file: String, pub file: String,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginToolManifest {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginDiscoveryLimits { pub struct PluginDiscoveryLimits {
pub max_packages_per_store: usize, pub max_packages_per_store: usize,
@ -1653,6 +1666,29 @@ file = "hooks/summary.md"
); );
} }
#[test]
fn package_manifest_tool_surface_shape_is_accepted() {
let manifest: PluginPackageManifest = toml::from_str(
r#"
schema_version = 1
id = "example.tool"
name = "Example Tool"
version = "0.1.0"
[[tools]]
name = "ExampleTool"
description = "Runs a package-defined tool."
input_schema = { type = "object", properties = { query = { type = "string" } }, required = ["query"], additionalProperties = false }
"#,
)
.unwrap();
assert_eq!(manifest.tools.len(), 1);
assert!(manifest.declared_surfaces().contains(&PluginSurface::Tool));
assert_eq!(manifest.tools[0].name, "ExampleTool");
assert_eq!(manifest.tools[0].input_schema["type"], "object");
}
#[test] #[test]
fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() { fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -634,66 +634,74 @@ where
), ),
); );
} }
let _feature_install_report = pod.install_features(feature_registry); for module in crate::feature::plugin::plugin_tool_features_if_enabled(
feature_config.plugins.enabled,
let worker = pod.worker_mut(); &pod.manifest().plugins,
) {
// Memory tools require both explicit feature exposure and memory storage feature_registry = feature_registry.with_module(module);
// configuration. This keeps resident-memory config separate from the
// model-visible Memory*/Knowledge* tool surface.
if feature_config.memory.enabled {
let mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section",
)
})?;
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root);
let query_cfg = memory::tool::QueryConfig::from(mem);
worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(),
session_id_for_usage,
));
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
} }
// Pod-orchestration tools (SpawnPod + the four comm tools) share {
// the Pod-scoped `SpawnedPodRegistry` (also consumed by the main let worker = pod.worker_mut();
// loop's `PodEvent` handler). Expose them only behind the explicit
// profile feature and require delegation authority up front so enabling // Memory tools require both explicit feature exposure and memory storage
// the surface cannot imply broad child scope by accident. // configuration. This keeps resident-memory config separate from the
if feature_config.pods.enabled { // model-visible Memory*/Knowledge* tool surface.
if spawner_manifest.delegation_scope.allow.is_empty() { if feature_config.memory.enabled {
return Err(std::io::Error::new( let mem = memory_config.as_ref().ok_or_else(|| {
std::io::ErrorKind::InvalidInput, std::io::Error::new(
"[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]", std::io::ErrorKind::InvalidInput,
"[feature.memory].enabled = true requires a [memory] configuration section",
)
})?;
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root);
let query_cfg = memory::tool::QueryConfig::from(mem);
worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(),
session_id_for_usage,
)); ));
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
}
// Pod-orchestration tools (SpawnPod + the four comm tools) share
// the Pod-scoped `SpawnedPodRegistry` (also consumed by the main
// loop's `PodEvent` handler). Expose them only behind the explicit
// profile feature and require delegation authority up front so enabling
// the surface cannot imply broad child scope by accident.
if feature_config.pods.enabled {
if spawner_manifest.delegation_scope.allow.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]",
));
}
worker.register_tool(spawn_pod_tool(
spawner_name.clone(),
spawner_socket,
runtime_base.clone(),
workspace_root.clone(),
cwd.clone(),
spawned_registry.clone(),
self_parent_socket,
spawner_manifest,
scope_handle,
prompts,
));
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
worker.register_tool(stop_pod_tool(spawned_registry.clone()));
let discovery =
PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry);
worker.register_tool(list_pods_tool(discovery.clone()));
worker.register_tool(restore_pod_tool(discovery.clone()));
worker.register_tool(send_to_peer_pod_tool(discovery));
} }
worker.register_tool(spawn_pod_tool(
spawner_name.clone(),
spawner_socket,
runtime_base.clone(),
workspace_root.clone(),
cwd.clone(),
spawned_registry.clone(),
self_parent_socket,
spawner_manifest,
scope_handle,
prompts,
));
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
worker.register_tool(stop_pod_tool(spawned_registry.clone()));
let discovery =
PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry);
worker.register_tool(list_pods_tool(discovery.clone()));
worker.register_tool(restore_pod_tool(discovery.clone()));
worker.register_tool(send_to_peer_pod_tool(discovery));
} }
let _feature_install_report = pod.install_features(feature_registry);
pod.attach_tracker(tracker); pod.attach_tracker(tracker);
Ok(fs_for_view) Ok(fs_for_view)
} }

View File

@ -1290,15 +1290,36 @@ impl FeatureRegistryBuilder {
hook_builder: &mut HookRegistryBuilder, hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let mut pending_tools = Vec::new(); let mut pending_tools = Vec::new();
let report = self.install_into_pending(&mut pending_tools, hook_builder); worker.tool_server_handle().flush_pending();
let registered_tool_names = worker
.tool_server_handle()
.tool_definitions_sorted()
.into_iter()
.map(|definition| (definition.name, FeatureId::builtin("preexisting-tool")))
.collect();
let report = self.install_into_pending_with_registered(
&mut pending_tools,
hook_builder,
registered_tool_names,
);
worker.register_tools(pending_tools); worker.register_tools(pending_tools);
report report
} }
#[allow(dead_code)]
pub(crate) fn install_into_pending( pub(crate) fn install_into_pending(
self, self,
pending_tools: &mut Vec<ToolDefinition>, pending_tools: &mut Vec<ToolDefinition>,
hook_builder: &mut HookRegistryBuilder, hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport {
self.install_into_pending_with_registered(pending_tools, hook_builder, HashMap::new())
}
fn install_into_pending_with_registered(
self,
pending_tools: &mut Vec<ToolDefinition>,
hook_builder: &mut HookRegistryBuilder,
mut installed_tool_names: HashMap<String, FeatureId>,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let descriptors: Vec<_> = self let descriptors: Vec<_> = self
.modules .modules
@ -1307,7 +1328,6 @@ impl FeatureRegistryBuilder {
.collect(); .collect();
let mut service_registry = FeatureServiceRegistry::default(); let mut service_registry = FeatureServiceRegistry::default();
let mut reports = Vec::with_capacity(self.modules.len()); let mut reports = Vec::with_capacity(self.modules.len());
let mut installed_tool_names = HashMap::new();
let mut seen_features = HashSet::new(); let mut seen_features = HashSet::new();
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) { for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
@ -1455,6 +1475,7 @@ pub enum FeatureInstallError {
} }
pub mod builtin; pub mod builtin;
pub mod plugin;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@ -0,0 +1,671 @@
//! Plugin package contributions for model-visible Tool schemas.
//!
//! This module registers *enabled* plugin package tool surface definitions as
//! unavailable Tool stubs. It deliberately does not execute plugin code or grant
//! plugin permissions; the runtime/WASM executor belongs to a later boundary.
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use llm_worker::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
};
use manifest::plugin::{PluginConfig, PluginSurface, ResolvedPluginRecord};
use serde_json::Value;
use super::{
FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule,
FeatureRuntimeKind, ToolContribution, ToolDeclaration,
};
/// Build Feature modules for enabled plugin packages when the profile exposes
/// the plugin Tool surface feature.
pub fn plugin_tool_features_if_enabled(
feature_enabled: bool,
config: &PluginConfig,
) -> Vec<PluginToolFeature> {
if !feature_enabled {
return Vec::new();
}
plugin_tool_features(config)
}
/// Build Feature modules for enabled plugin packages that declare Tool surfaces.
pub fn plugin_tool_features(config: &PluginConfig) -> Vec<PluginToolFeature> {
config
.resolved
.iter()
.filter(|record| record.enabled_surfaces.contains(&PluginSurface::Tool))
.filter(|record| !record.manifest.tools.is_empty())
.cloned()
.map(PluginToolFeature::new)
.collect()
}
#[derive(Clone, Debug)]
pub struct PluginToolFeature {
record: ResolvedPluginRecord,
feature_id: FeatureId,
}
impl PluginToolFeature {
pub fn new(record: ResolvedPluginRecord) -> Self {
let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity))
.expect("source-qualified plugin identity yields non-empty feature id");
Self { record, feature_id }
}
pub fn origin(&self) -> ToolOrigin {
ToolOrigin {
kind: "plugin".into(),
plugin_id: self.record.manifest.id.clone(),
plugin_ref: self.record.identity.to_string(),
source: self.record.identity.source.to_string(),
digest: self.record.digest.clone(),
package_version: self.record.version.clone(),
package_api_version: self.record.manifest.schema_version,
surface: "tool".into(),
}
}
}
impl FeatureModule for PluginToolFeature {
fn descriptor(&self) -> FeatureDescriptor {
let mut descriptor =
FeatureDescriptor {
id: self.feature_id.clone(),
runtime: FeatureRuntimeKind::ExternalPlugin,
display_name: self.record.manifest.name.clone(),
version: self.record.manifest.version.clone(),
description: self.record.manifest.description.clone().unwrap_or_else(|| {
format!("Plugin tool surface from {}", self.record.identity)
}),
tools: Vec::new(),
hooks: Vec::new(),
background_tasks: Vec::new(),
provides_services: Vec::new(),
requires_services: Vec::new(),
protocol_providers: Vec::new(),
};
for tool in &self.record.manifest.tools {
descriptor = descriptor.with_tool(ToolDeclaration::new(
tool.name.clone(),
tool.description.clone(),
));
}
descriptor
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
validate_declared_tool_names(&self.record)?;
let origin = self.origin();
for tool in &self.record.manifest.tools {
validate_tool_name(&tool.name).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin `{}` tool `{}` has invalid name: {reason}",
self.record.identity, tool.name
))
})?;
validate_input_schema(&tool.input_schema).map_err(|reason| {
FeatureInstallError::Install(format!(
"plugin `{}` tool `{}` has invalid input_schema: {reason}",
self.record.identity, tool.name
))
})?;
context.tools().register(ToolContribution::new(
tool.name.clone(),
plugin_runtime_missing_definition(
tool.name.clone(),
tool.description.clone(),
tool.input_schema.clone(),
origin.clone(),
),
))?;
}
Ok(())
}
}
fn plugin_runtime_missing_definition(
name: String,
description: String,
input_schema: Value,
origin: ToolOrigin,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(name.clone())
.description(description.clone())
.input_schema(input_schema.clone())
.origin(origin.clone()),
Arc::new(PluginRuntimeMissingTool {
name: name.clone(),
origin: origin.clone(),
}) as Arc<dyn Tool>,
)
})
}
struct PluginRuntimeMissingTool {
name: String,
origin: ToolOrigin,
}
#[async_trait]
impl Tool for PluginRuntimeMissingTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
Err(ToolError::ExecutionFailed(format!(
"plugin tool runtime missing/unavailable for `{}` from `{}` (digest {}, package {} api {})",
self.name,
self.origin.plugin_ref,
self.origin.digest,
self.origin.package_version,
self.origin.package_api_version
)))
}
}
fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> {
let mut seen = HashSet::new();
for tool in &record.manifest.tools {
if !seen.insert(tool.name.as_str()) {
return Err(FeatureInstallError::DuplicateToolName {
tool: tool.name.clone(),
first_feature: format!("{} (same plugin package)", record.identity),
duplicate_feature: record.identity.to_string(),
});
}
}
Ok(())
}
fn validate_tool_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("name must not be empty");
}
if name.len() > 128 {
return Err("name is longer than 128 bytes");
}
if name.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err("name must not contain whitespace or control characters");
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SupportedSchemaType {
Object,
Array,
String,
Number,
Integer,
Boolean,
Null,
}
impl SupportedSchemaType {
fn parse(value: &str) -> Option<Self> {
match value {
"object" => Some(Self::Object),
"array" => Some(Self::Array),
"string" => Some(Self::String),
"number" => Some(Self::Number),
"integer" => Some(Self::Integer),
"boolean" => Some(Self::Boolean),
"null" => Some(Self::Null),
_ => None,
}
}
}
fn validate_input_schema(schema: &Value) -> Result<(), String> {
let ty = validate_schema_node(schema, "$", true)?;
if ty != SupportedSchemaType::Object {
return Err("root schema type must be `object`".into());
}
Ok(())
}
fn validate_schema_node(
schema: &Value,
path: &str,
root: bool,
) -> Result<SupportedSchemaType, String> {
let Value::Object(map) = schema else {
return Err(format!("{path}: schema node must be a JSON object"));
};
for key in map.keys() {
if !is_supported_schema_keyword(key) {
return Err(format!("{path}: unsupported schema keyword `{key}`"));
}
}
let ty = match map.get("type") {
Some(Value::String(value)) => SupportedSchemaType::parse(value)
.ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?,
Some(_) => return Err(format!("{path}: type must be a string")),
None if root => return Err("root schema must declare type = `object`".into()),
None => return Err(format!("{path}: schema node must declare type")),
};
if let Some(title) = map.get("title") {
if !title.is_string() {
return Err(format!("{path}: title must be a string"));
}
}
if let Some(description) = map.get("description") {
if !description.is_string() {
return Err(format!("{path}: description must be a string"));
}
}
let properties = map.get("properties");
if let Some(properties) = properties {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: properties is only supported for object schemas"
));
}
let Some(properties) = properties.as_object() else {
return Err(format!("{path}: properties must be a JSON object"));
};
for (name, child_schema) in properties {
validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?;
}
}
if let Some(required) = map.get("required") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: required is only supported for object schemas"
));
}
let Some(required) = required.as_array() else {
return Err(format!("{path}: required must be an array"));
};
let mut seen = HashSet::new();
for entry in required {
let Some(name) = entry.as_str() else {
return Err(format!("{path}: required entries must be strings"));
};
if !seen.insert(name) {
return Err(format!("{path}: required entries must be unique"));
}
if let Some(properties) = properties.and_then(Value::as_object) {
if !properties.contains_key(name) {
return Err(format!(
"{path}: required entry `{name}` is not declared in properties"
));
}
}
}
}
if let Some(additional) = map.get("additionalProperties") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: additionalProperties is only supported for object schemas"
));
}
match additional {
Value::Bool(_) => {}
Value::Object(_) => {
validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?;
}
_ => {
return Err(format!(
"{path}: additionalProperties must be boolean or schema object"
));
}
}
}
if let Some(items) = map.get("items") {
if ty != SupportedSchemaType::Array {
return Err(format!("{path}: items is only supported for array schemas"));
}
validate_schema_node(items, &format!("{path}.items"), false)?;
}
if let Some(enum_values) = map.get("enum") {
let Some(enum_values) = enum_values.as_array() else {
return Err(format!("{path}: enum must be an array"));
};
if enum_values.is_empty() {
return Err(format!("{path}: enum must not be empty"));
}
for (index, value) in enum_values.iter().enumerate() {
if enum_values
.iter()
.skip(index + 1)
.any(|other| other == value)
{
return Err(format!("{path}: enum entries must be unique"));
}
}
}
Ok(ty)
}
fn is_supported_schema_keyword(key: &str) -> bool {
matches!(
key,
"type"
| "title"
| "description"
| "properties"
| "required"
| "additionalProperties"
| "items"
| "enum"
)
}
#[cfg(test)]
mod tests {
use super::*;
use manifest::plugin::{PluginPackageManifest, SourceQualifiedPluginId};
use serde_json::json;
fn tool(name: &str) -> manifest::plugin::PluginToolManifest {
manifest::plugin::PluginToolManifest {
name: name.into(),
description: format!("{name} tool"),
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
}
}
fn record(tools: Vec<manifest::plugin::PluginToolManifest>) -> ResolvedPluginRecord {
record_with_identity("project:example", tools)
}
fn record_with_identity(
identity: &str,
tools: Vec<manifest::plugin::PluginToolManifest>,
) -> ResolvedPluginRecord {
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
ResolvedPluginRecord {
identity: parsed_identity.clone(),
source: parsed_identity.source,
package_path: std::path::PathBuf::from("/tmp/example.zip"),
package_label: "example.zip".into(),
digest: "sha256:abc".into(),
version: "0.1.0".into(),
manifest: PluginPackageManifest {
schema_version: 1,
id: "example".into(),
name: "Example".into(),
version: "0.1.0".into(),
description: None,
surfaces: vec![PluginSurface::Tool],
runtime: None,
hooks: Vec::new(),
tools,
},
enabled_surfaces: vec![PluginSurface::Tool],
grants: manifest::plugin::PluginGrantConfig::default(),
config: None,
}
}
fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize {
report
.reports
.iter()
.map(|feature_report| feature_report.skipped.len())
.sum()
}
fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool {
report.reports.iter().any(|feature_report| {
feature_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains(needle))
})
}
#[test]
fn rejects_invalid_root_schema() {
let schema = json!({"type":"string"});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("type must be `object`")
);
}
#[test]
fn rejects_unsupported_schema_keyword() {
let schema = json!({"type":"object","oneOf":[]});
assert!(
validate_input_schema(&schema)
.unwrap_err()
.contains("unsupported schema keyword")
);
}
#[test]
fn rejects_invalid_nested_property_schema_node() {
let schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let error = validate_input_schema(&schema).unwrap_err();
assert!(error.contains("$.properties.query"));
assert!(error.contains("schema node must be a JSON object"));
}
#[test]
fn rejects_invalid_recursive_schema_members() {
let duplicate_required = json!({
"type":"object",
"properties":{"query":{"type":"string"}},
"required":["query", "query"]
});
assert!(
validate_input_schema(&duplicate_required)
.unwrap_err()
.contains("required entries must be unique")
);
let invalid_items = json!({
"type":"object",
"properties":{"values":{"type":"array", "items":"not-a-schema"}}
});
assert!(
validate_input_schema(&invalid_items)
.unwrap_err()
.contains("$.properties.values.items")
);
let invalid_additional = json!({
"type":"object",
"additionalProperties":{"type":"unsupported"}
});
assert!(
validate_input_schema(&invalid_additional)
.unwrap_err()
.contains("unsupported schema type")
);
}
#[test]
fn accepts_object_tool_schema() {
validate_input_schema(&json!({
"type":"object",
"properties":{
"query":{"type":"string", "description":"Search text"},
"limit":{"type":"integer", "enum":[1, 5, 10]},
"tags":{"type":"array", "items":{"type":"string"}}
},
"required":["query"],
"additionalProperties":{"type":"string"}
}))
.unwrap();
}
#[test]
fn origin_retains_plugin_metadata() {
let feature = PluginToolFeature::new(record(Vec::new()));
let origin = feature.origin();
assert_eq!(origin.kind, "plugin");
assert_eq!(origin.plugin_id, "example");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.source, "project");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.package_version, "0.1.0");
assert_eq!(origin.package_api_version, 1);
assert_eq!(origin.surface, "tool");
}
#[test]
fn enabled_plugin_tool_registers_model_visible_schema_and_origin() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.install_into_pending(&mut pending, &mut hooks);
assert!(
report
.reports
.iter()
.all(|feature_report| feature_report.diagnostics.is_empty()),
"{:#?}",
report.reports
);
assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]);
assert_eq!(pending.len(), 1);
let (meta, _) = pending[0]();
assert_eq!(meta.name, "PluginSearch");
assert_eq!(meta.input_schema["type"], "object");
let origin = meta.origin.expect("plugin origin metadata");
assert_eq!(origin.plugin_ref, "project:example");
assert_eq!(origin.digest, "sha256:abc");
assert_eq!(origin.source, "project");
assert_eq!(origin.surface, "tool");
}
#[test]
fn package_without_enabled_tool_surface_registers_no_schema() {
let mut config = PluginConfig::default();
let mut disabled = record(vec![tool("PluginSearch")]);
disabled.enabled_surfaces.clear();
config.resolved.push(disabled);
assert!(plugin_tool_features(&config).is_empty());
}
#[test]
fn disabled_profile_feature_registers_no_schema() {
let mut config = PluginConfig::default();
config.resolved.push(record(vec![tool("PluginSearch")]));
assert!(plugin_tool_features_if_enabled(false, &config).is_empty());
assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1);
}
#[test]
fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.with_module(PluginToolFeature::new(record_with_identity(
"project:other",
vec![tool("PluginSearch")],
)))
.install_into_pending(&mut pending, &mut hooks);
assert_eq!(pending.len(), 1);
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn builtin_tool_name_collision_is_rejected_with_diagnostic() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let mut registered = std::collections::HashMap::new();
registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool"));
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("Read")])))
.install_into_pending_with_registered(&mut pending, &mut hooks, registered);
assert!(pending.is_empty());
assert_eq!(skipped_count(&report), 1);
assert!(has_diagnostic(&report, "duplicate tool contribution"));
}
#[test]
fn invalid_input_schema_is_rejected_with_diagnostic() {
let mut invalid = tool("BadSchema");
invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
}
#[test]
fn nested_invalid_input_schema_does_not_register_plugin_tool() {
let mut invalid = tool("BadNestedSchema");
invalid.input_schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![invalid])))
.install_into_pending(&mut pending, &mut hooks);
assert!(pending.is_empty());
assert!(has_diagnostic(&report, "invalid input_schema"));
assert!(has_diagnostic(&report, "$.properties.query"));
}
#[tokio::test]
async fn registered_tool_executes_as_runtime_missing_error() {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
.install_into_pending(&mut pending, &mut hooks);
assert!(
report
.reports
.iter()
.all(|feature_report| feature_report.diagnostics.is_empty()),
"{:#?}",
report.reports
);
let (_, tool) = pending[0]();
let error = tool
.execute("{}", ToolExecutionContext::default())
.await
.unwrap_err();
assert!(error.to_string().contains("runtime missing/unavailable"));
assert!(error.to_string().contains("project:example"));
}
}

View File

@ -824,7 +824,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
registry: FeatureRegistryBuilder, registry: FeatureRegistryBuilder,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let worker = self.worker.as_mut().expect("worker taken during run"); let worker = self.worker.as_mut().expect("worker taken during run");
let report = registry.install_into_worker(worker, &mut self.hook_builder);
let active_workflow_committer = self.log_writer.clone().map(|writer| { let active_workflow_committer = self.log_writer.clone().map(|writer| {
Arc::new(move |entry| writer.commit_log_entry(entry)) Arc::new(move |entry| writer.commit_log_entry(entry))
as active_workflow::LogEntryCommitter as active_workflow::LogEntryCommitter
@ -833,6 +832,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.active_workflows.clone(), self.active_workflows.clone(),
active_workflow_committer, active_workflow_committer,
)); ));
let report = registry.install_into_worker(worker, &mut self.hook_builder);
report report
} }
@ -5353,6 +5353,7 @@ permission = "read"
surfaces: vec![manifest::plugin::PluginSurface::Hook], surfaces: vec![manifest::plugin::PluginSurface::Hook],
runtime: None, runtime: None,
hooks: vec![], hooks: vec![],
tools: vec![],
}, },
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(), grants: manifest::plugin::PluginGrantConfig::default(),