diff --git a/.yoi/tickets/00001KVJHYP4Q/item.md b/.yoi/tickets/00001KVJHYP4Q/item.md index 7e46adf2..51a81926 100644 --- a/.yoi/tickets/00001KVJHYP4Q/item.md +++ b/.yoi/tickets/00001KVJHYP4Q/item.md @@ -1,8 +1,8 @@ --- title: 'Plugin Service/Ingress component lifecycle surface' -state: 'inprogress' +state: 'closed' created_at: '2026-06-20T13:01:37Z' -updated_at: '2026-06-20T13:30:38Z' +updated_at: '2026-06-20T15:23:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-20T13:28:19Z' diff --git a/.yoi/tickets/00001KVJHYP4Q/resolution.md b/.yoi/tickets/00001KVJHYP4Q/resolution.md new file mode 100644 index 00000000..5e6d2837 --- /dev/null +++ b/.yoi/tickets/00001KVJHYP4Q/resolution.md @@ -0,0 +1,21 @@ +Plugin Service/Ingress component lifecycle surface を実装し、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- Pod plugin feature に host-managed `PluginInstanceRegistry` / instance handle 境界を追加し、Tool dispatch を instance 経由に変更。 +- New instance-capable component world `yoi:plugin/instance@1.0.0` と WIT resource を追加。 +- `yoi-plugin-pdk` と Rust component template に instance-oriented authoring support を追加。 +- Existing component Tool world / raw wasm Tool runtime を instance registry compatibility path に維持。 +- Manifest/static validation に Service / Ingress declarations と per-surface grant validation を追加。 +- Service lifecycle/status/diagnostics と bounded in-process ingress dispatch path を実装。 +- Tool / Service / Ingress enabled-surface filtering を runtime install, dispatch guard, and resolved static inspection / `yoi plugin list/show` に適用。 +- `plugin check` は package declaration inspection、resolved `plugin list/show` は selected/enabled surfaces に基づく reporting に分離。 +- Focused tests added for manifest validation, legacy Tool compatibility, instance state persistence, ingress dispatch, Service/Ingress grant denial, failure diagnostics, and partial enabled-surface static reporting。 + +統合・検証: +- Merge commit: `43c9216e merge: plugin instance lifecycle surface` +- Implementation commits: `147a6005`, `870bcc76`, `79ca0f7f`, `627c8f36` +- Reviewer final verdict: approve +- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p manifest plugin -- --nocapture`, `cargo test -p pod plugin -- --nocapture`, `cargo test -p yoi plugin -- --nocapture`, `cargo check -p yoi`, `cargo check -p yoi-plugin-pdk`, template cargo-check, `yoi ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外: +- Discord Bridge 本体、public registry/install/update/signature tooling、arbitrary Plugin UI channel、hidden context injection、Service/Ingress による model-visible Tool schema mutation は実装していない。 \ No newline at end of file diff --git a/.yoi/tickets/00001KVJHYP4Q/thread.md b/.yoi/tickets/00001KVJHYP4Q/thread.md index bbf250b5..03852504 100644 --- a/.yoi/tickets/00001KVJHYP4Q/thread.md +++ b/.yoi/tickets/00001KVJHYP4Q/thread.md @@ -122,3 +122,584 @@ Next action: - Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。 --- + + + +## Implementation report + +Coder implementation report received from `yoi-coder-00001KVJHYP4Q`. + +Implementation commit: +- `147a6005 plugin: add instance lifecycle surface` + +Changed areas reported: +- Manifest support for Service/Ingress declarations and permissions。 +- New `yoi:plugin/instance@1.0.0` WIT/resource surface。 +- Host-managed `PluginInstanceRegistry` / `PluginInstanceHandle` in Pod plugin feature layer。 +- Plugin Tool registration/dispatch now routes through instance handles。 +- Legacy raw-wasm and `yoi:plugin/tool@1.0.0` component Tool runtimes preserved through registry adapter path。 +- Instance lifecycle/status/stop/diagnostics surfaces and bounded Ingress dispatch API added。 +- Independent Tool/Service/Ingress grant validation added。 +- Static inspection / Plugin CLI diagnostics extended for Service/Ingress eligibility。 +- Rust PDK instance-oriented types/trait/macro support added。 +- Rust component instance template resources added。 +- Plugin component model docs updated。 +- Tests added/updated for manifest validation, CLI reporting/templates, legacy compatibility, grant checks, and in-process shared Tool/Ingress dispatch。 + +Coder validation reported as passing: +- `cargo test -p manifest plugin -- --nocapture` +- `cargo test -p pod plugin -- --nocapture` +- `cargo test -p yoi plugin -- --nocapture` +- `cargo check -p yoi` +- `cargo check -p yoi-plugin-pdk` +- `cargo fmt --check` +- `git diff --check` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Dependency/package impact: +- No new Rust dependencies。 +- Added package/resource files under `resources/plugin/templates/rust-component-instance/` and `resources/plugin/wit/yoi-plugin-instance-v1.wit`。 +- No `Cargo.lock` or `package.nix` changes required; Nix build succeeded。 + +Known risks / deferrals: +- Instance Component ABI is intentionally minimal string-JSON host dispatch (`start`, `handle-tool`, `handle-ingress`, `status`, `stop`) and should be reviewed before treating it as stable public low-level ABI。 +- Service lifecycle is surfaced as host-managed registration/status/stop diagnostics; no unsafe hidden model/tool/context action path was added。 +- Rust PDK instance macro provides author-facing instance helpers, but full cargo-component generated glue ergonomics may need follow-up polish if public PDK shape becomes stricter。 + +Orchestrator evidence checked before review dispatch: +- Implementation worktree is clean。 +- HEAD is `147a6005`。 +- Diff from acceptance `5ec8bae9..HEAD` is one implementation commit touching 12 files, about 1399 insertions / 34 deletions。 +- `git diff --check 5ec8bae9..HEAD` produced no diagnostics。 + +Next action: +- Dispatch Reviewer for r1 review against Ticket requirements, with focus on instance registry boundary, legacy compatibility, ToolRegistry run-stability, no hidden context/action paths, per-surface grants, Service/Ingress lifecycle semantics, static inspection/CLI reporting, WIT/PDK/template correctness, bounds/resource limits, and validation evidence。 + +--- + + + +## Plan + +Review dispatch: +- Spawned Reviewer Pod `yoi-reviewer-00001KVJHYP4Q-r1` against implementation branch `impl/00001KVJHYP4Q-plugin-instance-lifecycle`。 +- Review target commit: `147a6005 plugin: add instance lifecycle surface`。 +- Review baseline: `5ec8bae9`。 +- Reviewer task focuses on instance registry boundary, legacy Tool compatibility, ordinary Tool result/history path, Service/Ingress no hidden action/context paths, ToolRegistry run-stability, per-surface and host API grant independence, no ambient WASI/network authority, bounded ingress/events/diagnostics, static inspection no-exec behavior, WIT/PDK/template coherence, tests, and Nix/package evidence。 +- Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。 + +--- + + + +## Review: request changes + +Verdict: `request_changes` + +確認範囲: +- Ticket contract / Orchestrator IntentPacket。 +- Implementation diff: `5ec8bae9..147a6005`。 +- 主な対象: + - `crates/pod/src/feature/plugin.rs` + - `crates/manifest/src/plugin.rs` + - `crates/yoi/src/plugin_cli.rs` + - `crates/plugin-pdk/src/lib.rs` + - `resources/plugin/wit/yoi-plugin-instance-v1.wit` + - `resources/plugin/templates/rust-component-instance/*` + - Plugin component docs/templates and focused tests。 +- Hidden history/context injection、ToolRegistry bypass、Ingress/Service production call path、ambient WASI/network authority の有無を確認。 + +Blocking issues: + +1. Rust instance PDK/template が buildable ではなく、WIT component instance ABI を実際に export していない。 + - `resources/plugin/templates/rust-component-instance/Cargo.toml` は repository 配下 package だが `[workspace]` がなく、direct authoring check が workspace membership error で失敗する。 + - `resources/plugin/templates/rust-component-instance/src/lib.rs` は `ToolOutput::text(...)` を呼ぶが、`crates/plugin-pdk/src/lib.rs` には `ToolOutput::new`, `ToolOutput::json`, `ToolOutput::summary` しかない。 + - `export_plugin_instance!` は generated WIT bindings / generated `export!` macro for `world instance` を実装していない。raw placeholder `#[unsafe(export_name = "start")]` と private Rust methods を定義するだけで、host が期待する component-model exports (`start`, `handle-tool`, `handle-ingress`, `status`, `stop`) を生成しない。 + - Ticket が要求する WIT/PDK/template coherence と instance-oriented authoring surface を満たしていない。 + +2. Component instance lifecycle が status/error outputs を parse せず、component `status` export が実質 unused。 + - `PluginInstance::status` は host-side lifecycle/diagnostics のみを返し、component runtime の `status` export を呼ばない。 + - `PluginComponentInstanceRuntime::start` は component `start` export の returned string を捨てている。 + - `PluginComponentInstanceRuntime::stop` も returned string を捨てている。 + - WIT は `status` を export しているが、host-side runtime method がない。 + - Component が `{"error": ...}` を `start` から返しても host が started と扱い得るため、lifecycle/status/diagnostics acceptance criteria を満たしていない。 + +3. Service/Ingress surfaces が Tool 登録なしでは host-managed Plugin instance を作成・保持しない。 + - `PluginToolFeature::install` の実 install path では Service declarations は metadata only、Ingress declarations は authorization-check only。 + - Instance は Tool registration loop 内でのみ作られる。 + - Service-only / Ingress-only instance-capable plugin は host-managed instance、lifecycle start、status、diagnostics、retained ingress dispatch handle を得られない。 + - `deliver_ingress` は production caller に接続されておらず、focused ingress test は synthetic `TestIngress` instance を使っている。 + - Ticket intent の “Tool / Service / Ingress as surfaces of the same Plugin instance” を満たしていない。 + +Non-blocking concerns / follow-ups: +- Human `yoi plugin show` output でも Service/Ingress eligibility を明示すると JSON なしでも rejected surfaces が分かりやすい。 +- Real instance-component tests が弱い。Rust/PDK generated `yoi:plugin/instance@1.0.0` component の build/execution through start/tool/ingress/status/stop coverage が欲しい。 + +Reviewer validation: +- `git diff --check 5ec8bae9..HEAD`: passed。 +- `cargo fmt --check`: passed。 +- `cargo test -p manifest plugin -- --nocapture`: passed。 +- `cargo test -p pod plugin -- --nocapture`: passed。 +- `cargo test -p yoi plugin -- --nocapture`: passed。 +- `cargo check -p yoi`: passed。 +- `cargo check -p yoi-plugin-pdk`: passed。 +- `yoi ticket doctor`: passed。 +- `nix build .#yoi --no-link`: passed。 +- Additional focused template validation failed: `CARGO_TARGET_DIR=target/review-template cargo check --manifest-path resources/plugin/templates/rust-component-instance/Cargo.toml` with workspace membership error。 + +Worktree status at review end: clean。 + +--- + + + +## Implementation report + +Coder r1-fix report received from `yoi-coder-00001KVJHYP4Q`. + +New fix commit: +- `870bcc76 plugin: fix instance lifecycle blockers` + +Branch commits now: +- `870bcc76 plugin: fix instance lifecycle blockers` +- `147a6005 plugin: add instance lifecycle surface` + +Fix summary: +- `crates/plugin-pdk/src/lib.rs`: + - Reworked instance PDK export macro to use generated `wit-bindgen` `Guest` / `export!` shape instead of raw placeholder exports。 + - Added coherent instance-world helpers/status/error JSON handling。 +- `resources/plugin/templates/rust-component-instance/`: + - Added empty `[workspace]` so template checks as an in-tree standalone authoring package。 + - Fixed template API mismatch by using existing `ToolOutput::json`。 + - Added `wit_bindgen::generate!` for `instance` world and updated export macro usage。 +- `crates/pod/src/feature/plugin.rs`: + - Component instance lifecycle now decodes `start` / `stop` returned JSON and fails closed on returned errors / failed status。 + - Host now calls component `status` export and includes component status in `PluginInstanceStatus`。 + - Service/Ingress-capable installs create and retain host-managed instances even without Tool registration。 + - Added retained registry handle and bounded installed-path Ingress dispatch API。 + - Added tests for component start error handling, component status/stop reporting, service-only instance retention, and installed-path Tool/Ingress shared-instance dispatch。 +- No `Cargo.lock`, dependency, or `package.nix` changes。 + +Coder validation reported as passing: +- `cargo test -p manifest plugin -- --nocapture` +- `cargo test -p pod plugin -- --nocapture` +- `cargo test -p yoi plugin -- --nocapture` +- `cargo check -p yoi` +- `cargo check -p yoi-plugin-pdk` +- `CARGO_TARGET_DIR=target/review-template cargo check --manifest-path resources/plugin/templates/rust-component-instance/Cargo.toml` +- `cargo fmt --check` +- `git diff --check 5ec8bae9..HEAD` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Remaining deferral / risk: +- Instance PDK now uses `wit-bindgen` export glue and template checks, but `yoi:plugin/instance@1.0.0` remains first public shape and should be reviewed before broad external stability promises。 + +Orchestrator evidence checked before r2 review: +- Implementation worktree is clean。 +- HEAD is `870bcc76`。 +- Fix diff from `147a6005..HEAD` touches 4 files, about 506 insertions / 119 deletions。 +- Overall `git diff --check 5ec8bae9..HEAD` produced no diagnostics。 + +Next action: +- Route back to Reviewer for r2 verification of the PDK/template export glue, lifecycle status/error parsing, Service/Ingress instance retention, installed-path ingress dispatch, and final acceptance criteria。 + +--- + + + +## Review: request changes + +Verdict: `request_changes` + +確認範囲: +- Ticket authority / Orchestrator IntentPacket。 +- Fix diff: `147a6005..870bcc76`。 +- Overall implementation shape: `5ec8bae9..870bcc76`。 +- 主な対象: + - `crates/plugin-pdk/src/lib.rs` + - `resources/plugin/templates/rust-component-instance/Cargo.toml` + - `resources/plugin/templates/rust-component-instance/src/lib.rs` + - `resources/plugin/wit/yoi-plugin-instance-v1.wit` + - `crates/pod/src/feature/plugin.rs` + - `crates/manifest/src/plugin.rs` + - `crates/yoi/src/plugin_cli.rs` + +Previous blocker verification: +- Rust instance PDK/template buildability / WIT export shape: substantially addressed。 + - `export_plugin_instance!` now targets generated `wit-bindgen` `Guest` / `export!` shape。 + - Instance template has empty `[workspace]`, uses `ToolOutput::json`, and includes `wit_bindgen::generate!`。 + - Focused template validation passes。 +- Component lifecycle start/status/stop handling: substantially addressed。 + - Host parses lifecycle JSON and fails closed on error/failure status。 + - Component `status` export is called and reflected in instance status。 + - Stop output is decoded/reported。 +- Service/Ingress instance retention and installed-path dispatch: substantially addressed。 + - `PluginToolFeature` retains a `PluginInstanceRegistry`。 + - Service/Ingress-capable installs can create/retain instance without Tool registration。 + - Installed-path `dispatch_ingress` exists and tests cover shared Tool/Ingress dispatch。 + +Blocking issue: + +1. Enabled surface selection is not enforced per surface during descriptor/install, so mixed-surface plugins can expose or block the wrong surfaces。 + - `PluginToolFeature` is gated at coarse whole-record level by whether any of Tool/Service/Ingress appears in `record.enabled_surfaces`。 + - Once feature exists, descriptor and install paths iterate over all declared manifest services/tools/ingresses, not only selected/enabled surfaces。 + - Concrete risk: + - Plugin declaring both Tool and Service with only Service selected can still attempt to authorize/register Tool。 + - Missing Tool grant can fail selected Service install。 + - If Tool grant exists, unselected Tool can become model-visible。 + - Unselected Service/Ingress can be provided/started if their grants exist。 + - This violates the Ticket requirement that Tool / Service / Ingress grants and exposure remain independent and explicitly gated。 + +Required fix: +- Filter descriptor/install loops by `record.enabled_surfaces`。 +- Ensure registered/denial behavior accounts for selected surface set, not all manifest declarations。 +- Add focused tests for mixed-surface packages with partial enabled surfaces: + - Service selected, Tool declared but not selected: Service installs and Tool does not register。 + - Tool selected, Service/Ingress declared but not selected: no Service/Ingress instance/dispatch exposure。 + - Missing grants for unselected surfaces do not fail selected-surface install。 + - Grants for unselected surfaces do not cause exposure。 + +Non-blocking concern: +- Existing disabled-surface test only clears all enabled surfaces and does not cover mixed-surface partial enablement。 + +Reviewer validation: +- `cargo fmt --check`: passed。 +- `git diff --check 5ec8bae9..HEAD`: passed。 +- `CARGO_TARGET_DIR=target/review-template cargo check --manifest-path resources/plugin/templates/rust-component-instance/Cargo.toml`: passed。 +- `cargo test -p manifest plugin -- --nocapture`: passed。 +- `cargo test -p pod plugin -- --nocapture`: passed。 +- `cargo test -p yoi plugin -- --nocapture`: passed。 +- `cargo check -p yoi`: passed。 +- `cargo check -p yoi-plugin-pdk`: passed。 +- `yoi ticket doctor`: passed。 +- `nix build .#yoi --no-link`: passed。 + +Worktree status at end: clean。 + +--- + + + +## Implementation report + +Coder r2-fix report received from `yoi-coder-00001KVJHYP4Q`. + +New fix commit: +- `79ca0f7f plugin: enforce enabled lifecycle surfaces` + +Branch commits now: +- `79ca0f7f plugin: enforce enabled lifecycle surfaces` +- `870bcc76 plugin: fix instance lifecycle blockers` +- `147a6005 plugin: add instance lifecycle surface` + +Fix summary: +- `crates/pod/src/feature/plugin.rs`: + - Added per-surface `record.enabled_surfaces` gating for Plugin descriptor construction。 + - Filtered install loops so Tool / Service / Ingress setup only processes selected surfaces。 + - Changed install denial/exposure accounting to consider selected surfaces only。 + - Added runtime dispatch guards so unselected Tool/Ingress surfaces cannot be used even if grants exist。 + - Added focused tests for mixed-surface partial enablement: + - Service selected, Tool declared but not selected: Service installs, Tool does not register, missing Tool grants do not fail install。 + - Tool selected, Service/Ingress declared but not selected: no Service descriptor/install exposure, Ingress dispatch denied even with grants。 + - Kept previous lifecycle/status/ingress retention tests passing。 + +Coder validation reported as passing: +- `cargo test -p manifest plugin -- --nocapture` +- `cargo test -p pod plugin -- --nocapture` +- `cargo test -p yoi plugin -- --nocapture` +- `cargo check -p yoi` +- `cargo check -p yoi-plugin-pdk` +- `CARGO_TARGET_DIR=target/review-template cargo check --manifest-path resources/plugin/templates/rust-component-instance/Cargo.toml` +- `cargo fmt --check` +- `git diff --check 5ec8bae9..HEAD` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Orchestrator evidence checked before r3 review: +- Implementation worktree is clean。 +- HEAD is `79ca0f7f`。 +- Fix diff from `870bcc76..HEAD` touches only `crates/pod/src/feature/plugin.rs`, about 190 insertions / 97 deletions。 +- Overall `git diff --check 5ec8bae9..HEAD` produced no diagnostics。 + +Next action: +- Route back to Reviewer for r3 verification of per-surface enabled selection enforcement and final acceptance criteria。 + +--- + + + +## Review: request changes + +Verdict: `request_changes` + +確認範囲: +- Ticket authority / Orchestrator IntentPacket。 +- r3 fix diff: `870bcc76..79ca0f7f`。 +- Relevant overall implementation shape: `5ec8bae9..79ca0f7f`。 +- 主な対象: + - `crates/pod/src/feature/plugin.rs` + - `crates/yoi/src/plugin_cli.rs` + - `crates/manifest/src/plugin.rs` + - `crates/plugin-pdk/src/lib.rs` + - `resources/plugin/templates/rust-component-instance/*` + - `resources/plugin/wit/yoi-plugin-instance-v1.wit` + +Previous r2 blocker verification: +- Runtime/registration side is mostly fixed。 +- Descriptor construction is gated by `record.enabled_surfaces`。 +- Install loops now gate Service / Ingress / Tool setup independently。 +- Install denial accounting counts selected exposed surfaces rather than only Tools。 +- Runtime dispatch guards exist for unselected Tool/Ingress use。 +- Tests cover requested selected/unselected mixed-surface cases。 + +Blocking issue: + +1. Static inspection / `yoi plugin list/show` still evaluates unselected surfaces and can mark valid partial-surface enablement as rejected。 + - Runtime install now filters enabled surfaces, but static inspection still treats all declared manifest Tool / Service / Ingress surfaces as active eligibility requirements。 + - `PluginStaticInspection::statically_eligible()` requires all inspected tools, services, and ingresses to be eligible。 + - `inspect_resolved_plugin_static()` still iterates over all declared manifest tools/services/ingresses rather than selected/enabled surfaces。 + - `yoi plugin list/show` uses this unfiltered static inspection for resolved items。 + - Status calculation can classify the resolved item as `rejected` when no usable Tool exists and `static_eligible` is false。 + +Concrete failure mode: +- Mixed package declares Tool + Service。 +- Enablement selects only Service。 +- Service grants are present; Tool grants are absent because Tool is not selected。 +- Runtime install correctly ignores unselected Tool。 +- Static inspection still checks unselected Tool and records missing Tool grant diagnostics, so `static_eligible = false`。 +- For service-only selection with no usable Tool, `yoi plugin list/show` can report the resolved plugin as `rejected` even though selected Service surface is valid/installable。 + +Why this blocks approval: +- Ticket requires Tool / Service / Ingress grants to be independent。 +- Unselected surfaces must not block selected surfaces。 +- Ticket also requires `yoi plugin check/list/show` to report legacy vs instance-capable/rejected surfaces accurately。 +- Runtime path is fixed, but inspection/status can still be blocked by unselected surfaces, creating authority/reporting mismatch。 + +Required fix: +- Make resolved static inspection eligibility account for `record.enabled_surfaces`。 +- Alternatively, separate declared-surface inspection from enabled-surface inspection and compute resolved `list/show` status from enabled surfaces only。 +- Keep `plugin check` free to inspect full package declaration if appropriate, but `plugin list/show` for a resolved enablement must not mark selected Service/Ingress invalid because unrelated unselected Tool grants are absent。 +- Add focused `yoi plugin` tests for mixed-surface partial enablement status/reporting。 + +Non-blocking concern: +- Runtime install tests now cover partial enablement, but focused `yoi plugin list/show` tests are still missing and should be added with the fix。 + +Reviewer validation: +- `cargo fmt --check`: passed。 +- `git diff --check 5ec8bae9..HEAD`: passed。 +- Template cargo-check: passed。 +- `cargo test -p manifest plugin -- --nocapture`: passed。 +- `cargo test -p pod plugin -- --nocapture`: passed。 +- `cargo test -p yoi plugin -- --nocapture`: passed。 +- `cargo check -p yoi`: passed。 +- `cargo check -p yoi-plugin-pdk`: passed。 +- `yoi ticket doctor`: passed。 +- `nix build .#yoi --no-link`: passed。 + +Worktree status at end: clean。 + +--- + + + +## Implementation report + +Coder r3-fix report received from `yoi-coder-00001KVJHYP4Q`. + +New fix commit: +- `627c8f36 plugin: filter static enabled surfaces` + +Branch commits now: +- `627c8f36 plugin: filter static enabled surfaces` +- `79ca0f7f plugin: enforce enabled lifecycle surfaces` +- `870bcc76 plugin: fix instance lifecycle blockers` +- `147a6005 plugin: add instance lifecycle surface` + +Fix summary: +- `crates/pod/src/feature/plugin.rs`: + - `inspect_resolved_plugin_static()` now filters Tool / Service / Ingress inspection by `record.enabled_surfaces`。 + - `PluginStaticInspection::statically_eligible()` therefore reflects enabled/selected surfaces only for resolved Plugin records。 + - `plugin check` remains full-declaration oriented because check-time inspection uses all declared manifest surfaces as enabled。 +- `crates/yoi/src/plugin_cli.rs`: + - Resolved `plugin list/show` diagnostics now use filtered static inspection。 + - Added focused CLI inspection test for mixed Tool+Service package where only Service is enabled and Tool grants are absent: + - status remains `active`。 + - static eligibility remains true。 + - unselected Tool is not listed/reported。 + - unselected Tool missing grants do not produce diagnostics/rejection。 + +Coder validation reported as passing: +- `cargo test -p manifest plugin -- --nocapture` +- `cargo test -p pod plugin -- --nocapture` +- `cargo test -p yoi plugin -- --nocapture` +- `cargo check -p yoi` +- `cargo check -p yoi-plugin-pdk` +- `CARGO_TARGET_DIR=target/review-template cargo check --manifest-path resources/plugin/templates/rust-component-instance/Cargo.toml` +- `cargo fmt --check` +- `git diff --check 5ec8bae9..HEAD` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Orchestrator evidence checked before r4 review: +- Implementation worktree is clean。 +- HEAD is `627c8f36`。 +- Fix diff from `79ca0f7f..HEAD` touches `crates/pod/src/feature/plugin.rs` and `crates/yoi/src/plugin_cli.rs`, about 209 insertions / 76 deletions。 +- Overall `git diff --check 5ec8bae9..HEAD` produced no diagnostics。 + +Next action: +- Route back to Reviewer for r4 verification of static inspection / `plugin list/show` partial-surface reporting and final acceptance criteria。 + +--- + + + +## Review: approve + +Verdict: `approve` + +確認範囲: +- Ticket contract: + - `.yoi/tickets/00001KVJHYP4Q/item.md` + - `.yoi/tickets/00001KVJHYP4Q/thread.md` +- r4 fix diff: `79ca0f7f..627c8f36` +- overall implementation shape: `5ec8bae9..627c8f36` +- Focused files: + - `crates/pod/src/feature/plugin.rs` + - `crates/yoi/src/plugin_cli.rs` + - `crates/manifest/src/plugin.rs` + - `crates/plugin-pdk/src/lib.rs` + - `resources/plugin/templates/rust-component-instance/*` + - `resources/plugin/wit/yoi-plugin-instance-v1.wit` + +r3 blocker verification: +- Previous static-inspection/list-show blocker is resolved。 +- `inspect_resolved_plugin_static()` now filters Tool / Service / Ingress eligibility by `record.enabled_surfaces`。 +- `PluginStaticInspection::statically_eligible()` now operates over filtered/selected surface vectors for resolved records。 +- `plugin check` remains full-declaration oriented because check-time inspection constructs the record with all declared manifest surfaces enabled。 +- Resolved `plugin list/show` diagnostics now use filtered static inspection and include selected Service/Ingress diagnostics。 +- Focused CLI test `service_only_enablement_ignores_unselected_tool_static_grants` confirms a mixed Tool+Service package with only Service enabled remains `active`, is statically eligible, does not report the unselected Tool, and does not emit missing Tool grant diagnostics。 + +Earlier blocker re-check: +- Instance PDK/template shape, lifecycle returned JSON/status handling, Service/Ingress instance retention, installed-path ingress dispatch, and per-surface runtime guards remain present at a high level。 + +Blocking issues: +- None found。 + +Non-blocking follow-ups: +- None requiring this Ticket to remain blocked。 + +Reviewer validation passed: +- `cargo fmt --check` +- `git diff --check 5ec8bae9..HEAD` +- `CARGO_TARGET_DIR=target/review-template cargo check --manifest-path resources/plugin/templates/rust-component-instance/Cargo.toml` +- `cargo test -p manifest plugin -- --nocapture` +- `cargo test -p pod plugin -- --nocapture` +- `cargo test -p yoi plugin -- --nocapture` +- `cargo check -p yoi` +- `cargo check -p yoi-plugin-pdk` +- `/home/hare/Projects/yoi/target/debug/yoi ticket doctor` +- `nix build .#yoi --no-link` + +Template check generated `resources/plugin/templates/rust-component-instance/Cargo.lock`; reviewer removed it after validation。 + +Worktree status: +- Clean at commit `627c8f36` on `impl/00001KVJHYP4Q-plugin-instance-lifecycle`。 + +--- + + + +## Implementation report + +Reviewer r4 approvalを受けて、実装ブランチを Orchestrator worktree の `orchestration` branch に統合した。 + +統合: +- Merge commit: `43c9216e merge: plugin instance lifecycle surface` +- Merged branch: `impl/00001KVJHYP4Q-plugin-instance-lifecycle` +- Implementation commits: + - `147a6005 plugin: add instance lifecycle surface` + - `870bcc76 plugin: fix instance lifecycle blockers` + - `79ca0f7f plugin: enforce enabled lifecycle surfaces` + - `627c8f36 plugin: filter static enabled surfaces` + +Orchestrator worktree validation: +- `cargo fmt --check`: passed +- `git diff --check HEAD^1..HEAD`: passed +- `cargo test -p manifest plugin -- --nocapture`: passed +- `cargo test -p pod plugin -- --nocapture`: passed +- `cargo test -p yoi plugin -- --nocapture`: passed +- `cargo check -p yoi`: passed +- `cargo check -p yoi-plugin-pdk`: passed +- `CARGO_TARGET_DIR=target/review-template cargo check --manifest-path resources/plugin/templates/rust-component-instance/Cargo.toml`: passed +- `/home/hare/Projects/yoi/target/debug/yoi ticket doctor`: passed +- `nix build .#yoi --no-link`: passed + +Template validation generated `resources/plugin/templates/rust-component-instance/Cargo.lock`; it was removed after validation. Final Orchestrator worktree status is clean。 + +--- + + + +## State changed + +Reviewer approval、Orchestrator worktree への統合、必要な検証が完了したため `done` に遷移する。実装は Plugin instance lifecycle surface、Service/Ingress declarations and grants、PDK/template、static/runtime inspection、legacy compatibility、および focused tests を含む。 + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Plugin Service/Ingress component lifecycle surface を実装し、Orchestrator worktree の `orchestration` branch に統合した。 + +主な成果: +- Pod plugin feature に host-managed `PluginInstanceRegistry` / instance handle 境界を追加し、Tool dispatch を instance 経由に変更。 +- New instance-capable component world `yoi:plugin/instance@1.0.0` と WIT resource を追加。 +- `yoi-plugin-pdk` と Rust component template に instance-oriented authoring support を追加。 +- Existing component Tool world / raw wasm Tool runtime を instance registry compatibility path に維持。 +- Manifest/static validation に Service / Ingress declarations と per-surface grant validation を追加。 +- Service lifecycle/status/diagnostics と bounded in-process ingress dispatch path を実装。 +- Tool / Service / Ingress enabled-surface filtering を runtime install, dispatch guard, and resolved static inspection / `yoi plugin list/show` に適用。 +- `plugin check` は package declaration inspection、resolved `plugin list/show` は selected/enabled surfaces に基づく reporting に分離。 +- Focused tests added for manifest validation, legacy Tool compatibility, instance state persistence, ingress dispatch, Service/Ingress grant denial, failure diagnostics, and partial enabled-surface static reporting。 + +統合・検証: +- Merge commit: `43c9216e merge: plugin instance lifecycle surface` +- Implementation commits: `147a6005`, `870bcc76`, `79ca0f7f`, `627c8f36` +- Reviewer final verdict: approve +- Validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p manifest plugin -- --nocapture`, `cargo test -p pod plugin -- --nocapture`, `cargo test -p yoi plugin -- --nocapture`, `cargo check -p yoi`, `cargo check -p yoi-plugin-pdk`, template cargo-check, `yoi ticket doctor`, and `nix build .#yoi --no-link`。 + +範囲外: +- Discord Bridge 本体、public registry/install/update/signature tooling、arbitrary Plugin UI channel、hidden context injection、Service/Ingress による model-visible Tool schema mutation は実装していない。 + +--- + + + +## Implementation report + +Post-close cleanup completed。 + +- Stopped child Pods and reclaimed scope: + - `yoi-coder-00001KVJHYP4Q` + - `yoi-reviewer-00001KVJHYP4Q-r1` +- Removed implementation worktree: + - `/home/hare/Projects/yoi/.worktree/00001KVJHYP4Q-plugin-instance-lifecycle` +- Deleted implementation branch: + - `impl/00001KVJHYP4Q-plugin-instance-lifecycle` +- Orchestrator worktree remains clean on `orchestration` at `bc484338`。 + +Root/original workspace was not used for merge/validation/cleanup operations beyond observing the worktree list output from the Orchestrator worktree command。 + +--- diff --git a/.yoi/tickets/00001KVJKHAFE/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVJKHAFE/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..2b82bfa9 --- /dev/null +++ b/.yoi/tickets/00001KVJKHAFE/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260620-133128-1","ticket_id":"00001KVJKHAFE","kind":"accepted_plan","accepted_plan":{"summary":"`yoi mcp` CLI namespaceを追加し、resolved MCP server config、trust policy、static diagnostics、provider-discovered tools/resources/prompts eligibility、live/unavailable stateを bounded human/JSON outputで inspectionできるようにする。CLIはMCP serverを起動せず、tools/call/resources/read/prompts/getを直接実行しない。","branch":"impl/00001KVJKHAFE-mcp-cli-inspection","worktree":"/home/hare/Projects/yoi/.worktree/00001KVJKHAFE-mcp-cli-inspection","role_plan":"Orchestrator は Plugin instance lifecycle work と並行して専用 implementation worktree `.worktree/00001KVJKHAFE-mcp-cli-inspection` を作成し、Coder をその child worktree への narrow write scope で起動する。Coder 実装後、Reviewer が read-only inspection境界、no server spawn/no tool execution/no content fetch、secret/content redaction、static-vs-live status表現、JSON shape、help/test/Nixを確認する。"},"author":"yoi-orchestrator","at":"2026-06-20T13:31:28Z"} diff --git a/.yoi/tickets/00001KVJKHAFE/item.md b/.yoi/tickets/00001KVJKHAFE/item.md index 3140f271..e3738ed7 100644 --- a/.yoi/tickets/00001KVJKHAFE/item.md +++ b/.yoi/tickets/00001KVJKHAFE/item.md @@ -1,8 +1,8 @@ --- title: 'MCP: add yoi CLI inspection commands' -state: 'queued' +state: 'closed' created_at: '2026-06-20T13:29:16Z' -updated_at: '2026-06-20T13:31:00Z' +updated_at: '2026-06-20T13:56:39Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-20T13:31:00Z' diff --git a/.yoi/tickets/00001KVJKHAFE/resolution.md b/.yoi/tickets/00001KVJKHAFE/resolution.md new file mode 100644 index 00000000..45625015 --- /dev/null +++ b/.yoi/tickets/00001KVJKHAFE/resolution.md @@ -0,0 +1,44 @@ +## Resolution + +`00001KVJKHAFE` を完了しました。 + +実装内容: +- `yoi mcp` CLI namespace を追加しました。 +- Read-only inspection commands を追加しました。 + - `yoi mcp list` + - `yoi mcp show ` + - `yoi mcp tools []` + - `yoi mcp resources []` + - `yoi mcp prompts []` +- Human-readable output と `--json` output を追加しました。 +- Inspection は static/resolved config のみを扱い、MCP server process を起動しません。 +- `tools/call`, `resources/read`, `prompts/get` は実行しません。 +- Live/provider-discovered state は `not_live` / `unavailable` と明示します。 +- Env values, secret refs, env refs, args, resource content, prompt content は redacted/omitted します。 +- Resource/prompt operation eligibility は content fetch なしで報告します。 +- MCP namespace は Plugin CLI namespace と分離したままです。 + +主な commit: +- `c91f5fc9 mcp: add cli inspection` +- `5e0b023a merge: mcp cli inspection` + +Review: +- r1 は `approve`。 +- Reviewer は read-only boundary、no process start、no tools/resource/prompt content fetch、static-vs-live unavailable state、secret/content redaction、MCP namespace separation、help/tests を確認しました。 + +最終 validation: +- `cargo fmt --check` +- `git diff --check HEAD^1..HEAD` +- `cargo test -p yoi mcp` +- `cargo check -p yoi` +- `cargo run -q -p yoi -- --help` + MCP command grep +- `TicketDoctor`: 0 errors + +Known unrelated note: +- `TicketDoctor` は既存 Ticket の warning 4 件を返しましたが、この Ticket の変更とは無関係です。 + +Nix validation: +- Not run because no dependency/package/source-filter files changed。 + +Validation log: +- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-xrqves.log` \ No newline at end of file diff --git a/.yoi/tickets/00001KVJKHAFE/thread.md b/.yoi/tickets/00001KVJKHAFE/thread.md index 6b1562f4..978ba31a 100644 --- a/.yoi/tickets/00001KVJKHAFE/thread.md +++ b/.yoi/tickets/00001KVJKHAFE/thread.md @@ -30,4 +30,302 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready_parallel + +Reason: +- Workspace Dashboard Queue により、この Ticket は Orchestrator routing 対象として明示許可された。 +- User standing directive: blocker が無いものは並列実行する。現在 `00001KVJHYP4Q` Plugin instance lifecycle Coder が inprogress だが、この Ticket は MCP CLI inspection namespaceであり直接 dependency/conflict はない。Potential shared CLI parser edits are manageable in separate worktree and will be resolved at merge/review boundary。 +- Ticket body は `yoi mcp` namespace、list/show/tools/resources/prompts commands、human/JSON output、workspace/profile resolution、read-only inspection boundary、static-vs-live availability、secret/content redaction、acceptance testsを実装可能な粒度で定義している。 +- 未解決 blocker relation はない。Relations are `related` context to completed MCP foundation Tickets。 +- Orchestrator worktree は clean、matching branch/worktree はなし。 +- Risk domain は CLI / MCP / inspection / secrets / read-only boundary だが、Ticket は no server process start、no tool execution、no resources/prompts content fetch、secrets/content non-printing、bounded outputを明示している。bounded context check 後も implementation 前に必要な追加 human decision は見つからなかった。 + +Evidence checked: +- Ticket `00001KVJKHAFE` body / thread / relations / artifacts。 +- `TicketRelationQuery(00001KVJKHAFE)`: only non-blocking `related` records to completed MCP config/lifecycle/tools/resources/list_changed Tickets。 +- `TicketOrchestrationPlanQuery(00001KVJKHAFE)`: no previous plan records; accepted plan recorded now。 +- Workspace state: + - Orchestrator worktree clean at `142fdffb`。 + - queued: this Ticket only。 + - inprogress: `00001KVJHYP4Q` Plugin instance lifecycle。 + - visible spawned child: `yoi-coder-00001KVJHYP4Q` running。 + - no matching MCP CLI branch/worktree。 + +IntentPacket: + +Intent: +- Add read-only `yoi mcp` CLI inspection namespace for configured/resolved MCP servers and provider-discovered tools/resources/prompts eligibility。 +- Provide bounded human-readable and JSON reports without bypassing runtime Tool/resource/prompt paths。 + +Binding decisions / invariants: +- CLI inspection must not start MCP server processes。 +- CLI inspection must not call MCP tools or fetch resource/prompt content。 +- Static config/resolution and live Pod state must be distinguished explicitly。 +- If live state is unavailable/unimplemented, output must say `not live` / `unavailable`, not silently stale。 +- Secrets, resolved secret/env values, resource contents, and prompt full text must not be printed in human or JSON output。 +- External server descriptions/schemas/annotations are untrusted and bounded/truncated。 +- Keep MCP separate from Plugin CLI namespace。 +- No Streamable HTTP/OAuth/sampling/elicitation/install/update/distribution implementation。 + +Requirements / acceptance criteria: +- `yoi --help` shows MCP CLI namespace。 +- `yoi mcp list --json` returns resolved MCP server bounded structured report。 +- `yoi mcp show --json` returns server identity, transport kind, trust policy summary, capabilities summary, diagnostics。 +- `yoi mcp tools [] --json` returns Yoi stable tool name, MCP server/tool identity, schema availability, registration status/diagnostics。 +- `yoi mcp resources [] --json` and `yoi mcp prompts [] --json` return summaries/eligibility without content fetch。 +- Human-readable output distinguishes empty/missing/invalid/unavailable。 +- Focused CLI tests cover list/show/tools/resources/prompts, missing server, invalid config, JSON output, and secret/content non-leakage。 +- Validation includes fmt, focused tests, check, diff, TicketDoctor, Nix build。 + +Next action: +- Record `queued -> inprogress` and commit Ticket records before creating implementation worktree and spawning Coder。 + +--- + + + +## State changed + +Ticket body/thread, relation metadata, Orchestrator worktree state, visible Pods, existing branch/worktree, and bounded MCP CLI inspection context were checked. There is no unresolved blocking dependency, no direct conflict with current Plugin instance lifecycle work, no matching worktree/branch, and no missing planning decision. Accepting this queued Ticket for parallel implementation before worktree/Pod side effects. + +--- + + + +## Implementation report + +Implementation orchestration start: +- Acceptance records were committed on Orchestrator branch: `12d7e69f ticket: accept mcp cli inspection`。 +- Created implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVJKHAFE-mcp-cli-inspection` on branch `impl/00001KVJKHAFE-mcp-cli-inspection` at `12d7e69f`。 +- Spawned Coder Pod `yoi-coder-00001KVJKHAFE` with write scope limited to the implementation worktree and read-only root scope only for runtime workspace identity。 +- Coder task explicitly forbids root/original workspace implementation, MCP server process startup, tools/call/resource/prompt content fetch, Plugin CLI namespace mixing, merge, Ticket close, and cleanup。 + +Next action: +- Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。 + +--- + + + +## Implementation report + +Coder implementation report received from `yoi-coder-00001KVJKHAFE`. + +Implementation commit: +- `c91f5fc9 mcp: add cli inspection` + +Changed areas reported: +- Added `crates/yoi/src/mcp_cli.rs`: + - Read-only `yoi mcp` inspection reports。 + - Commands: `list`, `show`, `tools`, `resources`, `prompts`。 + - Human and `--json` output。 + - Static resolved config inspection only; no MCP server processes are started。 + - Live/provider-discovered state is explicitly reported as `not_live` / `unavailable`。 + - Env values, secret refs, env refs, args, resource content, and prompt content are redacted/omitted。 + - Resource/prompt operation eligibility is reported without content fetch。 +- Updated `crates/yoi/src/main.rs`: + - Added `mcp` namespace to parser, dispatcher, help output, and parser tests。 + +Coder validation reported: +- `cargo test -p yoi mcp`: passed。 +- `cargo check -p yoi`: passed。 +- `cargo fmt --check`: passed。 +- `git diff --check`: passed。 +- `cargo run -p yoi -- --help | grep -E 'yoi mcp (list|show|tools)'`: passed; help shows MCP namespace。 +- `yoi ticket doctor`: passed。 + +Package impact: +- No dependency, lockfile, source-filter, or `package.nix` changes。 +- Nix not run because packaging inputs were not changed。 + +Known risks / deferrals: +- Live Pod MCP state is not integrated; provider-discovered tools/resources/prompts are therefore reported as `not_live` / `unavailable` rather than stale or fabricated。 +- `tools` cannot list Yoi stable provider tool names without live discovery; JSON schema includes future item shape, but item list is empty in static mode。 +- Touched `crates/yoi/src/main.rs` CLI parser/help, so there is possible narrow conflict risk with parallel Plugin Service/Ingress work. No Plugin Service/Ingress files were touched。 + +Orchestrator evidence checked before review dispatch: +- Implementation worktree is clean。 +- HEAD is `c91f5fc9`。 +- Diff from acceptance `12d7e69f..HEAD` is one implementation commit touching 2 files, about 1438 insertions / 1 deletion。 +- `git diff --check 12d7e69f..HEAD` produced no diagnostics。 + +Next action: +- Dispatch Reviewer for r1 review against Ticket requirements, with focus on read-only inspection boundary, no process start/no tools-call/no resource-prompt content fetch, static vs live unavailable honesty, redaction, JSON/human shape, help/tests, and conflict risk。 + +--- + + + +## Plan + +Review dispatch: +- Spawned Reviewer Pod `yoi-reviewer-00001KVJKHAFE-r1` against implementation branch `impl/00001KVJKHAFE-mcp-cli-inspection`。 +- Review target commit: `c91f5fc9 mcp: add cli inspection`。 +- Review baseline: `12d7e69f`。 +- Reviewer task focuses on read-only inspection boundary, no process start/no tools-call/no resource/prompt content fetch, static vs live unavailable honesty, secret/content redaction, JSON/human report shape, namespace separation from Plugin CLI, help/tests, scope creep absence, and parallel CLI merge conflict risk。 +- Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。 + +--- + + + +## Review: approve + +Verdict: `approve` + +確認範囲: +- Ticket contract / Orchestrator IntentPacket。 +- Implementation diff: `12d7e69f..c91f5fc9`。 +- 主な対象: + - `crates/yoi/src/mcp_cli.rs` + - `crates/yoi/src/main.rs` +- MCP CLI path の process spawn / MCP client / tool-call / resource-read / prompt-get behavior を検索。 +- `crates/yoi/src/main.rs` の merge conflict risk も確認。 + +Blocking issues: none。 + +Approval evidence: +- MCP CLI は static/read-only に留まっている。 + - `mcp_cli.rs` は manifest/profile config types を使い、MCP runtime/client path を import していない。 + - `inspect_static_config` は Profile/config を resolve して static-only diagnostics を report し、stdio lifecycle/client API を呼ばない。 + - CLI implementation から `std::process::Command`, `Command::new`, `spawn`, MCP lifecycle/client calls, ToolRegistry calls, `tools/call` path は見つからない。 +- Resource/prompt content fetch はない。 + - `resources/read` / `prompts/get` は operation label / stable name としてだけ出ており、`registration_status: "not_live"`, `content_state: "not_fetched"` を返す。 +- Static vs live state is explicit。 + - JSON includes `inspection_mode` booleans: `starts_servers: false`, `calls_tools: false`, `fetches_resource_or_prompt_content: false`。 + - Reports include `live_state.status = "not_live"`; human output says `live: not live / unavailable`。 +- Secrets/args are redacted。 + - stdio args are summarized as `arg_count` plus `args_redacted: true`。 + - env config is summarized by counts/kinds with `values_redacted: true`; env literal values, secret refs, and env refs are not emitted。 + - Tests cover non-leakage for arg secret, literal env value, secret ref, and env ref。 +- MCP namespace remains separate。 + - `main.rs` adds distinct top-level `mcp` parser/dispatcher path; Plugin CLI is not mixed beyond adjacency in top-level help/parsing。 +- Top-level `yoi --help` shows MCP namespace and commands。 + +Non-blocking concerns / follow-ups: +- Implementation is intentionally static-only. It does not surface real live provider-discovered tools/resources/prompts or live `notifications/*/list_changed` restart/reinitialize diagnostics. It clearly reports unavailable/not live, which matches this implementation boundary; future live-state inspection can extend it。 +- `yoi mcp --help` / subcommand help may flow through `ParseError` and error exit rather than normal help exit. Top-level `yoi --help` satisfies the stated acceptance criterion, so this is not blocking。 + +Reviewer validation: +- `cargo fmt --check`: passed。 +- `git diff --check 12d7e69f..HEAD`: passed。 +- `cargo test -p yoi mcp`: passed, 8 tests。 +- `cargo check -p yoi`: passed。 +- `cargo run -p yoi -- --help`: passed; output includes `yoi mcp list`, `show`, and `tools|resources|prompts`。 +- `cargo run -p yoi -- ticket doctor`: passed, `doctor: ok`。 +- Conflict-risk check: `main.rs` merge risk low; trivial merge check produced no conflict markers。 +- `nix build .#yoi --no-link` not run because no package/source-filter concern was found。 + +Worktree status at review end: source tree clean; ignored `target/` only。 + +--- + + + +## Implementation report + +Merge and final validation report: + +Merged implementation branch into Orchestrator branch: +- Merge commit: `5e0b023a merge: mcp cli inspection` +- Implementation commit included: + - `c91f5fc9 mcp: add cli inspection` + +Reviewer outcome: +- r1 approved with no blocking issues。 +- Reviewer confirmed CLI inspection is static/read-only, does not start MCP server processes, does not call tools/resources/prompts, distinguishes static vs live unavailable state, redacts/omits secrets/env/args/content, keeps MCP namespace separate from Plugin CLI, and has focused tests。 + +Orchestrator validation after merge passed: +- `cargo fmt --check` +- `git diff --check HEAD^1..HEAD` +- `cargo test -p yoi mcp` +- `cargo check -p yoi` +- `cargo run -q -p yoi -- --help` with targeted grep for MCP commands。 +- `TicketDoctor`: 0 errors, 4 existing warnings unrelated to this Ticket。 + +Validation log: +- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-xrqves.log` + +Nix validation: +- Not run because this Ticket changed only Rust source under `crates/yoi` and no dependency/package/source-filter files changed。 + +Final state: +- Orchestrator worktree clean at `5e0b023a` after validation。 +- Implementation worktree remains available for cleanup after Ticket completion records are committed。 +- Plugin instance lifecycle Coder continues in parallel。 + +--- + + + +## State changed + +Implementation was merged into Orchestrator branch at `5e0b023a`, review approved, and final Orchestrator validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p yoi mcp`, `cargo check -p yoi`, top-level help grep for MCP commands, and `TicketDoctor` with 0 errors. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +## Resolution + +`00001KVJKHAFE` を完了しました。 + +実装内容: +- `yoi mcp` CLI namespace を追加しました。 +- Read-only inspection commands を追加しました。 + - `yoi mcp list` + - `yoi mcp show ` + - `yoi mcp tools []` + - `yoi mcp resources []` + - `yoi mcp prompts []` +- Human-readable output と `--json` output を追加しました。 +- Inspection は static/resolved config のみを扱い、MCP server process を起動しません。 +- `tools/call`, `resources/read`, `prompts/get` は実行しません。 +- Live/provider-discovered state は `not_live` / `unavailable` と明示します。 +- Env values, secret refs, env refs, args, resource content, prompt content は redacted/omitted します。 +- Resource/prompt operation eligibility は content fetch なしで報告します。 +- MCP namespace は Plugin CLI namespace と分離したままです。 + +主な commit: +- `c91f5fc9 mcp: add cli inspection` +- `5e0b023a merge: mcp cli inspection` + +Review: +- r1 は `approve`。 +- Reviewer は read-only boundary、no process start、no tools/resource/prompt content fetch、static-vs-live unavailable state、secret/content redaction、MCP namespace separation、help/tests を確認しました。 + +最終 validation: +- `cargo fmt --check` +- `git diff --check HEAD^1..HEAD` +- `cargo test -p yoi mcp` +- `cargo check -p yoi` +- `cargo run -q -p yoi -- --help` + MCP command grep +- `TicketDoctor`: 0 errors + +Known unrelated note: +- `TicketDoctor` は既存 Ticket の warning 4 件を返しましたが、この Ticket の変更とは無関係です。 + +Nix validation: +- Not run because no dependency/package/source-filter files changed。 + +Validation log: +- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-xrqves.log` + --- diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 1ca3f2e7..6f062d4d 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -57,6 +57,40 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ }, ]; +/// Embedded starter template for Rust Component Model instance Plugins. +pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[ + PluginTemplateResource { + path: "Cargo.toml", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/Cargo.toml" + ), + }, + PluginTemplateResource { + path: "src/lib.rs", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/src/lib.rs" + ), + }, + PluginTemplateResource { + path: "plugin.toml", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/plugin.toml" + ), + }, + PluginTemplateResource { + path: "plugin.component.wasm", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/plugin.component.wasm" + ), + }, + PluginTemplateResource { + path: "README.md", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/README.md" + ), + }, +]; + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PluginConfig { @@ -170,6 +204,8 @@ pub enum PluginPermission { Surface { surface: PluginSurface }, Tool { name: String }, ToolNamespace { namespace: String }, + Service { name: String }, + Ingress { name: String }, ExternalWrite, HostApi { api: PluginHostApi }, } @@ -249,6 +285,8 @@ impl PluginPermission { Self::Surface { surface } => format!("surfaces.{surface}"), Self::Tool { name } => format!("tool.{name}"), Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"), + Self::Service { name } => format!("service.{name}"), + Self::Ingress { name } => format!("ingress.{name}"), Self::ExternalWrite => "external_write".to_string(), Self::HostApi { api } => format!("host_api.{api}"), } @@ -268,6 +306,14 @@ impl PluginPermission { } } + pub fn service(name: impl Into) -> Self { + Self::Service { name: name.into() } + } + + pub fn ingress(name: impl Into) -> Self { + Self::Ingress { name: name.into() } + } + pub fn host_api(api: PluginHostApi) -> Self { Self::HostApi { api } } @@ -382,7 +428,7 @@ pub enum PluginIdParseError { InvalidLocalId, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginPackageManifest { pub schema_version: u32, @@ -398,6 +444,10 @@ pub struct PluginPackageManifest { pub hooks: Vec, #[serde(default)] pub tools: Vec, + #[serde(default)] + pub services: Vec, + #[serde(default)] + pub ingresses: Vec, /// Permission requests declared by the package. These are requests only; /// enablement grants must match them before runtime surfaces are exposed. #[serde(default)] @@ -413,6 +463,12 @@ impl PluginPackageManifest { if !self.tools.is_empty() { surfaces.insert(PluginSurface::Tool); } + if !self.services.is_empty() { + surfaces.insert(PluginSurface::Service); + } + if !self.ingresses.is_empty() { + surfaces.insert(PluginSurface::Ingress); + } if self.runtime.is_some() { surfaces.insert(PluginSurface::Wasm); } @@ -429,6 +485,7 @@ pub const PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1"; /// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`. pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component"; pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; +pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -464,6 +521,34 @@ pub struct PluginToolManifest { pub external_write: bool, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginServiceManifest { + pub name: String, + pub description: String, + #[serde(default)] + pub lifecycle: String, + #[serde(default)] + pub status_schema: Option, + #[serde(default)] + pub side_effects: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginIngressManifest { + pub name: String, + pub description: String, + #[serde(default)] + pub event_kinds: Vec, + #[serde(default)] + pub input_schema: Option, + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub side_effects: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PluginDiscoveryLimits { pub max_packages_per_store: usize, @@ -514,7 +599,7 @@ impl PluginDiscoveryOptions { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct DiscoveredPluginPackage { pub identity: SourceQualifiedPluginId, pub package_path: PathBuf, @@ -529,19 +614,19 @@ pub struct DiscoveredPluginPackage { /// This is data-only metadata and bytes. Constructing it parses manifests and /// validates package/archive shape, but it does not load, instantiate, or /// execute Plugin code. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct MaterializedPluginPackage { pub package: DiscoveredPluginPackage, pub files: BTreeMap>, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct PackedPluginPackage { pub output_path: PathBuf, pub package: DiscoveredPluginPackage, } -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct PluginDiscoveryReport { pub packages: Vec, pub diagnostics: Vec, @@ -1072,7 +1157,10 @@ pub fn read_resolved_plugin_runtime_component( .with_package(&record.package_label) .with_digest(&record.digest)); } - if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) { + if !matches!( + runtime.world.as_deref(), + Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ) { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Api, PluginDiagnosticPhase::Manifest, @@ -1918,7 +2006,10 @@ fn validate_manifest( .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) .with_package(label)); } - if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) { + if !matches!( + runtime.world.as_deref(), + Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ) { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Api, PluginDiagnosticPhase::Manifest, @@ -1952,6 +2043,44 @@ fn validate_manifest( } } } + let instance_capable = manifest.runtime.as_ref().is_some_and(|runtime| { + runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND + && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + }); + if (!manifest.services.is_empty() || !manifest.ingresses.is_empty()) && !instance_capable { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Surface, + PluginDiagnosticPhase::Manifest, + "plugin service/ingress declarations require the yoi:plugin/instance@1.0.0 component world", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + for service in &manifest.services { + if !is_safe_id(&service.name) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Manifest, + "plugin service name is not safe", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + } + for ingress in &manifest.ingresses { + if !is_safe_id(&ingress.name) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Manifest, + "plugin ingress name is not safe", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + } for hook in &manifest.hooks { if !is_safe_id(&hook.id) { return Err(PluginDiagnostic::new( @@ -2425,7 +2554,13 @@ mod tests { .collect(); assert_eq!( paths, - BTreeSet::from(["Cargo.toml", "src/lib.rs", "plugin.toml", "README.md"]) + BTreeSet::from([ + "Cargo.toml", + "src/lib.rs", + "plugin.toml", + "plugin.component.wasm", + "README.md", + ]) ); assert!( RUST_COMPONENT_TOOL_TEMPLATE @@ -2451,6 +2586,86 @@ mod tests { assert_eq!(manifest.tools.len(), 1); } + #[test] + fn embedded_rust_component_instance_template_is_valid_package_shape() { + let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE + .iter() + .map(|file| file.path) + .collect(); + assert_eq!( + paths, + BTreeSet::from([ + "Cargo.toml", + "src/lib.rs", + "plugin.toml", + "plugin.component.wasm", + "README.md" + ]) + ); + assert!( + RUST_COMPONENT_INSTANCE_TEMPLATE + .iter() + .all(|file| !file.path.starts_with('/') && !file.path.contains("..")) + ); + let manifest_text = RUST_COMPONENT_INSTANCE_TEMPLATE + .iter() + .find(|file| file.path == "plugin.toml") + .unwrap() + .contents; + let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap(); + assert_eq!( + manifest.runtime.as_ref().unwrap().world.as_deref(), + Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ); + assert_eq!(manifest.services.len(), 1); + assert_eq!(manifest.ingresses.len(), 1); + assert!( + manifest + .declared_surfaces() + .contains(&PluginSurface::Service) + ); + assert!( + manifest + .declared_surfaces() + .contains(&PluginSurface::Ingress) + ); + } + + #[test] + fn service_ingress_require_instance_component_world() { + let manifest: PluginPackageManifest = toml::from_str( + r#" +schema_version = 1 +id = "bad.service" +name = "Bad Service" +version = "0.1.0" +surfaces = ["service"] +permissions = [{ kind = "surface", surface = "service" }, { kind = "service", name = "svc" }] + +[runtime] +kind = "wasm-component" +world = "yoi:plugin/tool@1.0.0" +component = "plugin.component.wasm" + +[[services]] +name = "svc" +description = "bad" +"#, + ) + .unwrap(); + let archive = StoredArchive { + files: BTreeMap::from([("plugin.component.wasm".to_string(), b"placeholder".to_vec())]), + }; + let err = validate_manifest( + &manifest, + &archive, + "bad.service", + PluginSourceKind::Project, + ) + .unwrap_err(); + assert!(err.message.contains("service/ingress")); + } + #[test] fn discovers_valid_user_and_workspace_packages() { let temp = TempDir::new().unwrap(); diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs index 69f9edd7..8e2fd4fc 100644 --- a/crates/plugin-pdk/src/lib.rs +++ b/crates/plugin-pdk/src/lib.rs @@ -38,6 +38,8 @@ use serde_json::Value; pub use wit_bindgen; +pub type Result = std::result::Result; + /// Current Yoi Component Model Tool world targeted by this PDK. pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; @@ -98,7 +100,10 @@ impl ToolOutput { } /// Create a Tool output whose content is typed JSON. - pub fn json(summary: impl Into, value: impl Serialize) -> Result { + pub fn json( + summary: impl Into, + value: impl Serialize, + ) -> std::result::Result { let content = serde_json::to_string(&value).map_err(ToolError::serialization)?; let output = Self { summary: normalize_summary(summary.into()), @@ -292,7 +297,7 @@ impl ToolError { } /// Parse the WIT `input-json` string into a typed input value. -pub fn parse_json_input(input_json: &str) -> Result +pub fn parse_json_input(input_json: &str) -> std::result::Result where T: DeserializeOwned, { @@ -311,7 +316,7 @@ where pub fn run_json_tool(tool_name: &str, input_json: &str, handler: F) -> String where I: DeserializeOwned, - F: FnOnce(ToolContext, I) -> Result, + F: FnOnce(ToolContext, I) -> std::result::Result, { let result = parse_json_input::(input_json).and_then(|input| { let context = ToolContext::new(tool_name); @@ -474,3 +479,166 @@ mod tests { assert!(HOST_WIT.contains("%list: func")); } } + +/// Versioned Component Model instance world handled by the host-managed +/// PluginInstanceRegistry. +pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; + +/// Repository WIT for the current instance world. +pub const INSTANCE_WIT: &str = + include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit"); + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct PluginIngressEvent { + pub kind: String, + pub source: String, + #[serde(default)] + pub payload: Value, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct PluginStatus { + pub state: String, + #[serde(default)] + pub data: Value, +} + +impl PluginStatus { + pub fn ready(data: Value) -> Self { + Self { + state: "ready".to_string(), + data, + } + } + + pub fn stopped() -> Self { + Self { + state: "stopped".to_string(), + data: Value::Null, + } + } +} + +/// Rust-facing instance Plugin contract. Hosts call `start` once, then route +/// Tool/Ingress surfaces through the same mutable instance. +pub trait Plugin: Sized + 'static { + fn start(config: Value) -> Result; + fn handle_tool(&mut self, name: &str, input: Value) -> Result; + fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result; + fn status(&self) -> Result { + Ok(PluginStatus::ready(Value::Null)) + } + fn stop(&mut self) -> Result { + Ok(PluginStatus::stopped()) + } +} + +#[doc(hidden)] +pub fn plugin_instance_error(message: impl Into) -> String { + serde_json::json!({ "error": { "message": message.into() } }).to_string() +} + +#[doc(hidden)] +pub fn plugin_instance_status(status: &PluginStatus) -> String { + serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string())) +} + +/// Implement the generated Component Model `Guest` trait for an instance Plugin +/// and export it with the `wit-bindgen` generated `export!` macro. +/// +/// The caller must invoke `wit_bindgen::generate!` for the `instance` world +/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines +/// the `Guest` trait and `export!` macro in the current module. +#[macro_export] +macro_rules! export_plugin_instance { + ($adapter:ident, $plugin:ty) => { + struct $adapter; + + thread_local! { + static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) }; + } + + impl Guest for $adapter { + fn start(config_json: ::std::string::String) -> ::std::string::String { + let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null); + match <$plugin as $crate::Plugin>::start(config) { + Ok(plugin) => { + YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin)); + $crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null)) + } + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + } + + fn handle_tool( + name: ::std::string::String, + input_json: ::std::string::String, + ) -> ::std::string::String { + let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null); + YOI_PLUGIN_INSTANCE.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(plugin) = slot.as_mut() else { + return $crate::plugin_instance_error("plugin instance has not been started"); + }; + match plugin.handle_tool(&name, input) { + Ok(output) => output.to_json_string(), + Err(error) => error.into_tool_output().to_json_string(), + } + }) + } + + fn handle_ingress( + name: ::std::string::String, + event_json: ::std::string::String, + ) -> ::std::string::String { + let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) { + Ok(event) => event, + Err(error) => return $crate::plugin_instance_error(error.to_string()), + }; + YOI_PLUGIN_INSTANCE.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(plugin) = slot.as_mut() else { + return $crate::plugin_instance_error("plugin instance has not been started"); + }; + match plugin.handle_ingress(&name, event) { + Ok(output) => serde_json::to_string(&output) + .unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())), + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + }) + } + + fn status() -> ::std::string::String { + YOI_PLUGIN_INSTANCE.with(|slot| { + let slot = slot.borrow(); + let Some(plugin) = slot.as_ref() else { + return $crate::plugin_instance_error("plugin instance has not been started"); + }; + match plugin.status() { + Ok(status) => $crate::plugin_instance_status(&status), + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + }) + } + + fn stop() -> ::std::string::String { + YOI_PLUGIN_INSTANCE.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(plugin) = slot.as_mut() else { + return $crate::plugin_instance_error("plugin instance has not been started"); + }; + match plugin.stop() { + Ok(status) => { + let output = $crate::plugin_instance_status(&status); + *slot = None; + output + } + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + }) + } + } + + export!($adapter); + }; +} diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index 3ea94c52..6ff66327 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -21,10 +21,10 @@ use llm_worker::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, }; use manifest::plugin::{ - PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, PLUGIN_RUNTIME_WASM_ABI, - PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant, - PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest, - ResolvedPluginRecord, read_resolved_plugin_runtime_component, + PLUGIN_COMPONENT_INSTANCE_WORLD, PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, + PLUGIN_RUNTIME_WASM_ABI, PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, + PluginFsGrant, PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, + PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_component, read_resolved_plugin_runtime_module, }; use serde::{Deserialize, Serialize}; @@ -32,7 +32,7 @@ use serde_json::Value; use super::{ FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule, - FeatureRuntimeKind, ToolContribution, ToolDeclaration, + FeatureRuntimeKind, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, }; /// Build Feature modules for enabled plugin packages when the profile exposes @@ -47,29 +47,67 @@ pub fn plugin_tool_features_if_enabled( plugin_tool_features(config) } -/// Build Feature modules for enabled plugin packages that declare Tool surfaces. +/// Build Feature modules for enabled plugin packages that declare Tool/Service/Ingress surfaces. pub fn plugin_tool_features(config: &PluginConfig) -> Vec { config .resolved .iter() - .filter(|record| record.enabled_surfaces.contains(&PluginSurface::Tool)) - .filter(|record| !record.manifest.tools.is_empty()) + .filter(|record| { + record.enabled_surfaces.contains(&PluginSurface::Tool) + || record.enabled_surfaces.contains(&PluginSurface::Service) + || record.enabled_surfaces.contains(&PluginSurface::Ingress) + }) + .filter(|record| { + !record.manifest.tools.is_empty() + || !record.manifest.services.is_empty() + || !record.manifest.ingresses.is_empty() + }) .cloned() .map(PluginToolFeature::new) .collect() } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct PluginToolFeature { record: ResolvedPluginRecord, feature_id: FeatureId, + registry: PluginInstanceRegistry, } 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 } + Self { + record, + feature_id, + registry: PluginInstanceRegistry::default(), + } + } + + fn ensure_instance(&self) -> Result { + self.registry.register(self.record.clone()) + } + + pub fn instance_status(&self) -> Option { + self.registry.status(&self.record.identity.to_string()) + } + + pub fn dispatch_ingress( + &self, + ingress_name: &str, + event: PluginIngressEvent, + ) -> Result { + if !surface_enabled(&self.record, PluginSurface::Ingress) { + return Err(PluginWasmError::Module( + "plugin ingress surface is not enabled".to_string(), + )); + } + let handle = self + .registry + .handle(&self.record.identity.to_string()) + .ok_or_else(|| PluginWasmError::Module("plugin instance is not started".to_string()))?; + handle.deliver_ingress(ingress_name, event) } pub fn origin(&self) -> ToolOrigin { @@ -86,6 +124,28 @@ impl PluginToolFeature { } } +fn surface_enabled(record: &ResolvedPluginRecord, surface: PluginSurface) -> bool { + record.enabled_surfaces.contains(&surface) +} + +fn plugin_tool_origin(record: &ResolvedPluginRecord) -> ToolOrigin { + ToolOrigin { + kind: "plugin".into(), + plugin_id: record.manifest.id.clone(), + plugin_ref: record.identity.to_string(), + source: record.identity.source.to_string(), + digest: record.digest.clone(), + package_version: record.version.clone(), + package_api_version: record.manifest.schema_version, + surface: "tool".into(), + } +} + +fn plugin_service_id(record: &ResolvedPluginRecord, name: &str) -> ServiceId { + ServiceId::new(format!("plugin:{}:{name}", record.identity.to_string())) + .expect("plugin service id is generated from safe plugin identity/name") +} + /// Static, read-only eligibility information for a resolved plugin package. /// /// This inspection mirrors the registration-time permission checks without @@ -95,6 +155,8 @@ pub struct PluginStaticInspection { pub runtime: PluginRuntimeEligibility, pub host_apis: Vec, pub tools: Vec, + pub services: Vec, + pub ingresses: Vec, } impl PluginStaticInspection { @@ -102,6 +164,8 @@ impl PluginStaticInspection { self.runtime.eligible && self.host_apis.iter().all(|api| api.eligible) && self.tools.iter().all(|tool| tool.eligible) + && self.services.iter().all(|service| service.eligible) + && self.ingresses.iter().all(|ingress| ingress.eligible) } } @@ -132,6 +196,16 @@ pub struct PluginToolEligibility { pub diagnostic: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginSurfaceEligibility { + pub name: String, + pub permission: String, + pub requested: bool, + pub granted: bool, + pub eligible: bool, + pub diagnostic: Option, +} + /// Inspect static plugin runtime/tool eligibility without executing plugin code. pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection { let runtime = match &record.manifest.runtime { @@ -160,12 +234,22 @@ pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginSt } Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND - && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD) + && matches!( + runtime.world.as_deref(), + Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ) && runtime.component.is_some() => { PluginRuntimeEligibility { eligible: true, - status: format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}"), + status: format!( + "{}/{}", + PLUGIN_RUNTIME_COMPONENT_KIND, + runtime + .world + .as_deref() + .unwrap_or(PLUGIN_COMPONENT_TOOL_WORLD) + ), diagnostic: None, } } @@ -219,35 +303,108 @@ pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginSt .collect(); let duplicate_tool_names = duplicate_tool_names(record); - let tools = record - .manifest - .tools - .iter() - .map(|tool| { - let permission = PluginPermission::tool(&tool.name); - let requested = permission_requested(record, &permission); - let granted = grant_allows(record, &permission); - let mut diagnostics = validate_plugin_tool_definition(tool, &duplicate_tool_names); - if let Err(error) = authorize_plugin_tool(record, tool) { - diagnostics.push(error.bounded_message()); - } - let diagnostic = join_tool_diagnostics(diagnostics); - PluginToolEligibility { - name: tool.name.clone(), - permission: permission.label(), - requested, - granted, - eligible: diagnostic.is_none(), - external_write: tool.external_write, - diagnostic, - } - }) - .collect(); + let tools = if surface_enabled(record, PluginSurface::Tool) { + record + .manifest + .tools + .iter() + .map(|tool| { + let permission = PluginPermission::tool(&tool.name); + let requested = permission_requested(record, &permission); + let granted = grant_allows(record, &permission); + let mut diagnostics = validate_plugin_tool_definition(tool, &duplicate_tool_names); + if let Err(error) = authorize_plugin_tool(record, tool) { + diagnostics.push(error.bounded_message()); + } + let diagnostic = join_tool_diagnostics(diagnostics); + PluginToolEligibility { + name: tool.name.clone(), + permission: permission.label(), + requested, + granted, + eligible: diagnostic.is_none(), + external_write: tool.external_write, + diagnostic, + } + }) + .collect() + } else { + Vec::new() + }; + + let instance_world = record.manifest.runtime.as_ref().is_some_and(|runtime| { + runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND + && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + }); + let services = if surface_enabled(record, PluginSurface::Service) { + record + .manifest + .services + .iter() + .map(|service| { + let permission = PluginPermission::service(&service.name); + let requested = permission_requested(record, &permission); + let granted = grant_allows(record, &permission); + let mut diagnostics = Vec::new(); + if !instance_world { + diagnostics + .push("service requires instance-capable component world".to_string()); + } + if let Err(error) = authorize_plugin_service(record, &service.name) { + diagnostics.push(error.bounded_message()); + } + let diagnostic = join_tool_diagnostics(diagnostics); + PluginSurfaceEligibility { + name: service.name.clone(), + permission: permission.label(), + requested, + granted, + eligible: diagnostic.is_none(), + diagnostic, + } + }) + .collect() + } else { + Vec::new() + }; + let ingresses = if surface_enabled(record, PluginSurface::Ingress) { + record + .manifest + .ingresses + .iter() + .map(|ingress| { + let permission = PluginPermission::ingress(&ingress.name); + let requested = permission_requested(record, &permission); + let granted = grant_allows(record, &permission); + let mut diagnostics = Vec::new(); + if !instance_world { + diagnostics + .push("ingress requires instance-capable component world".to_string()); + } + if let Err(error) = authorize_plugin_ingress(record, &ingress.name) { + diagnostics.push(error.bounded_message()); + } + let diagnostic = join_tool_diagnostics(diagnostics); + PluginSurfaceEligibility { + name: ingress.name.clone(), + permission: permission.label(), + requested, + granted, + eligible: diagnostic.is_none(), + diagnostic, + } + }) + .collect() + } else { + Vec::new() + }; PluginStaticInspection { runtime, host_apis, tools, + services, + ingresses, } } @@ -327,57 +484,134 @@ impl FeatureModule for PluginToolFeature { 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(), - )); + if surface_enabled(&self.record, PluginSurface::Service) { + for service in &self.record.manifest.services { + descriptor.provides_services.push(ServiceDeclaration::new( + plugin_service_id(&self.record, &service.name), + self.record.manifest.version.clone(), + service.description.clone(), + )); + } + } + if surface_enabled(&self.record, PluginSurface::Tool) { + 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(); - let mut registered = 0usize; - let mut denied = Vec::new(); - 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 - )) - })?; - if let Err(error) = authorize_plugin_tool(&self.record, tool) { - let message = format!( - "plugin `{}` tool `{}` registration denied: {}", - self.record.identity, - tool.name, - error.bounded_message() - ); - context.diagnostics().warning(message.clone()); - denied.push(message); - continue; - } - context.tools().register(ToolContribution::new( - tool.name.clone(), - plugin_wasm_tool_definition( - self.record.clone(), - tool.name.clone(), - tool.description.clone(), - tool.input_schema.clone(), - origin.clone(), - ), - ))?; - registered += 1; + if surface_enabled(&self.record, PluginSurface::Tool) { + validate_declared_tool_names(&self.record)?; } - if registered == 0 && !denied.is_empty() { + let mut instance: Option = None; + let mut exposed = 0usize; + let mut denied = Vec::new(); + if surface_enabled(&self.record, PluginSurface::Service) { + for service in &self.record.manifest.services { + validate_tool_name(&service.name).map_err(|reason| { + FeatureInstallError::Install(format!( + "plugin {} service {} has invalid name: {reason}", + self.record.identity, service.name + )) + })?; + if let Err(error) = authorize_plugin_service(&self.record, &service.name) { + let message = format!( + "plugin {} service {} registration denied: {}", + self.record.identity, + service.name, + error.bounded_message() + ); + context.diagnostics().warning(message.clone()); + denied.push(message); + continue; + } + if instance.is_none() { + instance = Some(self.ensure_instance()?); + } + context.services().provide(ServiceDeclaration::new( + plugin_service_id(&self.record, &service.name), + self.record.manifest.version.clone(), + service.description.clone(), + ))?; + exposed += 1; + } + } + if surface_enabled(&self.record, PluginSurface::Ingress) { + for ingress in &self.record.manifest.ingresses { + validate_tool_name(&ingress.name).map_err(|reason| { + FeatureInstallError::Install(format!( + "plugin {} ingress {} has invalid name: {reason}", + self.record.identity, ingress.name + )) + })?; + if let Err(error) = authorize_plugin_ingress(&self.record, &ingress.name) { + let message = format!( + "plugin {} ingress {} registration denied: {}", + self.record.identity, + ingress.name, + error.bounded_message() + ); + context.diagnostics().warning(message.clone()); + denied.push(message); + } else { + if instance.is_none() { + instance = Some(self.ensure_instance()?); + } + exposed += 1; + } + } + } + if surface_enabled(&self.record, PluginSurface::Tool) { + 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 + )) + })?; + if let Err(error) = authorize_plugin_tool(&self.record, tool) { + let message = format!( + "plugin {} tool {} registration denied: {}", + self.record.identity, + tool.name, + error.bounded_message() + ); + context.diagnostics().warning(message.clone()); + denied.push(message); + continue; + } + let tool_instance = match &instance { + Some(instance) => instance.clone(), + None => { + let created = self.ensure_instance()?; + instance = Some(created.clone()); + created + } + }; + context.tools().register(ToolContribution::new( + tool.name.clone(), + plugin_instance_tool_definition( + tool_instance, + tool.name.clone(), + tool.description.clone(), + tool.input_schema.clone(), + ), + ))?; + exposed += 1; + } + } + if exposed == 0 && !denied.is_empty() { let summary = if denied.len() == 1 { denied.remove(0) } else { @@ -1416,6 +1650,64 @@ fn authorize_plugin_tool( Ok(()) } +fn authorize_plugin_service( + record: &ResolvedPluginRecord, + service_name: &str, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + require_permission( + &record.manifest.permissions, + &PluginPermission::surface(PluginSurface::Service), + "requested surfaces.service permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::surface(PluginSurface::Service), + "granted surfaces.service permission is missing", + )?; + let permission = PluginPermission::service(service_name); + require_permission( + &record.manifest.permissions, + &permission, + &format!("requested service permission for `{service_name}` is missing"), + )?; + require_permission( + &record.grants.permissions, + &permission, + &format!("granted service permission for `{service_name}` is missing"), + )?; + Ok(()) +} + +fn authorize_plugin_ingress( + record: &ResolvedPluginRecord, + ingress_name: &str, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + require_permission( + &record.manifest.permissions, + &PluginPermission::surface(PluginSurface::Ingress), + "requested surfaces.ingress permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::surface(PluginSurface::Ingress), + "granted surfaces.ingress permission is missing", + )?; + let permission = PluginPermission::ingress(ingress_name); + require_permission( + &record.manifest.permissions, + &permission, + &format!("requested ingress permission for `{ingress_name}` is missing"), + )?; + require_permission( + &record.grants.permissions, + &permission, + &format!("granted ingress permission for `{ingress_name}` is missing"), + )?; + Ok(()) +} + fn authorize_plugin_host_api( record: &ResolvedPluginRecord, api: PluginHostApi, @@ -1692,21 +1984,598 @@ impl PluginHttpsError { } } -fn plugin_wasm_tool_definition( +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub enum PluginInstanceLifecycleState { + Ready, + Started, + Stopped, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginInstanceDiagnostic { + pub state: PluginInstanceLifecycleState, + pub message: String, +} + +impl PluginInstanceDiagnostic { + pub fn new(state: PluginInstanceLifecycleState, message: impl Into) -> Self { + Self { + state, + message: bounded_message(message.into()), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PluginInstanceStatus { + pub plugin_ref: String, + pub lifecycle: PluginInstanceLifecycleState, + pub component_status: Option, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PluginIngressEvent { + pub kind: String, + pub source: String, + pub payload: Value, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PluginIngressDispatchReport { + pub plugin_ref: String, + pub ingress: String, + pub accepted: bool, + pub output: Value, + pub diagnostics: Vec, +} + +#[derive(Clone, Default)] +pub struct PluginInstanceRegistry { + instances: Arc>>, +} + +impl PluginInstanceRegistry { + pub fn register( + &self, + record: ResolvedPluginRecord, + ) -> Result { + let key = record.identity.to_string(); + let mut instances = self + .instances + .lock() + .expect("plugin instance registry poisoned"); + if let Some(existing) = instances.get(&key) { + return Ok(existing.clone()); + } + let handle = PluginInstanceHandle::new(record).map_err(|error| { + FeatureInstallError::Install(format!( + "plugin instance startup failed closed: {}", + error.bounded_message() + )) + })?; + instances.insert(key, handle.clone()); + Ok(handle) + } + + pub fn status(&self, plugin_ref: &str) -> Option { + self.instances + .lock() + .expect("plugin instance registry poisoned") + .get(plugin_ref) + .map(PluginInstanceHandle::status) + } + + pub fn handle(&self, plugin_ref: &str) -> Option { + self.instances + .lock() + .expect("plugin instance registry poisoned") + .get(plugin_ref) + .cloned() + } + + pub fn stop(&self, plugin_ref: &str) -> Result, PluginWasmError> { + let handle = self + .instances + .lock() + .expect("plugin instance registry poisoned") + .get(plugin_ref) + .cloned(); + handle.map(|handle| handle.stop()).transpose() + } +} + +#[derive(Clone)] +pub struct PluginInstanceHandle(Arc>); + +impl PluginInstanceHandle { + fn new(record: ResolvedPluginRecord) -> Result { + let runtime = PluginInstanceRuntime::new(&record)?; + let mut instance = PluginInstance { + record, + runtime, + lifecycle: PluginInstanceLifecycleState::Ready, + component_status: None, + diagnostics: Vec::new(), + }; + instance.start()?; + Ok(Self(Arc::new(Mutex::new(instance)))) + } + + fn handle_tool(&self, tool_name: &str, input: Vec) -> Result { + self.0 + .lock() + .expect("plugin instance poisoned") + .handle_tool(tool_name, input) + } + + pub fn deliver_ingress( + &self, + ingress_name: &str, + event: PluginIngressEvent, + ) -> Result { + self.0 + .lock() + .expect("plugin instance poisoned") + .deliver_ingress(ingress_name, event) + } + + pub fn status(&self) -> PluginInstanceStatus { + let mut instance = self.0.lock().expect("plugin instance poisoned"); + instance.status() + } + + pub fn stop(&self) -> Result { + let mut instance = self.0.lock().expect("plugin instance poisoned"); + instance.stop()?; + Ok(instance.snapshot_status()) + } + + fn record_diagnostic(&self, diagnostic: PluginInstanceDiagnostic) { + if let Ok(mut instance) = self.0.lock() { + instance.lifecycle = diagnostic.state.clone(); + instance.diagnostics.push(diagnostic); + } + } +} + +struct PluginInstance { record: ResolvedPluginRecord, + runtime: PluginInstanceRuntime, + lifecycle: PluginInstanceLifecycleState, + component_status: Option, + diagnostics: Vec, +} + +impl PluginInstance { + fn start(&mut self) -> Result<(), PluginWasmError> { + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => { + self.lifecycle = PluginInstanceLifecycleState::Ready; + self.diagnostics.push(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Ready, + "legacy tool runtime adapted behind PluginInstanceRegistry", + )); + } + #[cfg(test)] + PluginInstanceRuntime::TestIngress { .. } => { + self.lifecycle = PluginInstanceLifecycleState::Started; + } + PluginInstanceRuntime::ComponentInstance(runtime) => { + let status = runtime.start(&self.record)?; + self.component_status = Some(status); + self.lifecycle = PluginInstanceLifecycleState::Started; + } + } + Ok(()) + } + + fn handle_tool( + &mut self, + tool_name: &str, + input: Vec, + ) -> Result { + if !surface_enabled(&self.record, PluginSurface::Tool) { + return Err(PluginWasmError::Module( + "plugin tool surface is not enabled".to_string(), + )); + } + let tool = self + .record + .manifest + .tools + .iter() + .find(|tool| tool.name == tool_name) + .ok_or_else(|| { + PluginWasmError::Module( + "requested tool is not declared by plugin manifest".to_string(), + ) + })?; + authorize_plugin_tool(&self.record, tool).map_err(|error| { + PluginWasmError::Module(format!( + "plugin permission denied: {}", + error.bounded_message() + )) + })?; + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => { + run_plugin_tool(self.record.clone(), tool_name.to_string(), input) + } + #[cfg(test)] + PluginInstanceRuntime::TestIngress { calls } => { + *calls += 1; + Ok(ToolOutput { + summary: format!("{tool_name}: {calls}"), + content: Some(String::from_utf8_lossy(&input).to_string()), + }) + } + PluginInstanceRuntime::ComponentInstance(runtime) => { + runtime.handle_tool(tool_name, input) + } + } + } + + fn deliver_ingress( + &mut self, + ingress_name: &str, + event: PluginIngressEvent, + ) -> Result { + if !surface_enabled(&self.record, PluginSurface::Ingress) { + return Err(PluginWasmError::Module( + "plugin ingress surface is not enabled".to_string(), + )); + } + if serde_json::to_vec(&event) + .map(|bytes| bytes.len()) + .unwrap_or(usize::MAX) + > PLUGIN_WASM_MAX_INPUT_BYTES + { + return Err(PluginWasmError::Module(format!( + "plugin ingress event exceeds {} bytes", + PLUGIN_WASM_MAX_INPUT_BYTES + ))); + } + self.record + .manifest + .ingresses + .iter() + .find(|ingress| ingress.name == ingress_name) + .ok_or_else(|| { + PluginWasmError::Module( + "requested ingress is not declared by plugin manifest".to_string(), + ) + })?; + authorize_plugin_ingress(&self.record, ingress_name).map_err(|error| { + PluginWasmError::Module(format!( + "plugin ingress permission denied: {}", + error.bounded_message() + )) + })?; + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => Err(PluginWasmError::Module( + "legacy tool runtime does not expose ingress dispatch".to_string(), + )), + #[cfg(test)] + PluginInstanceRuntime::TestIngress { calls } => { + let output = serde_json::json!({ + "ingress": ingress_name, + "kind": event.kind, + "source": event.source, + "calls": *calls, + "payload": event.payload, + }); + Ok(PluginIngressDispatchReport { + plugin_ref: self.record.identity.to_string(), + ingress: ingress_name.to_string(), + accepted: true, + output, + diagnostics: self.diagnostics.clone(), + }) + } + PluginInstanceRuntime::ComponentInstance(runtime) => { + let output = runtime.handle_ingress(ingress_name, &event)?; + Ok(PluginIngressDispatchReport { + plugin_ref: self.record.identity.to_string(), + ingress: ingress_name.to_string(), + accepted: true, + output, + diagnostics: self.diagnostics.clone(), + }) + } + } + } + + fn stop(&mut self) -> Result<(), PluginWasmError> { + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => {} + #[cfg(test)] + PluginInstanceRuntime::TestIngress { .. } => {} + PluginInstanceRuntime::ComponentInstance(runtime) => { + self.component_status = Some(runtime.stop()?); + } + } + self.lifecycle = PluginInstanceLifecycleState::Stopped; + Ok(()) + } + + fn snapshot_status(&self) -> PluginInstanceStatus { + PluginInstanceStatus { + plugin_ref: self.record.identity.to_string(), + lifecycle: self.lifecycle.clone(), + component_status: self.component_status.clone(), + diagnostics: self.diagnostics.clone(), + } + } + + fn status(&mut self) -> PluginInstanceStatus { + if let PluginInstanceRuntime::ComponentInstance(runtime) = &mut self.runtime { + match runtime.status() { + Ok(status) => self.component_status = Some(status), + Err(error) => { + self.lifecycle = PluginInstanceLifecycleState::Failed; + self.diagnostics.push(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Failed, + format!( + "plugin component status failed: {}", + error.bounded_message() + ), + )); + } + } + } + PluginInstanceStatus { + plugin_ref: self.record.identity.to_string(), + lifecycle: self.lifecycle.clone(), + component_status: self.component_status.clone(), + diagnostics: self.diagnostics.clone(), + } + } +} + +enum PluginInstanceRuntime { + LegacyToolAdapter, + #[cfg(test)] + TestIngress { + calls: u64, + }, + ComponentInstance(PluginComponentInstanceRuntime), +} + +impl PluginInstanceRuntime { + fn new(record: &ResolvedPluginRecord) -> Result { + let Some(runtime) = record.manifest.runtime.as_ref() else { + return Ok(Self::LegacyToolAdapter); + }; + match runtime.kind.as_str() { + #[cfg(test)] + "test-ingress" => Ok(Self::TestIngress { calls: 0 }), + PLUGIN_RUNTIME_WASM_KIND => Ok(Self::LegacyToolAdapter), + PLUGIN_RUNTIME_COMPONENT_KIND + if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) => + { + Ok(Self::ComponentInstance( + PluginComponentInstanceRuntime::instantiate(record)?, + )) + } + PLUGIN_RUNTIME_COMPONENT_KIND => Ok(Self::LegacyToolAdapter), + other => Err(PluginWasmError::Module(format!( + "unsupported plugin runtime kind `{other}`" + ))), + } + } +} + +struct PluginComponentInstanceRuntime { + store: wasmtime::Store, + instance: wasmtime::component::Instance, +} + +impl PluginComponentInstanceRuntime { + fn instantiate(record: &ResolvedPluginRecord) -> Result { + let limits = PluginDiscoveryLimits::default(); + let component_bytes = read_resolved_plugin_runtime_component(record, &limits) + .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; + if component_bytes.len() > limits.max_file_size_bytes as usize { + return Err(PluginWasmError::Package(format!( + "WASM component runtime artifact exceeds {} bytes", + limits.max_file_size_bytes + ))); + } + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + config.max_wasm_stack(8 * 1024 * 1024); + let engine = wasmtime::Engine::new(&config) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + let component = + wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| { + PluginWasmError::Module(format!("component is incompatible: {error:?}")) + })?; + validate_component_imports(record, &engine, &component)?; + let mut linker = wasmtime::component::Linker::::new(&engine); + define_plugin_component_host_imports(&mut linker)?; + let mut store = wasmtime::Store::new( + &engine, + PluginComponentHostState { + record: record.clone(), + https_client: Arc::new(ReqwestPluginHttpsClient), + store_limits: wasm_component_store_limits(), + }, + ); + store.limiter(|state| &mut state.store_limits); + store + .set_fuel(PLUGIN_WASM_FUEL) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + let instance = linker + .instantiate(&mut store, &component) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + Ok(Self { store, instance }) + } + + fn reset_fuel(&mut self) -> Result<(), PluginWasmError> { + self.store + .set_fuel(PLUGIN_WASM_FUEL) + .map_err(|error| PluginWasmError::Execution(error.to_string())) + } + + fn start(&mut self, record: &ResolvedPluginRecord) -> Result { + self.reset_fuel()?; + let start = self + .instance + .get_typed_func::<(&str,), (String,)>(&mut self.store, "start") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` start function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let config_json = plugin_config_json(record); + let (status,) = start + .call(&mut self.store, (&config_json,)) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + decode_plugin_lifecycle_output("start", &status) + } + + fn handle_tool( + &mut self, + tool_name: &str, + input: Vec, + ) -> Result { + self.reset_fuel()?; + let call = self + .instance + .get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-tool") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` handle-tool function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let input_json = std::str::from_utf8(&input).map_err(|error| { + PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}")) + })?; + let (output,) = call + .call(&mut self.store, (tool_name, input_json)) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + decode_plugin_wasm_output(output.as_bytes()) + } + + fn handle_ingress( + &mut self, + ingress_name: &str, + event: &PluginIngressEvent, + ) -> Result { + self.reset_fuel()?; + let call = self + .instance + .get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-ingress") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` handle-ingress function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let event_json = serde_json::to_string(event) + .map_err(|error| PluginWasmError::Output(error.to_string()))?; + let (output,) = call + .call(&mut self.store, (ingress_name, event_json.as_str())) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { + return Err(PluginWasmError::Output(format!( + "plugin ingress output exceeds {} bytes", + PLUGIN_WASM_MAX_OUTPUT_BYTES + ))); + } + serde_json::from_str(&output).map_err(|error| { + PluginWasmError::Output(format!("plugin ingress output is not JSON: {error}")) + }) + } + + fn stop(&mut self) -> Result { + self.reset_fuel()?; + let stop = self + .instance + .get_typed_func::<(), (String,)>(&mut self.store, "stop") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` stop function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let (status,) = stop + .call(&mut self.store, ()) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + decode_plugin_lifecycle_output("stop", &status) + } + + fn status(&mut self) -> Result { + self.reset_fuel()?; + let status = self + .instance + .get_typed_func::<(), (String,)>(&mut self.store, "status") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` status function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let (status,) = status + .call(&mut self.store, ()) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + decode_plugin_lifecycle_output("status", &status) + } +} + +fn decode_plugin_lifecycle_output(phase: &str, output: &str) -> Result { + if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { + return Err(PluginWasmError::Output(format!( + "plugin component {phase} output exceeds {} bytes", + PLUGIN_WASM_MAX_OUTPUT_BYTES + ))); + } + let value: Value = serde_json::from_str(output).map_err(|error| { + PluginWasmError::Output(format!( + "plugin component {phase} output is not JSON: {error}" + )) + })?; + if let Some(error) = value.get("error") { + return Err(PluginWasmError::Execution(format!( + "plugin component {phase} returned error: {}", + bounded_message(error.to_string()) + ))); + } + if value.get("state").and_then(Value::as_str) == Some("failed") { + return Err(PluginWasmError::Execution(format!( + "plugin component {phase} returned failed status: {}", + bounded_message(value.to_string()) + ))); + } + Ok(value) +} + +fn plugin_config_json(record: &ResolvedPluginRecord) -> String { + serde_json::to_string(&record.config).unwrap_or_else(|_| "{}".to_string()) +} + +fn plugin_instance_tool_definition( + instance: PluginInstanceHandle, name: String, description: String, input_schema: Value, - origin: ToolOrigin, ) -> ToolDefinition { + let origin = { + let guard = instance.0.lock().expect("plugin instance poisoned"); + plugin_tool_origin(&guard.record) + }; Arc::new(move || { ( ToolMeta::new(name.clone()) .description(description.clone()) .input_schema(input_schema.clone()) .origin(origin.clone()), - Arc::new(PluginWasmTool { - record: record.clone(), + Arc::new(PluginInstanceTool { + instance: instance.clone(), name: name.clone(), origin: origin.clone(), }) as Arc, @@ -1714,12 +2583,77 @@ fn plugin_wasm_tool_definition( }) } +struct PluginInstanceTool { + instance: PluginInstanceHandle, + name: String, + origin: ToolOrigin, +} + +#[async_trait] +impl Tool for PluginInstanceTool { + async fn execute( + &self, + input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + if input_json.len() > PLUGIN_WASM_MAX_INPUT_BYTES { + return Err(ToolError::InvalidArgument(format!( + "plugin tool `{}` input exceeds {} bytes", + self.name, PLUGIN_WASM_MAX_INPUT_BYTES + ))); + } + serde_json::from_str::(input_json).map_err(|error| { + ToolError::InvalidArgument(format!( + "plugin tool `{}` input is not valid JSON: {}", + self.name, + bounded_message(error.to_string()) + )) + })?; + let instance = self.instance.clone(); + let name = self.name.clone(); + let plugin_ref = self.origin.plugin_ref.clone(); + let digest = self.origin.digest.clone(); + let input = input_json.as_bytes().to_vec(); + let execution = tokio::task::spawn_blocking(move || instance.handle_tool(&name, input)); + match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await { + Ok(Ok(Ok(output))) => Ok(output), + Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!( + "plugin tool `{}` from `{}` (digest {}) failed closed: {}", + self.name, + plugin_ref, + digest, + error.bounded_message() + ))), + Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!( + "plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", + self.name, + plugin_ref, + digest, + bounded_message(error.to_string()) + ))), + Err(_) => { + self.instance + .record_diagnostic(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Failed, + format!("plugin tool timed out after {:?}", PLUGIN_WASM_TIMEOUT), + )); + Err(ToolError::ExecutionFailed(format!( + "plugin tool `{}` from `{}` (digest {}) timed out after {:?}", + self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT + ))) + } + } + } +} + +#[cfg(test)] struct PluginWasmTool { record: ResolvedPluginRecord, name: String, origin: ToolOrigin, } +#[cfg(test)] #[async_trait] impl Tool for PluginWasmTool { async fn execute( @@ -1740,7 +2674,6 @@ impl Tool for PluginWasmTool { bounded_message(error.to_string()) )) })?; - let record = self.record.clone(); let name = self.name.clone(); let plugin_ref = self.origin.plugin_ref.clone(); @@ -1772,7 +2705,7 @@ impl Tool for PluginWasmTool { } #[derive(Debug)] -enum PluginWasmError { +pub enum PluginWasmError { Package(String), Module(String), Execution(String), @@ -2812,6 +3745,8 @@ mod tests { runtime: None, hooks: Vec::new(), tools, + services: Vec::new(), + ingresses: Vec::new(), permissions: permissions.clone(), }, enabled_surfaces: vec![PluginSurface::Tool], @@ -2837,6 +3772,304 @@ mod tests { permissions } + fn install_feature( + feature: PluginToolFeature, + ) -> ( + super::super::FeatureRegistryInstallReport, + Vec, + ) { + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + let report = super::super::FeatureRegistryBuilder::default() + .with_module(feature) + .install_into_pending(&mut pending, &mut hooks); + (report, pending) + } + + #[test] + fn component_lifecycle_rejects_start_error_status() { + let component = component_instance_with_outputs( + br#"{"error":{"message":"boom"}}"#, + br#"{"state":"ready"}"#, + br#"{"state":"stopped"}"#, + br#"{"summary":"tool"}"#, + br#"{"accepted":true}"#, + ); + let (_dir, mut record) = resolved_record_with_component(component); + record.manifest.runtime.as_mut().unwrap().world = + Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()); + let error = match PluginInstanceHandle::new(record) { + Ok(_) => panic!("component start error should fail instance creation"), + Err(error) => error, + }; + assert!(error.bounded_message().contains("start returned error")); + } + + #[test] + fn component_lifecycle_reports_status_and_stop_outputs() { + let component = component_instance_with_outputs( + br#"{"state":"ready","data":{"phase":"start"}}"#, + br#"{"state":"ready","data":{"phase":"status"}}"#, + br#"{"state":"stopped","data":{"phase":"stop"}}"#, + br#"{"summary":"tool"}"#, + br#"{"accepted":true}"#, + ); + let (_dir, mut record) = resolved_record_with_component(component); + record.manifest.runtime.as_mut().unwrap().world = + Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()); + let handle = PluginInstanceHandle::new(record).unwrap(); + let status = handle.status(); + assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Started); + assert_eq!(status.component_status.unwrap()["data"]["phase"], "status"); + let stopped = handle.stop().unwrap(); + assert_eq!(stopped.lifecycle, PluginInstanceLifecycleState::Stopped); + assert_eq!(stopped.component_status.unwrap()["data"]["phase"], "stop"); + } + + fn add_service(record: &mut ResolvedPluginRecord, name: &str) { + record.manifest.surfaces.push(PluginSurface::Service); + record.enabled_surfaces.push(PluginSurface::Service); + record + .manifest + .services + .push(manifest::plugin::PluginServiceManifest { + name: name.into(), + description: "test service".into(), + lifecycle: "host-managed".into(), + status_schema: None, + side_effects: Vec::new(), + }); + record + .manifest + .permissions + .push(PluginPermission::surface(PluginSurface::Service)); + record + .manifest + .permissions + .push(PluginPermission::service(name)); + record + .grants + .permissions + .push(PluginPermission::surface(PluginSurface::Service)); + record + .grants + .permissions + .push(PluginPermission::service(name)); + } + + fn add_ingress(record: &mut ResolvedPluginRecord, name: &str) { + record.manifest.surfaces.push(PluginSurface::Ingress); + record.enabled_surfaces.push(PluginSurface::Ingress); + record + .manifest + .ingresses + .push(manifest::plugin::PluginIngressManifest { + name: name.into(), + description: "test ingress".into(), + event_kinds: vec!["test".into()], + input_schema: None, + sources: Vec::new(), + side_effects: Vec::new(), + }); + record + .manifest + .permissions + .push(PluginPermission::surface(PluginSurface::Ingress)); + record + .manifest + .permissions + .push(PluginPermission::ingress(name)); + record + .grants + .permissions + .push(PluginPermission::surface(PluginSurface::Ingress)); + record + .grants + .permissions + .push(PluginPermission::ingress(name)); + } + + #[test] + fn service_selected_ignores_unselected_tool_without_grants() { + let mut record = record(vec![tool("hidden_tool")]); + add_service(&mut record, "svc"); + record.enabled_surfaces = vec![PluginSurface::Service]; + record.manifest.permissions = vec![ + PluginPermission::surface(PluginSurface::Service), + PluginPermission::service("svc"), + ]; + record.grants.permissions = record.manifest.permissions.clone(); + let feature = PluginToolFeature::new(record); + assert!(feature.descriptor().tools.is_empty()); + assert_eq!(feature.descriptor().provides_services.len(), 1); + let (report, pending) = install_feature(feature.clone()); + assert!( + report.reports.iter().all(|report| report.installed), + "{report:#?}" + ); + assert!(pending.is_empty(), "unselected Tool must not register"); + assert_eq!(report.reports[0].provided_services.len(), 1); + assert_eq!( + feature.instance_status().unwrap().lifecycle, + PluginInstanceLifecycleState::Ready + ); + } + + #[test] + fn tool_selected_ignores_unselected_service_ingress_even_with_grants() { + let mut record = record(vec![tool("visible_tool")]); + add_service(&mut record, "hidden_service"); + add_ingress(&mut record, "hidden_ingress"); + record.enabled_surfaces = vec![PluginSurface::Tool]; + let feature = PluginToolFeature::new(record); + assert!(feature.descriptor().provides_services.is_empty()); + assert_eq!(feature.descriptor().tools.len(), 1); + let (report, pending) = install_feature(feature.clone()); + assert!( + report.reports.iter().all(|report| report.installed), + "{report:#?}" + ); + assert_eq!(pending.len(), 1); + assert!(report.reports[0].provided_services.is_empty()); + let dispatch = feature.dispatch_ingress( + "hidden_ingress", + PluginIngressEvent { + kind: "test".into(), + source: "unit".into(), + payload: serde_json::json!({}), + }, + ); + assert!( + dispatch + .unwrap_err() + .bounded_message() + .contains("not enabled") + ); + } + + #[test] + fn service_only_install_retains_host_managed_instance() { + let mut record = record(Vec::new()); + add_service(&mut record, "svc"); + record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest { + kind: "test-ingress".into(), + entry: None, + abi: None, + component: None, + world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()), + }); + let feature = PluginToolFeature::new(record); + let (report, _pending) = install_feature(feature.clone()); + assert!( + report.reports.iter().all(|report| report.installed), + "{report:#?}" + ); + let status = feature.instance_status().expect("service instance started"); + assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Started); + } + + #[test] + fn installed_ingress_dispatch_uses_retained_shared_instance() { + let mut record = record(vec![tool("shared_tool")]); + add_ingress(&mut record, "shared_ingress"); + record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest { + kind: "test-ingress".into(), + entry: None, + abi: None, + component: None, + world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()), + }); + let feature = PluginToolFeature::new(record); + let (report, pending) = install_feature(feature.clone()); + assert!( + report.reports.iter().all(|report| report.installed), + "{report:#?}" + ); + let (_meta, tool) = pending + .into_iter() + .map(|definition| definition()) + .find(|(meta, _tool)| meta.name == "shared_tool") + .unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let output = runtime + .block_on(tool.execute(r#"{"first":true}"#, ToolExecutionContext::default())) + .unwrap(); + assert!(output.summary.contains("shared_tool")); + let report = feature + .dispatch_ingress( + "shared_ingress", + PluginIngressEvent { + kind: "test".into(), + source: "unit".into(), + payload: serde_json::json!({ "hello": "world" }), + }, + ) + .unwrap(); + assert!(report.accepted); + assert_eq!(report.output["calls"], 1); + } + + #[test] + fn instance_ingress_dispatch_uses_shared_in_process_instance() { + let mut record = record(vec![tool("shared_tool")]); + record.manifest.surfaces.push(PluginSurface::Ingress); + record.enabled_surfaces.push(PluginSurface::Ingress); + record + .manifest + .ingresses + .push(manifest::plugin::PluginIngressManifest { + name: "shared_ingress".into(), + description: "test ingress".into(), + event_kinds: vec!["test".into()], + input_schema: None, + sources: Vec::new(), + side_effects: Vec::new(), + }); + record + .manifest + .permissions + .push(PluginPermission::surface(PluginSurface::Ingress)); + record + .manifest + .permissions + .push(PluginPermission::ingress("shared_ingress")); + record + .grants + .permissions + .push(PluginPermission::surface(PluginSurface::Ingress)); + record + .grants + .permissions + .push(PluginPermission::ingress("shared_ingress")); + let handle = PluginInstanceHandle(Arc::new(Mutex::new(PluginInstance { + record, + runtime: PluginInstanceRuntime::TestIngress { calls: 0 }, + lifecycle: PluginInstanceLifecycleState::Started, + component_status: None, + diagnostics: Vec::new(), + }))); + + let _tool = handle + .handle_tool("shared_tool", br#"{"first":true}"#.to_vec()) + .unwrap(); + let report = handle + .deliver_ingress( + "shared_ingress", + PluginIngressEvent { + kind: "test".into(), + source: "unit".into(), + payload: serde_json::json!({ "hello": "world" }), + }, + ) + .unwrap(); + assert!(report.accepted); + assert_eq!(report.output["calls"], 1); + assert_eq!(report.output["ingress"], "shared_ingress"); + } + fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize { report .reports @@ -4101,6 +5334,81 @@ input_schema = {{ type = "object", additionalProperties = true }} (dir, record) } + fn component_instance_with_outputs( + start: &[u8], + status: &[u8], + stop: &[u8], + tool: &[u8], + ingress: &[u8], + ) -> Vec { + wat::parse_str(format!( + r#"(component + (core module $m + (memory (export "memory") 1) + (func (export "realloc") (param i32 i32 i32 i32) (result i32) + (if (result i32) (i32.eqz (local.get 0)) + (then (i32.const 8192)) + (else (local.get 0)))) + (data (i32.const 1024) "{}") + (data (i32.const 2048) "{}") + (data (i32.const 3072) "{}") + (data (i32.const 4096) "{}") + (data (i32.const 5120) "{}") + (func $write (param i32 i32) + (i32.store (i32.const 6144) (local.get 0)) + (i32.store (i32.const 6148) (local.get 1))) + (func (export "start") (param i32 i32) (result i32) + (call $write (i32.const 1024) (i32.const {})) + (i32.const 6144)) + (func (export "status") (result i32) + (call $write (i32.const 2048) (i32.const {})) + (i32.const 6144)) + (func (export "stop") (result i32) + (call $write (i32.const 3072) (i32.const {})) + (i32.const 6144)) + (func (export "tool") (param i32 i32 i32 i32) (result i32) + (call $write (i32.const 4096) (i32.const {})) + (i32.const 6144)) + (func (export "ingress") (param i32 i32 i32 i32) (result i32) + (call $write (i32.const 5120) (i32.const {})) + (i32.const 6144)) + ) + (core instance $i (instantiate $m)) + (alias core export $i "memory" (core memory $mem)) + (alias core export $i "realloc" (core func $realloc)) + (alias core export $i "start" (core func $start_core)) + (alias core export $i "status" (core func $status_core)) + (alias core export $i "stop" (core func $stop_core)) + (alias core export $i "tool" (core func $tool_core)) + (alias core export $i "ingress" (core func $ingress_core)) + (type $start_ty (func (param "config-json" string) (result string))) + (type $noarg_ty (func (result string))) + (type $twoarg_ty (func (param "name" string) (param "json" string) (result string))) + (func $start (type $start_ty) (canon lift (core func $start_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) + (func $status (type $noarg_ty) (canon lift (core func $status_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) + (func $stop (type $noarg_ty) (canon lift (core func $stop_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) + (func $tool (type $twoarg_ty) (canon lift (core func $tool_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) + (func $ingress (type $twoarg_ty) (canon lift (core func $ingress_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) + (export "start" (func $start)) + (export "status" (func $status)) + (export "stop" (func $stop)) + (export "handle-tool" (func $tool)) + (export "handle-ingress" (func $ingress)) + )"#, + wat_bytes(start), + wat_bytes(status), + wat_bytes(stop), + wat_bytes(tool), + wat_bytes(ingress), + start.len(), + status.len(), + stop.len(), + tool.len(), + ingress.len(), + )) + .unwrap() + } + fn component_tool_that_returns(output: &[u8]) -> Vec { component_tool_with_memory_pages(output, 1) } diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 9c4da1a8..882ee11e 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -5375,6 +5375,8 @@ permission = "read" runtime: None, hooks: vec![], tools: vec![], + services: vec![], + ingresses: vec![], permissions: vec![], }, enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 933d4e39..4a2e4e81 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1,3 +1,4 @@ +mod mcp_cli; mod memory_lint; mod objective_cli; mod plugin_cli; @@ -18,6 +19,7 @@ enum Mode { Help, MemoryLintHelp, MemoryLint(LintCliOptions), + Mcp(mcp_cli::McpCliCommand), Plugin(plugin_cli::PluginCliCommand), Objective(objective_cli::ObjectiveCli), Session(session_cli::SessionCli), @@ -70,6 +72,13 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + Mode::Mcp(command) => match mcp_cli::run(command) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("yoi mcp: {e}"); + ExitCode::FAILURE + } + }, Mode::Plugin(command) => match plugin_cli::run(command) { Ok(()) => ExitCode::SUCCESS, Err(e) => { @@ -186,6 +195,10 @@ fn parse_args_slice(args: &[String]) -> Result { let plugin_cli = parse_plugin_args(&args[1..])?; return Ok(Mode::Plugin(plugin_cli)); } + "mcp" => { + let mcp_cli = parse_mcp_args(&args[1..])?; + return Ok(Mode::Mcp(mcp_cli)); + } "panel" => { return Ok(Mode::Tui { mode: LaunchMode::Panel, @@ -593,6 +606,147 @@ fn plugin_usage() -> &'static str { "usage: yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" } +fn parse_mcp_args(args: &[String]) -> Result { + let Some((subcommand, rest)) = args.split_first() else { + return Err(ParseError( + "yoi mcp requires `list`, `show `, `tools [server]`, `resources [server]`, or `prompts [server]`".to_string(), + )); + }; + match subcommand.as_str() { + "list" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + if !positional.is_empty() { + return Err(ParseError( + "yoi mcp list does not accept positional arguments".to_string(), + )); + } + Ok(mcp_cli::McpCliCommand::List(mcp_args)) + } + "show" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [server] => Ok(mcp_cli::McpCliCommand::Show { + server: server.clone(), + args: mcp_args, + }), + [] => Err(ParseError( + "yoi mcp show requires a server name".to_string(), + )), + _ => Err(ParseError( + "yoi mcp show accepts exactly one server name".to_string(), + )), + } + } + "tools" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [] => Ok(mcp_cli::McpCliCommand::Tools { + server: None, + args: mcp_args, + }), + [server] => Ok(mcp_cli::McpCliCommand::Tools { + server: Some(server.clone()), + args: mcp_args, + }), + _ => Err(ParseError( + "yoi mcp tools accepts at most one server name".to_string(), + )), + } + } + "resources" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [] => Ok(mcp_cli::McpCliCommand::Resources { + server: None, + args: mcp_args, + }), + [server] => Ok(mcp_cli::McpCliCommand::Resources { + server: Some(server.clone()), + args: mcp_args, + }), + _ => Err(ParseError( + "yoi mcp resources accepts at most one server name".to_string(), + )), + } + } + "prompts" => { + let (mcp_args, positional) = parse_mcp_common_args(rest)?; + match positional.as_slice() { + [] => Ok(mcp_cli::McpCliCommand::Prompts { + server: None, + args: mcp_args, + }), + [server] => Ok(mcp_cli::McpCliCommand::Prompts { + server: Some(server.clone()), + args: mcp_args, + }), + _ => Err(ParseError( + "yoi mcp prompts accepts at most one server name".to_string(), + )), + } + } + "--help" | "-h" => Err(ParseError(mcp_usage().to_string())), + other => Err(ParseError(format!("unknown yoi mcp command: {other}"))), + } +} + +fn parse_mcp_common_args( + args: &[String], +) -> Result<(mcp_cli::McpCliArgs, Vec), ParseError> { + let mut mcp_args = mcp_cli::McpCliArgs::default(); + let mut positional = Vec::new(); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--json" { + mcp_args.json = true; + index += 1; + } else if arg == "--workspace" { + let value = args + .get(index + 1) + .ok_or_else(|| ParseError("--workspace requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--workspace requires a value".to_string())); + } + mcp_args.workspace = Some(PathBuf::from(value)); + index += 2; + } else if let Some(value) = arg.strip_prefix("--workspace=") { + if value.is_empty() { + return Err(ParseError("--workspace requires a value".to_string())); + } + mcp_args.workspace = Some(PathBuf::from(value)); + index += 1; + } else if arg == "--profile" { + let value = args + .get(index + 1) + .ok_or_else(|| ParseError("--profile requires a value".to_string()))?; + if value.starts_with('-') { + return Err(ParseError("--profile requires a value".to_string())); + } + mcp_args.profile = Some(value.clone()); + index += 2; + } else if let Some(value) = arg.strip_prefix("--profile=") { + if value.is_empty() { + return Err(ParseError("--profile requires a value".to_string())); + } + mcp_args.profile = Some(value.to_string()); + index += 1; + } else if arg == "--help" || arg == "-h" { + return Err(ParseError(mcp_usage().to_string())); + } else if arg.starts_with('-') { + return Err(ParseError(format!("unknown yoi mcp argument: {arg}"))); + } else { + positional.push(arg.clone()); + index += 1; + } + } + Ok((mcp_args, positional)) +} + +fn mcp_usage() -> &'static str { + "usage: yoi mcp list [--workspace PATH] [--profile REF] [--json]\n yoi mcp show [--workspace PATH] [--profile REF] [--json]\n yoi mcp tools [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp resources [server] [--workspace PATH] [--profile REF] [--json]\n yoi mcp prompts [server] [--workspace PATH] [--profile REF] [--json]" +} + fn parse_panel_workspace(args: &[String]) -> Result { match args { [] => std::env::current_dir() @@ -623,7 +777,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, --resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n -r, --resume Open the Pod Console picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -814,6 +968,57 @@ mod tests { } } + #[test] + fn parse_mcp_commands() { + match parse_args_from(["mcp", "list", "--workspace=/tmp/ws", "--json"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::List(options)) => { + assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); + assert!(options.json); + } + _ => panic!("expected MCP list mode"), + } + + match parse_args_from(["mcp", "show", "filesystem", "--profile", "project:mcp"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Show { server, args }) => { + assert_eq!(server, "filesystem"); + assert_eq!(args.profile.as_deref(), Some("project:mcp")); + } + _ => panic!("expected MCP show mode"), + } + + match parse_args_from(["mcp", "tools", "filesystem"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Tools { server, .. }) => { + assert_eq!(server.as_deref(), Some("filesystem")); + } + _ => panic!("expected MCP tools mode"), + } + + match parse_args_from(["mcp", "resources"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Resources { server, .. }) => { + assert!(server.is_none()); + } + _ => panic!("expected MCP resources mode"), + } + + match parse_args_from(["mcp", "prompts", "filesystem"]).unwrap() { + Mode::Mcp(mcp_cli::McpCliCommand::Prompts { server, .. }) => { + assert_eq!(server.as_deref(), Some("filesystem")); + } + _ => panic!("expected MCP prompts mode"), + } + } + + #[test] + fn parse_mcp_rejects_usage_errors() { + let err = parse_args_from(["mcp", "show"]).unwrap_err(); + assert_eq!(err.to_string(), "yoi mcp show requires a server name"); + let err = parse_args_from(["mcp", "list", "extra"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi mcp list does not accept positional arguments" + ); + } + #[test] fn parse_memory_lint_rejects_usage_errors() { let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err(); diff --git a/crates/yoi/src/mcp_cli.rs b/crates/yoi/src/mcp_cli.rs new file mode 100644 index 00000000..a898e374 --- /dev/null +++ b/crates/yoi/src/mcp_cli.rs @@ -0,0 +1,1232 @@ +use std::error::Error; +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; + +use manifest::{ + McpConfig, McpEnvConfig, McpEnvValue, McpStdioCwdPolicy, McpStdioServerConfig, + ProfileResolveOptions, ProfileResolver, ProfileSelector, +}; +use serde::Serialize; + +pub(crate) type Result = std::result::Result>; + +const MAX_SERVERS: usize = 128; +const MAX_DIAGNOSTICS: usize = 48; +const MAX_TEXT_CHARS: usize = 240; +const MCP_STATIC_NOT_LIVE_REASON: &str = "CLI inspection reads resolved static MCP config only; provider-discovered state is unavailable without live Pod/runtime MCP state, and this command does not start MCP server processes."; + +#[derive(Clone, Debug, Default)] +pub(crate) struct McpCliArgs { + pub workspace: Option, + pub profile: Option, + pub json: bool, +} + +#[derive(Clone, Debug)] +pub(crate) enum McpCliCommand { + List(McpCliArgs), + Show { + server: String, + args: McpCliArgs, + }, + Tools { + server: Option, + args: McpCliArgs, + }, + Resources { + server: Option, + args: McpCliArgs, + }, + Prompts { + server: Option, + args: McpCliArgs, + }, +} + +pub(crate) fn run(command: McpCliCommand) -> Result<()> { + let rendered = match command { + McpCliCommand::List(args) => render_list(&args)?, + McpCliCommand::Show { server, args } => render_show(&server, &args)?, + McpCliCommand::Tools { server, args } => render_tools(server.as_deref(), &args)?, + McpCliCommand::Resources { server, args } => render_resources(server.as_deref(), &args)?, + McpCliCommand::Prompts { server, args } => render_prompts(server.as_deref(), &args)?, + }; + print!("{rendered}"); + Ok(()) +} + +fn render_list(args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ListReport::from_snapshot(snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_list_human(&report) +} + +fn render_show(server: &str, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ShowReport::from_snapshot(server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_show_human(&report) +} + +fn render_tools(server: Option<&str>, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ToolsReport::from_snapshot(server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_tools_human(&report) +} + +fn render_resources(server: Option<&str>, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ResourceLikeReport::from_snapshot(ResourceKind::Resources, server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_resource_like_human(&report) +} + +fn render_prompts(server: Option<&str>, args: &McpCliArgs) -> Result { + let snapshot = inspect_static_config(args); + let report = ResourceLikeReport::from_snapshot(ResourceKind::Prompts, server, snapshot); + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); + } + render_resource_like_human(&report) +} + +fn inspect_static_config(args: &McpCliArgs) -> StaticConfigSnapshot { + let workspace_input = match &args.workspace { + Some(path) => path.clone(), + None => match std::env::current_dir() { + Ok(path) => path, + Err(error) => { + return StaticConfigSnapshot::invalid( + PathBuf::from("."), + profile_label(args), + DiagnosticReport::error( + "current_dir_unavailable", + format!("failed to resolve current directory: {error}"), + ), + ); + } + }, + }; + + let workspace = match fs::canonicalize(&workspace_input) { + Ok(path) => path, + Err(error) => { + return StaticConfigSnapshot::invalid( + workspace_input.clone(), + profile_label(args), + DiagnosticReport::error( + "workspace_unavailable", + format!( + "workspace `{}` is unavailable: {error}", + workspace_input.display() + ), + ), + ); + } + }; + + let selector = args + .profile + .as_deref() + .map(ProfileSelector::parse_cli) + .unwrap_or(ProfileSelector::Default); + let profile = selector.display_label(); + match ProfileResolver::new() + .with_workspace_base(&workspace) + .resolve( + &selector, + ProfileResolveOptions::with_pod_name("mcp-inspect"), + ) { + Ok(resolved) => { + let mut diagnostics = vec![DiagnosticReport::info( + "static_only", + "static profile/config resolution completed; no MCP server process was started", + )]; + diagnostics.push(DiagnosticReport::info( + "live_state_unavailable", + MCP_STATIC_NOT_LIVE_REASON, + )); + StaticConfigSnapshot { + status: "ok".to_string(), + workspace, + profile, + mcp: Some(resolved.manifest.mcp), + diagnostics, + } + } + Err(error) => StaticConfigSnapshot::invalid( + workspace, + profile, + DiagnosticReport::error( + "profile_resolution_failed", + format!("failed to resolve profile/MCP config: {error}"), + ), + ), + } +} + +fn profile_label(args: &McpCliArgs) -> String { + args.profile + .clone() + .unwrap_or_else(|| "default".to_string()) +} + +#[derive(Debug)] +struct StaticConfigSnapshot { + status: String, + workspace: PathBuf, + profile: String, + mcp: Option, + diagnostics: Vec, +} + +impl StaticConfigSnapshot { + fn invalid(workspace: PathBuf, profile: String, diagnostic: DiagnosticReport) -> Self { + Self { + status: "invalid_config".to_string(), + workspace, + profile, + mcp: None, + diagnostics: vec![ + diagnostic, + DiagnosticReport::info("live_state_unavailable", MCP_STATIC_NOT_LIVE_REASON), + ], + } + } +} + +#[derive(Debug, Serialize)] +struct ListReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + summary: SummaryReport, + servers: Vec, + diagnostics: Vec, +} + +impl ListReport { + fn from_snapshot(mut snapshot: StaticConfigSnapshot) -> Self { + let (servers, mut diagnostics) = server_reports(snapshot.mcp.as_ref()); + diagnostics.splice(0..0, snapshot.diagnostics.drain(..).take(MAX_DIAGNOSTICS)); + diagnostics.truncate(MAX_DIAGNOSTICS); + let summary = SummaryReport { + configured_servers: servers.len(), + truncated: snapshot + .mcp + .as_ref() + .is_some_and(|mcp| mcp.stdio_servers.len() > MAX_SERVERS), + provider_discovery: "not_live".to_string(), + }; + Self { + command: "list", + status: snapshot.status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + summary, + servers, + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct ShowReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + server: Option, + diagnostics: Vec, +} + +impl ShowReport { + fn from_snapshot(server_name: &str, snapshot: StaticConfigSnapshot) -> Self { + let mut diagnostics = snapshot.diagnostics; + let server = snapshot.mcp.as_ref().and_then(|mcp| { + mcp.stdio_servers + .iter() + .find(|server| server.name == server_name) + .map(ServerReport::from_stdio_server) + }); + let status = if snapshot.status != "ok" { + snapshot.status + } else if server.is_some() { + "ok".to_string() + } else { + diagnostics.push(DiagnosticReport::error( + "server_not_configured", + format!( + "MCP server `{}` is not configured", + bounded_text(server_name) + ), + )); + "missing_server".to_string() + }; + truncate_diagnostics(&mut diagnostics); + Self { + command: "show", + status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + server, + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct ToolsReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + server_filter: Option, + summary: DiscoverySummaryReport, + servers: Vec, + tools: Vec, + diagnostics: Vec, +} + +impl ToolsReport { + fn from_snapshot(server_filter: Option<&str>, mut snapshot: StaticConfigSnapshot) -> Self { + let (servers, mut diagnostics, missing) = + discovery_servers(snapshot.mcp.as_ref(), server_filter, DiscoveryKind::Tools); + diagnostics.splice(0..0, snapshot.diagnostics.drain(..).take(MAX_DIAGNOSTICS)); + let status = if snapshot.status != "ok" { + snapshot.status + } else if missing { + "missing_server".to_string() + } else { + "not_live".to_string() + }; + truncate_diagnostics(&mut diagnostics); + Self { + command: "tools", + status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + server_filter: server_filter.map(ToString::to_string), + summary: DiscoverySummaryReport { + configured_servers: servers.len(), + discovered_items: 0, + discovery_status: "unavailable".to_string(), + reason: MCP_STATIC_NOT_LIVE_REASON.to_string(), + }, + servers, + tools: Vec::new(), + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct ResourceLikeReport { + command: &'static str, + status: String, + workspace: String, + profile: String, + inspection_mode: InspectionModeReport, + live_state: LiveStateReport, + server_filter: Option, + summary: DiscoverySummaryReport, + servers: Vec, + operations: Vec, + items: Vec, + diagnostics: Vec, +} + +impl ResourceLikeReport { + fn from_snapshot( + kind: ResourceKind, + server_filter: Option<&str>, + mut snapshot: StaticConfigSnapshot, + ) -> Self { + let (servers, mut diagnostics, missing) = + discovery_servers(snapshot.mcp.as_ref(), server_filter, kind.discovery_kind()); + diagnostics.splice(0..0, snapshot.diagnostics.drain(..).take(MAX_DIAGNOSTICS)); + let status = if snapshot.status != "ok" { + snapshot.status + } else if missing { + "missing_server".to_string() + } else { + "not_live".to_string() + }; + let operations = servers + .iter() + .flat_map(|server| operation_reports_for_server(kind, &server.server_name)) + .collect::>(); + truncate_diagnostics(&mut diagnostics); + Self { + command: kind.command_name(), + status, + workspace: snapshot.workspace.display().to_string(), + profile: snapshot.profile, + inspection_mode: InspectionModeReport::static_config(), + live_state: LiveStateReport::not_live(), + server_filter: server_filter.map(ToString::to_string), + summary: DiscoverySummaryReport { + configured_servers: servers.len(), + discovered_items: 0, + discovery_status: "unavailable".to_string(), + reason: MCP_STATIC_NOT_LIVE_REASON.to_string(), + }, + servers, + operations, + items: Vec::new(), + diagnostics, + } + } +} + +#[derive(Debug, Serialize)] +struct InspectionModeReport { + kind: &'static str, + starts_servers: bool, + calls_tools: bool, + fetches_resource_or_prompt_content: bool, +} + +impl InspectionModeReport { + fn static_config() -> Self { + Self { + kind: "static_config", + starts_servers: false, + calls_tools: false, + fetches_resource_or_prompt_content: false, + } + } +} + +#[derive(Debug, Serialize)] +struct LiveStateReport { + status: &'static str, + available: bool, + reason: &'static str, +} + +impl LiveStateReport { + fn not_live() -> Self { + Self { + status: "not_live", + available: false, + reason: MCP_STATIC_NOT_LIVE_REASON, + } + } +} + +#[derive(Debug, Serialize)] +struct SummaryReport { + configured_servers: usize, + truncated: bool, + provider_discovery: String, +} + +#[derive(Debug, Clone, Serialize)] +struct ServerReport { + name: String, + status: &'static str, + transport: TransportReport, + cwd: CwdReport, + env_policy: EnvPolicyReport, + trust_policy: TrustPolicyReport, + capabilities: CapabilitySummaryReport, + diagnostics: Vec, +} + +impl ServerReport { + fn from_stdio_server(server: &McpStdioServerConfig) -> Self { + Self { + name: bounded_text(&server.name), + status: "configured", + transport: TransportReport { + kind: "stdio", + command: bounded_text(&server.command), + arg_count: server.args.len(), + args_redacted: true, + }, + cwd: CwdReport::from_policy(server.cwd.as_ref()), + env_policy: EnvPolicyReport::from_env(&server.env), + trust_policy: TrustPolicyReport::read_only_inspection(), + capabilities: CapabilitySummaryReport::not_live(), + diagnostics: vec![DiagnosticReport::info( + "provider_discovery_unavailable", + MCP_STATIC_NOT_LIVE_REASON, + )], + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct TransportReport { + kind: &'static str, + command: String, + arg_count: usize, + args_redacted: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct CwdReport { + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, +} + +impl CwdReport { + fn from_policy(policy: Option<&McpStdioCwdPolicy>) -> Self { + match policy { + Some(McpStdioCwdPolicy::Inherit) => Self { + kind: "inherit", + path: None, + }, + Some(McpStdioCwdPolicy::Path { path }) => Self { + kind: "path", + path: Some(path.display().to_string()), + }, + None => Self { + kind: "unspecified", + path: None, + }, + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct EnvPolicyReport { + inherit_count: usize, + set_count: usize, + value_kinds: EnvValueKindCounts, + values_redacted: bool, +} + +impl EnvPolicyReport { + fn from_env(env: &McpEnvConfig) -> Self { + let mut value_kinds = EnvValueKindCounts::default(); + for value in env.set.values() { + match value { + McpEnvValue::Literal { .. } => value_kinds.literal += 1, + McpEnvValue::SecretRef { .. } => value_kinds.secret_ref += 1, + McpEnvValue::EnvRef { .. } => value_kinds.env_ref += 1, + } + } + Self { + inherit_count: env.inherit.len(), + set_count: env.set.len(), + value_kinds, + values_redacted: true, + } + } +} + +#[derive(Debug, Clone, Default, Serialize)] +struct EnvValueKindCounts { + literal: usize, + secret_ref: usize, + env_ref: usize, +} + +#[derive(Debug, Clone, Serialize)] +struct TrustPolicyReport { + process_start: &'static str, + tool_calls: &'static str, + resource_prompt_content: &'static str, + server_metadata: &'static str, +} + +impl TrustPolicyReport { + fn read_only_inspection() -> Self { + Self { + process_start: "not_started_by_cli_inspection", + tool_calls: "not_called_by_cli_inspection", + resource_prompt_content: "not_fetched_by_cli_inspection", + server_metadata: "untrusted_when_available", + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct CapabilitySummaryReport { + tools: &'static str, + resources: &'static str, + prompts: &'static str, + diagnostics: Vec, +} + +impl CapabilitySummaryReport { + fn not_live() -> Self { + Self { + tools: "unavailable_not_live", + resources: "unavailable_not_live", + prompts: "unavailable_not_live", + diagnostics: vec![DiagnosticReport::info( + "capabilities_require_live_initialize", + "MCP capabilities are server-provided during initialize and are unavailable in static config inspection", + )], + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct DiagnosticReport { + level: &'static str, + code: &'static str, + message: String, +} + +impl DiagnosticReport { + fn info(code: &'static str, message: impl AsRef) -> Self { + Self { + level: "info", + code, + message: bounded_text(message.as_ref()), + } + } + fn warning(code: &'static str, message: impl AsRef) -> Self { + Self { + level: "warning", + code, + message: bounded_text(message.as_ref()), + } + } + fn error(code: &'static str, message: impl AsRef) -> Self { + Self { + level: "error", + code, + message: bounded_text(message.as_ref()), + } + } +} + +#[derive(Debug, Serialize)] +struct DiscoverySummaryReport { + configured_servers: usize, + discovered_items: usize, + discovery_status: String, + reason: String, +} + +#[derive(Debug, Clone, Serialize)] +struct DiscoveryServerReport { + server_name: String, + transport_kind: &'static str, + capability_status: &'static str, + discovery_status: &'static str, + registration_status: &'static str, + diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct ToolItemReport { + yoi_stable_tool_name: Option, + server_name: String, + mcp_tool_name: Option, + schema_availability: &'static str, + registration_status: &'static str, + diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct ResourceItemReport { + server_name: String, + kind: &'static str, + identifier: Option, + name: Option, + description: Option, + eligibility: &'static str, + content_state: &'static str, + diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +struct OperationReport { + server_name: String, + method: &'static str, + yoi_stable_tool_name: String, + registration_status: &'static str, + eligibility: &'static str, + content_state: &'static str, + diagnostics: Vec, +} + +#[derive(Clone, Copy)] +enum DiscoveryKind { + Tools, + Resources, + Prompts, +} + +impl DiscoveryKind { + fn label(self) -> &'static str { + match self { + Self::Tools => "tools", + Self::Resources => "resources", + Self::Prompts => "prompts", + } + } +} + +#[derive(Clone, Copy)] +enum ResourceKind { + Resources, + Prompts, +} + +impl ResourceKind { + fn command_name(self) -> &'static str { + match self { + Self::Resources => "resources", + Self::Prompts => "prompts", + } + } + fn discovery_kind(self) -> DiscoveryKind { + match self { + Self::Resources => DiscoveryKind::Resources, + Self::Prompts => DiscoveryKind::Prompts, + } + } +} + +fn server_reports(config: Option<&McpConfig>) -> (Vec, Vec) { + let Some(config) = config else { + return (Vec::new(), Vec::new()); + }; + let mut diagnostics = Vec::new(); + if config.stdio_servers.len() > MAX_SERVERS { + diagnostics.push(DiagnosticReport::warning( + "server_list_truncated", + format!( + "configured MCP server list truncated from {} to {} entries", + config.stdio_servers.len(), + MAX_SERVERS + ), + )); + } + let servers = config + .stdio_servers + .iter() + .take(MAX_SERVERS) + .map(ServerReport::from_stdio_server) + .collect(); + (servers, diagnostics) +} + +fn selected_stdio_servers<'a>( + config: Option<&'a McpConfig>, + server_filter: Option<&str>, +) -> (Vec<&'a McpStdioServerConfig>, bool) { + let Some(config) = config else { + return (Vec::new(), false); + }; + match server_filter { + Some(filter) => { + let servers = config + .stdio_servers + .iter() + .filter(|server| server.name == filter) + .collect::>(); + let missing = servers.is_empty(); + (servers, missing) + } + None => ( + config.stdio_servers.iter().take(MAX_SERVERS).collect(), + false, + ), + } +} + +fn discovery_servers( + config: Option<&McpConfig>, + server_filter: Option<&str>, + kind: DiscoveryKind, +) -> (Vec, Vec, bool) { + let (servers, missing) = selected_stdio_servers(config, server_filter); + let mut diagnostics = Vec::new(); + if missing { + if let Some(filter) = server_filter { + diagnostics.push(DiagnosticReport::error( + "server_not_configured", + format!("MCP server `{}` is not configured", bounded_text(filter)), + )); + } + } else if let Some(config) = + config.filter(|config| server_filter.is_none() && config.stdio_servers.len() > MAX_SERVERS) + { + diagnostics.push(DiagnosticReport::warning( + "server_list_truncated", + format!( + "configured MCP server list truncated from {} to {} entries", + config.stdio_servers.len(), + MAX_SERVERS + ), + )); + } + + let reports = servers + .into_iter() + .map(|server| DiscoveryServerReport { + server_name: bounded_text(&server.name), + transport_kind: "stdio", + capability_status: "unavailable_not_live", + discovery_status: "unavailable", + registration_status: "not_live", + diagnostics: vec![DiagnosticReport::info( + "provider_discovery_unavailable", + format!( + "MCP {} discovery for server `{}` is unavailable in static CLI inspection; no server process was started", + kind.label(), + bounded_text(&server.name) + ), + )], + }) + .collect(); + (reports, diagnostics, missing) +} + +fn operation_reports_for_server(kind: ResourceKind, server_name: &str) -> Vec { + let operations: &[(&str, &str)] = match kind { + ResourceKind::Resources => &[ + ("resources/list", "resources_list"), + ("resources/read", "resources_read"), + ], + ResourceKind::Prompts => &[ + ("prompts/list", "prompts_list"), + ("prompts/get", "prompts_get"), + ], + }; + let server_namespace = sanitize_segment(server_name); + operations + .iter() + .map(|(method, segment)| OperationReport { + server_name: server_name.to_string(), + method, + yoi_stable_tool_name: format!("Mcp_{server_namespace}_{segment}"), + registration_status: "not_live", + eligibility: "unknown_until_live_capability_discovery", + content_state: "not_fetched", + diagnostics: vec![DiagnosticReport::info( + "operation_requires_live_capability", + format!( + "{} operation registration requires server capability discovery; CLI inspection did not initialize the server", + method + ), + )], + }) + .collect() +} + +fn render_list_human(report: &ListReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp servers (workspace: {}, profile: {})", + report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " mode: static config inspection")?; + writeln!(out, " live: not live / unavailable")?; + if report.servers.is_empty() { + if report.status == "ok" { + writeln!(out, " no MCP servers configured")?; + } else { + writeln!(out, " MCP server config unavailable or invalid")?; + } + } else { + for server in &report.servers { + writeln!(out, " - {} [{}]", server.name, server.transport.kind)?; + writeln!(out, " command: {}", server.transport.command)?; + writeln!(out, " args: {} redacted", server.transport.arg_count)?; + writeln!(out, " cwd: {}", cwd_human(&server.cwd))?; + writeln!( + out, + " env: inherit_count={} set_count={} values=redacted", + server.env_policy.inherit_count, server.env_policy.set_count + )?; + writeln!(out, " capabilities: unavailable (not live)")?; + } + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn render_show_human(report: &ShowReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp server (workspace: {}, profile: {})", + report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " live: not live / unavailable")?; + match &report.server { + Some(server) => { + writeln!(out, " name: {}", server.name)?; + writeln!(out, " transport: {}", server.transport.kind)?; + writeln!(out, " command: {}", server.transport.command)?; + writeln!(out, " args: {} redacted", server.transport.arg_count)?; + writeln!(out, " cwd: {}", cwd_human(&server.cwd))?; + writeln!( + out, + " env: inherit_count={} set_count={} values=redacted", + server.env_policy.inherit_count, server.env_policy.set_count + )?; + writeln!(out, " trust:")?; + writeln!( + out, + " process_start: {}", + server.trust_policy.process_start + )?; + writeln!(out, " tool_calls: {}", server.trust_policy.tool_calls)?; + writeln!( + out, + " resource_prompt_content: {}", + server.trust_policy.resource_prompt_content + )?; + writeln!(out, " capabilities: unavailable (not live)")?; + } + None => { + writeln!(out, " server: missing or invalid")?; + } + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn render_tools_human(report: &ToolsReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp tools (workspace: {}, profile: {})", + report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " live: not live / unavailable")?; + if let Some(filter) = &report.server_filter { + writeln!(out, " server filter: {filter}")?; + } + if report.tools.is_empty() { + writeln!(out, " provider-discovered tools: unavailable (not live)")?; + } + for server in &report.servers { + writeln!( + out, + " - server {}: discovery={}, registration={}", + server.server_name, server.discovery_status, server.registration_status + )?; + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn render_resource_like_human(report: &ResourceLikeReport) -> Result { + let mut out = String::new(); + writeln!( + out, + "mcp {} (workspace: {}, profile: {})", + report.command, report.workspace, report.profile + )?; + writeln!(out, " status: {}", report.status)?; + writeln!(out, " live: not live / unavailable")?; + if let Some(filter) = &report.server_filter { + writeln!(out, " server filter: {filter}")?; + } + if report.items.is_empty() { + writeln!( + out, + " provider-discovered {}: unavailable (not live); content not fetched", + report.command + )?; + } + for server in &report.servers { + writeln!( + out, + " - server {}: discovery={}, registration={}", + server.server_name, server.discovery_status, server.registration_status + )?; + } + if !report.operations.is_empty() { + writeln!( + out, + " explicit operation tools (names are static; eligibility unknown until live discovery):" + )?; + for op in &report.operations { + writeln!( + out, + " - {} -> {} ({}, content={})", + op.method, op.yoi_stable_tool_name, op.registration_status, op.content_state + )?; + } + } + render_diagnostics_human(&mut out, &report.diagnostics)?; + Ok(out) +} + +fn cwd_human(cwd: &CwdReport) -> String { + match &cwd.path { + Some(path) => format!("{} ({path})", cwd.kind), + None => cwd.kind.to_string(), + } +} + +fn render_diagnostics_human(out: &mut String, diagnostics: &[DiagnosticReport]) -> Result<()> { + if diagnostics.is_empty() { + return Ok(()); + } + writeln!(out, " diagnostics:")?; + for diagnostic in diagnostics.iter().take(MAX_DIAGNOSTICS) { + writeln!( + out, + " - {} [{}]: {}", + diagnostic.level, diagnostic.code, diagnostic.message + )?; + } + Ok(()) +} + +fn truncate_diagnostics(diagnostics: &mut Vec) { + diagnostics.truncate(MAX_DIAGNOSTICS); +} + +fn sanitize_segment(input: &str) -> String { + let mut output = String::new(); + let mut last_underscore = false; + for ch in input.chars() { + let normalized = if ch.is_ascii_alphanumeric() { ch } else { '_' }; + if normalized == '_' { + if last_underscore { + continue; + } + last_underscore = true; + } else { + last_underscore = false; + } + output.push(normalized); + if output.len() >= 48 { + break; + } + } + let output = output.trim_matches('_').to_string(); + if output.is_empty() { + "unnamed".to_string() + } else { + output + } +} + +fn bounded_text(input: &str) -> String { + let mut out = String::new(); + let mut previous_space = false; + for ch in input.chars() { + let normalized = if ch.is_control() { ' ' } else { ch }; + if normalized.is_whitespace() { + if previous_space { + continue; + } + previous_space = true; + out.push(' '); + } else { + previous_space = false; + out.push(normalized); + } + if out.chars().count() >= MAX_TEXT_CHARS { + out.push('…'); + break; + } + } + out.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use std::path::Path; + use tempfile::TempDir; + + fn write_profile(dir: &Path, name: &str, body: &str) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, body).unwrap(); + path + } + + fn workspace_and_profile(body: &str) -> (TempDir, PathBuf, PathBuf) { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let profile = write_profile(tmp.path(), "mcp.lua", body); + (tmp, workspace, profile) + } + + fn mcp_profile() -> String { + r#" +local profile = require("yoi.profile") +return profile { + slug = "mcp-test", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, + mcp = { + stdio_server = { + { + name = "filesystem", + command = "definitely-not-spawned-during-cli-inspection", + args = { "--root", ".", "--token", "ARG_SECRET_DO_NOT_PRINT" }, + env = { + inherit = { "PATH" }, + set = { + SAFE_MODE = { kind = "literal", value = "SUPER_SECRET_LITERAL_DO_NOT_PRINT" }, + API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" }, + FROM_ENV = { kind = "env_ref", name = "SECRET_ENV_NAME_DO_NOT_PRINT" }, + }, + }, + }, + }, + }, +} +"# + .to_string() + } + + fn args(workspace: &Path, profile: &Path, json: bool) -> McpCliArgs { + McpCliArgs { + workspace: Some(workspace.to_path_buf()), + profile: Some(format!("path:{}", profile.display())), + json, + } + } + + #[test] + fn list_json_reports_resolved_servers_without_secret_values_or_args() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let output = render_list(&args(&workspace, &profile, true)).unwrap(); + assert!(output.contains("filesystem")); + assert!(output.contains("definitely-not-spawned-during-cli-inspection")); + assert!(!output.contains("ARG_SECRET_DO_NOT_PRINT")); + assert!(!output.contains("SUPER_SECRET_LITERAL_DO_NOT_PRINT")); + assert!(!output.contains("providers/mcp-token")); + assert!(!output.contains("SECRET_ENV_NAME_DO_NOT_PRINT")); + + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["command"], "list"); + assert_eq!(json["status"], "ok"); + assert_eq!(json["servers"][0]["transport"]["kind"], "stdio"); + assert_eq!(json["servers"][0]["transport"]["arg_count"], 4); + assert_eq!(json["servers"][0]["env_policy"]["set_count"], 3); + assert_eq!(json["live_state"]["status"], "not_live"); + assert_eq!(json["inspection_mode"]["starts_servers"], false); + } + + #[test] + fn show_json_reports_missing_server_explicitly() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let output = render_show("missing", &args(&workspace, &profile, true)).unwrap(); + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["command"], "show"); + assert_eq!(json["status"], "missing_server"); + assert!(json["server"].is_null()); + assert!(output.contains("server_not_configured")); + } + + #[test] + fn tools_json_reports_provider_discovery_unavailable_not_stale() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let output = render_tools(None, &args(&workspace, &profile, true)).unwrap(); + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["command"], "tools"); + assert_eq!(json["status"], "not_live"); + assert_eq!(json["tools"].as_array().unwrap().len(), 0); + assert_eq!(json["servers"][0]["discovery_status"], "unavailable"); + assert_eq!(json["servers"][0]["registration_status"], "not_live"); + assert!(output.contains("no server process was started")); + } + + #[test] + fn resources_and_prompts_json_report_operation_eligibility_without_content() { + let (_tmp, workspace, profile) = workspace_and_profile(&mcp_profile()); + let resource_output = + render_resources(Some("filesystem"), &args(&workspace, &profile, true)).unwrap(); + let resources: Value = serde_json::from_str(&resource_output).unwrap(); + assert_eq!(resources["command"], "resources"); + assert_eq!(resources["status"], "not_live"); + assert_eq!(resources["items"].as_array().unwrap().len(), 0); + assert_eq!( + resources["operations"][0]["yoi_stable_tool_name"], + "Mcp_filesystem_resources_list" + ); + assert_eq!(resources["operations"][0]["content_state"], "not_fetched"); + assert!(!resource_output.contains("RESOURCE_CONTENT_DO_NOT_PRINT")); + + let prompt_output = + render_prompts(Some("filesystem"), &args(&workspace, &profile, true)).unwrap(); + let prompts: Value = serde_json::from_str(&prompt_output).unwrap(); + assert_eq!(prompts["command"], "prompts"); + assert_eq!(prompts["status"], "not_live"); + assert_eq!(prompts["items"].as_array().unwrap().len(), 0); + assert_eq!( + prompts["operations"][1]["yoi_stable_tool_name"], + "Mcp_filesystem_prompts_get" + ); + assert_eq!(prompts["operations"][1]["content_state"], "not_fetched"); + assert!(!prompt_output.contains("PROMPT_CONTENT_DO_NOT_PRINT")); + } + + #[test] + fn invalid_config_is_reported_as_invalid_not_silently_empty() { + let body = r#" +local profile = require("yoi.profile") +return profile { + slug = "bad-mcp", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, + mcp = { + stdio_server = { + { name = "dup", command = "one" }, + { name = "dup", command = "two" }, + }, + }, +} +"#; + let (_tmp, workspace, profile) = workspace_and_profile(body); + let output = render_list(&args(&workspace, &profile, true)).unwrap(); + let json: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["status"], "invalid_config"); + assert_eq!(json["servers"].as_array().unwrap().len(), 0); + assert!(output.contains("profile_resolution_failed")); + assert!(output.contains("duplicate stdio server name")); + } + + #[test] + fn human_output_distinguishes_empty_and_unavailable() { + let body = r#" +local profile = require("yoi.profile") +return profile { + slug = "empty-mcp", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, +} +"#; + let (_tmp, workspace, profile) = workspace_and_profile(body); + let output = render_list(&args(&workspace, &profile, false)).unwrap(); + assert!(output.contains("no MCP servers configured")); + assert!(output.contains("live: not live / unavailable")); + } +} diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index c680126b..6ea65fde 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -329,6 +329,24 @@ fn static_inspection_diagnostics( }); } } + for service in &inspection.services { + if let Some(message) = &service.diagnostic { + diagnostics.push(PluginDiagnosticReport { + kind: "grant".to_string(), + phase: "resolution".to_string(), + message: bound_text(format!("service `{}`: {message}", service.name)), + }); + } + } + for ingress in &inspection.ingresses { + if let Some(message) = &ingress.diagnostic { + diagnostics.push(PluginDiagnosticReport { + kind: "grant".to_string(), + phase: "resolution".to_string(), + message: bound_text(format!("ingress `{}`: {message}", ingress.name)), + }); + } + } diagnostics } @@ -1072,6 +1090,18 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) { .iter() .filter_map(|tool| tool.diagnostic.as_ref()), ) + .chain( + static_runtime + .services + .iter() + .filter_map(|service| service.diagnostic.as_ref()), + ) + .chain( + static_runtime + .ingresses + .iter() + .filter_map(|ingress| ingress.diagnostic.as_ref()), + ) { builder.diagnostics.push(DiagnosticSummary { kind: "static_eligibility".to_string(), @@ -1473,6 +1503,58 @@ mod tests { assert!(show.contains("configured_grants: surfaces.tool, tool.Echo")); } + #[test] + fn service_only_enablement_ignores_unselected_tool_static_grants() { + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let digest = write_mixed_tool_service_package(workspace, "mixed"); + let mut config = PluginConfig::default(); + config.enabled.push(PluginEnablementConfig { + id: "project:mixed".to_string(), + digest: Some(digest.clone()), + version: Some(PluginExactVersion("0.1.0".to_string())), + surfaces: vec![PluginSurface::Service], + grants: PluginGrantConfig { + id: Some("project:mixed".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some(digest), + permissions: vec![ + PluginPermission::surface(PluginSurface::Service), + PluginPermission::service("svc"), + ], + https: Vec::new(), + fs: Vec::new(), + }, + config: None, + }); + + let snapshot = inspect_snapshot(workspace, &config); + let item = select_item(&snapshot, "project:mixed").unwrap(); + + assert_eq!(item.status, "active"); + assert!(item.static_eligible); + assert_eq!(item.enabled_surfaces, vec!["service"]); + assert!( + item.tools.is_empty(), + "unselected Tool must not be reported" + ); + assert!( + item.diagnostics + .iter() + .all(|diagnostic| !diagnostic.message.contains("tool.Echo")), + "unselected Tool grant diagnostics must not affect service-only enablement: {:#?}", + item.diagnostics + ); + + let show_json = serde_json::to_value(item).unwrap(); + assert_eq!(show_json["status"], "active"); + assert_eq!( + show_json["enabled_surfaces"], + serde_json::json!(["service"]) + ); + assert_eq!(show_json["tools"], serde_json::json!([])); + } + #[test] fn human_list_uses_required_status_vocabulary() { let dir = tempdir().unwrap(); @@ -2080,6 +2162,61 @@ mod tests { assert!(error.len() < 160); } + fn write_mixed_tool_service_package(workspace: &Path, id: &str) -> String { + let package_dir = workspace.join(".yoi/plugins"); + fs::create_dir_all(&package_dir).unwrap(); + let package = package_dir.join(format!("{id}.yoi-plugin")); + let manifest = format!( + r#"schema_version = 1 +id = "{id}" +name = "{id}" +version = "0.1.0" +description = "mixed surface package" +surfaces = ["tool", "service"] +permissions = [ + {{ kind = "surface", surface = "tool" }}, + {{ kind = "tool", name = "Echo" }}, + {{ kind = "surface", surface = "service" }}, + {{ kind = "service", name = "svc" }}, +] + +[runtime] +kind = "wasm-component" +world = "yoi:plugin/instance@1.0.0" +component = "plugin.component.wasm" + +[[tools]] +name = "Echo" +description = "unselected tool" +input_schema = {{ type = "object" }} + +[[services]] +name = "svc" +description = "selected service" +lifecycle = "host-managed" +"#, + ); + write_stored_zip( + &package, + &[ + ("plugin.toml", manifest.as_bytes()), + ("plugin.component.wasm", b"placeholder component bytes"), + ], + ); + let discovery = discover_plugins(&PluginDiscoveryOptions { + workspace_root: workspace.to_path_buf(), + user_data_home: None, + limits: PluginDiscoveryLimits::default(), + }); + discovery + .packages + .iter() + .find(|package| package.identity.local_id == id) + .unwrap() + .digest + .clone() + } + fn inspect_snapshot(workspace: &Path, config: &PluginConfig) -> PluginInspectionSnapshot { let discovery = discover_plugins(&PluginDiscoveryOptions { workspace_root: workspace.to_path_buf(), diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index f3ab5ee5..bf9aa9ff 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -179,3 +179,26 @@ semantics while moving package authors onto WIT/canonical ABI bindings. Structured WIT records for Tool requests/responses/errors and host HTTPS/FS payloads are deferred to a follow-up API-design step rather than accidentally omitted. + +## Instance lifecycle surface + +The first instance-capable world is `yoi:plugin/instance@1.0.0`. It moves +runtime ownership from per-Tool artifact execution to a host-managed +`PluginInstance`. The same instance handles Tool, Service, and Ingress surfaces, +so Plugin state/config/diagnostics can be shared without bypassing Yoi's normal +authority model. + +Important boundaries: + +- Tool calls still enter through `ToolRegistry` and return ordinary `ToolOutput` + that is visible in the Worker history path. +- Service and Ingress grants are separate from Tool grants. Sharing an instance + does not authorize a surface that lacks its own `surface.*` and per-surface + permission/grant. +- Ingress delivery accepts bounded typed untrusted events and returns explicit + JSON to the host. It does not call model Tools or mutate LLM context/history. +- Legacy raw-wasm and `yoi:plugin/tool@1.0.0` component packages are adapted + behind `PluginInstanceRegistry` for compatibility rather than executed through + a separate authority path. +- Host APIs such as `https` and `fs` remain independently grant-gated and still + reject ambient filesystem/network authority. diff --git a/resources/plugin/templates/rust-component-instance/Cargo.toml b/resources/plugin/templates/rust-component-instance/Cargo.toml new file mode 100644 index 00000000..06a0cf83 --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] + +[package] +name = "example-yoi-instance-plugin" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md new file mode 100644 index 00000000..aad35af3 --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/README.md @@ -0,0 +1,9 @@ +# Yoi instance Plugin template + +This template targets `yoi:plugin/instance@1.0.0`. The host creates one +`PluginInstance` for the package; Tool, Service, and Ingress surfaces share that +instance state while each surface keeps separate permissions/grants. + +Tools still run only through ordinary model/user-initiated Tool calls. Ingress +handlers receive bounded typed untrusted events and must return explicit JSON +for host-mediated visible/durable paths. diff --git a/resources/plugin/templates/rust-component-instance/plugin.component.wasm b/resources/plugin/templates/rust-component-instance/plugin.component.wasm new file mode 100644 index 00000000..66d02db0 --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/plugin.component.wasm @@ -0,0 +1,3 @@ +# Build with: +# cargo component build --release +# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm diff --git a/resources/plugin/templates/rust-component-instance/plugin.toml b/resources/plugin/templates/rust-component-instance/plugin.toml new file mode 100644 index 00000000..71bbbaec --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/plugin.toml @@ -0,0 +1,35 @@ +schema_version = 1 +id = "example.rust_instance_plugin" +name = "Rust Instance Plugin Template" +version = "0.1.0" +description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state." +surfaces = ["tool", "service", "ingress"] +permissions = [ + { kind = "surface", surface = "tool" }, + { kind = "tool", name = "example_instance_tool" }, + { kind = "surface", surface = "service" }, + { kind = "service", name = "example_instance_service" }, + { kind = "surface", surface = "ingress" }, + { kind = "ingress", name = "example_instance_ingress" }, +] + +[runtime] +kind = "wasm-component" +world = "yoi:plugin/instance@1.0.0" +component = "plugin.component.wasm" + +[[tools]] +name = "example_instance_tool" +description = "Return the input and increment shared instance state." +input_schema = { type = "object" } + +[[services]] +name = "example_instance_service" +description = "Reports shared plugin instance lifecycle status." +lifecycle = "host-managed" + +[[ingresses]] +name = "example_instance_ingress" +description = "Accepts bounded in-process ingress events." +event_kinds = ["example"] +input_schema = { type = "object" } diff --git a/resources/plugin/templates/rust-component-instance/src/lib.rs b/resources/plugin/templates/rust-component-instance/src/lib.rs new file mode 100644 index 00000000..0cc5a31a --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/src/lib.rs @@ -0,0 +1,52 @@ +use serde_json::{json, Value}; +use yoi_plugin_pdk::wit_bindgen; +use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput}; + +wit_bindgen::generate!({ + world: "instance", + path: "../../../../resources/plugin/wit", + generate_all, + runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", +}); + +struct ExamplePlugin { + calls: u64, +} + +impl Plugin for ExamplePlugin { + fn start(_config: Value) -> yoi_plugin_pdk::Result { + Ok(Self { calls: 0 }) + } + + fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result { + self.calls += 1; + ToolOutput::json( + format!("{name} handled by shared instance"), + json!({ + "tool": name, + "calls": self.calls, + "input": input + }), + ) + } + + fn handle_ingress( + &mut self, + name: &str, + event: PluginIngressEvent, + ) -> yoi_plugin_pdk::Result { + Ok(json!({ + "ingress": name, + "kind": event.kind, + "source": event.source, + "calls": self.calls, + "accepted": true + })) + } + + fn status(&self) -> yoi_plugin_pdk::Result { + Ok(PluginStatus::ready(json!({ "calls": self.calls }))) + } +} + +export_plugin_instance!(ExamplePluginComponent, ExamplePlugin); diff --git a/resources/plugin/wit/yoi-plugin-instance-v1.wit b/resources/plugin/wit/yoi-plugin-instance-v1.wit new file mode 100644 index 00000000..e811ecd7 --- /dev/null +++ b/resources/plugin/wit/yoi-plugin-instance-v1.wit @@ -0,0 +1,12 @@ +package yoi:plugin@1.0.0; + +world instance { + import yoi:host/https@1.0.0; + import yoi:host/fs@1.0.0; + + export start: func(config-json: string) -> string; + export handle-tool: func(name: string, input-json: string) -> string; + export handle-ingress: func(name: string, event-json: string) -> string; + export status: func() -> string; + export stop: func() -> string; +}