Compare commits

...

63 Commits

Author SHA1 Message Date
5df7580a1e
merge: integrate orchestration branch 2026-06-20 17:00:26 +09:00
96223148c0
ticket: clarify mcp runtime-discovered tools 2026-06-20 17:00:17 +09:00
68a8fc97d2
ticket: close mcp stdio lifecycle client 2026-06-20 16:59:37 +09:00
ca28c927b2
ticket: complete mcp stdio lifecycle client 2026-06-20 16:59:17 +09:00
9cf5344fc5
merge: mcp stdio lifecycle client 2026-06-20 16:58:29 +09:00
e5510620cc
ticket: approve mcp stdio lifecycle client 2026-06-20 16:58:21 +09:00
8e6b440f9a
ticket: record mcp stdio lifecycle redaction fix 2026-06-20 16:56:06 +09:00
f396e1a253
mcp: redact stdio server spec debug 2026-06-20 16:54:48 +09:00
39b55fb6e8
ticket: request changes on mcp stdio lifecycle 2026-06-20 16:51:36 +09:00
c91ed5600f
ticket: dispatch mcp stdio lifecycle review 2026-06-20 16:46:44 +09:00
35e6533986
ticket: record mcp stdio lifecycle implementation report 2026-06-20 16:46:05 +09:00
a114fa9d0a
mcp: implement stdio lifecycle client 2026-06-20 16:45:05 +09:00
017c4471ed
ticket: start mcp stdio lifecycle worktree 2026-06-20 16:31:10 +09:00
c0e760d73e
ticket: accept mcp stdio lifecycle client 2026-06-20 16:30:12 +09:00
8f5eef94e4
ticket: close mcp stdio config trust 2026-06-20 16:29:03 +09:00
c4a7eb7a2e
ticket: complete mcp stdio config trust 2026-06-20 16:28:39 +09:00
9b7c4e279d
merge: mcp stdio config trust 2026-06-20 16:27:12 +09:00
c6fa0b2d95
ticket: approve mcp stdio config trust 2026-06-20 16:27:07 +09:00
a0cd3dd0e9
ticket: dispatch mcp stdio config review 2026-06-20 16:19:31 +09:00
e578d888e3
ticket: record mcp stdio config implementation report 2026-06-20 16:18:46 +09:00
e0680ccee0
mcp: add stdio server config 2026-06-20 16:17:38 +09:00
6cc8551a6c
ticket: start mcp stdio config worktree 2026-06-20 15:57:19 +09:00
b0225e48b8
ticket: accept mcp stdio config trust 2026-06-20 15:56:29 +09:00
a5df9e3728
ticket: close plugin authoring cli 2026-06-20 15:55:14 +09:00
ead96654be
ticket: complete plugin authoring cli 2026-06-20 15:54:51 +09:00
0430ed982d
merge: plugin authoring cli 2026-06-20 15:50:55 +09:00
52e40b2fdc
ticket: approve plugin authoring cli 2026-06-20 15:50:48 +09:00
93265ae65c
ticket: record plugin authoring cli hardening fix 2026-06-20 15:46:18 +09:00
699db538b6
plugin: harden authoring checks 2026-06-20 15:45:12 +09:00
9ac540f7cf
ticket: request changes on plugin authoring cli 2026-06-20 15:36:56 +09:00
8206b5912d
ticket: dispatch plugin authoring cli review 2026-06-20 15:26:33 +09:00
59d0a58e3a
ticket: record plugin authoring cli implementation report 2026-06-20 15:25:47 +09:00
945ecdf64d
plugin: add authoring cli 2026-06-20 15:24:35 +09:00
e37a360d07
ticket: hold queued mcp chain for dependencies 2026-06-20 15:00:52 +09:00
9881a061fe
ticket: queue 00001KVHR3WSW 2026-06-20 14:59:05 +09:00
76729c3377
ticket: queue 00001KVHR3WSD 2026-06-20 14:59:04 +09:00
f880007639
ticket: queue 00001KVHR3WS6 2026-06-20 14:58:58 +09:00
eee2ce00e2
ticket: queue 00001KVHR3WSN 2026-06-20 14:58:57 +09:00
a729282cc1
ticket: queue 00001KVHR3WRY 2026-06-20 14:58:55 +09:00
061322d425
ticket: queue 00001KVHR3WRF 2026-06-20 14:58:46 +09:00
191e7999c0
merge: sync orchestration before queue 00001KVHR3WRF 2026-06-20 14:58:46 +09:00
c0239684ed
ticket: start plugin authoring cli worktree 2026-06-20 14:55:36 +09:00
d1095f854a
ticket: accept plugin authoring cli 2026-06-20 14:54:50 +09:00
902b383de7
ticket: close plugin rust pdk templates 2026-06-20 14:53:26 +09:00
ab7ab69f20
ticket: complete plugin rust pdk templates 2026-06-20 14:51:47 +09:00
edc53a6bc9
merge: plugin rust pdk templates 2026-06-20 14:47:33 +09:00
1f0766c1d7
ticket: approve plugin rust pdk templates 2026-06-20 14:47:25 +09:00
01f2e926b0
ticket: record plugin rust pdk template fix 2026-06-20 14:40:23 +09:00
0a9e585c1d
plugin: fix rust pdk wit template probes 2026-06-20 14:39:10 +09:00
75e8103cdd
ticket: split mcp stdio roadmap 2026-06-20 14:34:00 +09:00
356d06ef58
fix: allow queueing behind active blockers 2026-06-20 14:34:00 +09:00
730bc73975
ticket: hold plugin authoring cli for pdk dependency 2026-06-20 14:23:53 +09:00
b4cb9fbc41
ticket: queue 00001KVHKWNQS 2026-06-20 14:23:14 +09:00
2c78428d9d
merge: sync orchestration before queue 00001KVHKWNQS 2026-06-20 14:23:14 +09:00
88f4c7e104
ticket: request changes on plugin rust pdk 2026-06-20 14:21:54 +09:00
3fb3368272
ticket: dispatch plugin rust pdk review 2026-06-20 14:16:58 +09:00
af435fa9bc
ticket: record plugin rust pdk implementation report 2026-06-20 14:16:22 +09:00
06287aca40
plugin: add rust pdk template 2026-06-20 14:15:20 +09:00
6c04cb9a0b
merge: sync orchestration before queue 00001KVHKWNQS 2026-06-20 14:12:46 +09:00
b5e623e5a1
ticket: start plugin rust pdk worktree 2026-06-20 13:54:58 +09:00
5f7f81bdde
ticket: accept plugin rust pdk templates 2026-06-20 13:54:09 +09:00
9ca2f85b08
ticket: queue 00001KVHKWNQA 2026-06-20 13:52:58 +09:00
8b42e319e2
ticket: add plugin authoring roadmap 2026-06-20 13:43:16 +09:00
90 changed files with 7417 additions and 83 deletions

View File

@ -1,24 +1,25 @@
--- ---
title: 'MCP local stdio integration architecture' title: 'MCP local stdio integration roadmap'
state: 'active' state: 'active'
created_at: '2026-06-10T07:48:45Z' created_at: '2026-06-10T07:48:45Z'
updated_at: '2026-06-13T15:30:22Z' updated_at: '2026-06-20T05:34:00Z'
linked_tickets: ['00001KTR81P9X', '00001KV0SP0TY', '00001KVHR3WRF', '00001KVHR3WRY', '00001KVHR3WS6', '00001KVHR3WSD', '00001KVHR3WSN', '00001KVHR3WSW']
--- ---
## Objective ## Objective
Add MCP local stdio integration to Yoi without weakening Worker history, prompt-context, scoped tool permission, or Plugin/Feature layering invariants. Add MCP local stdio integration to Yoi without weakening Worker history, prompt-context, scoped tool permission, or Plugin/Feature layering invariants.
MCP should be implemented as a protocol-backed integration layer on top of `pod::feature`. `pod::feature` supplies the contribution/lifecycle API substrate; MCP owns its own enablement, local server trust model, command/env/secret policy, and MCP-specific permissions. MCP is not the Plugin model, and Plugin permission policy is not implemented by feature-layer authority grants. MCP is a protocol-backed integration layer on top of `pod::feature`. `pod::feature` supplies contribution/lifecycle/runtime-discovered registration substrate; MCP owns its own enablement, local server trust model, command/env/secret policy, and MCP-specific permission decisions. MCP is not the Plugin model, and Plugin permission policy is not implemented by feature-layer authority grants.
## Strategic direction ## Strategic direction
- Baseline the implementation on MCP specification `2025-11-25`. - Baseline the initial implementation on MCP specification `2025-11-25`.
- Start with local stdio MCP servers only. - Start with local stdio MCP servers only.
- Treat MCP server metadata, tools, resources, prompts, and results as untrusted content. - Treat MCP server metadata, tools, resources, prompts, and results as untrusted content.
- Do not allow MCP resources/prompts to be hidden context injection. - Do not allow MCP resources/prompts to become hidden context injection.
- They must be explicit tool operations with history records. - They must be explicit tool operations with history records.
- Use the normal Yoi tool registry, PreToolCall permission, history, and bounded result paths. - Use the normal Yoi ToolRegistry, PreToolCall permission, history, and bounded result paths.
- Do not add private MCP-only bypasses around Worker/tool invariants. - Do not add private MCP-only bypasses around Worker/tool invariants.
- Keep sampling and elicitation fail-closed initially. - Keep sampling and elicitation fail-closed initially.
- Keep Streamable HTTP, remote auth, OAuth, and MCP Registry/distribution out of the first slice. - Keep Streamable HTTP, remote auth, OAuth, and MCP Registry/distribution out of the first slice.
@ -28,37 +29,59 @@ MCP should be implemented as a protocol-backed integration layer on top of `pod:
## Layering decisions ## Layering decisions
- `pod::feature` is an API/contribution substrate. - `pod::feature` is an API/contribution substrate.
- It owns contribution declarations, provider/service lifecycle hooks, diagnostics, dynamic registration plumbing, and integration with normal Worker/ToolRegistry paths. - It owns contribution declarations, provider/service lifecycle hooks, diagnostics, runtime-discovered registration plumbing, and integration with normal Worker/ToolRegistry paths.
- It does not own Plugin permission policy or MCP server trust policy. - It does not own Plugin permission policy or MCP server trust policy.
- Plugin is a user-facing package/config/runtime layer over `pod::feature`. - Plugin is a user-facing package/config/runtime layer over `pod::feature`.
- Plugin permissions are Plugin-layer policy. - Plugin permissions are Plugin-layer policy.
- Plugin package discovery/enablement must not be conflated with MCP local server execution. - Plugin package discovery/enablement must not be conflated with MCP local server execution.
- MCP is a separate feature-backed integration layer. - MCP is a separate feature-backed integration layer.
- MCP enablement, command/env/secret handling, server trust, and MCP-specific permission decisions live in MCP config/implementation. - MCP enablement, command/env/secret handling, server trust, and MCP-specific permission decisions live in MCP config/implementation.
- MCP dynamic tools/resources/prompts are exposed through the feature API and ordinary Yoi tool paths. - MCP provider-discovered tools/resources/prompts are exposed through the feature API and ordinary Yoi tool paths.
## Work breakdown ## Concrete implementation tickets
1. `00001KTR81P9X` — Extend `pod::feature` API for protocol-backed external providers. Completed prerequisites:
- provider/service lifecycle
- startup discovery and dynamic contribution registration - `00001KTR81P9X` — Extend `pod::feature` API for external protocol-backed capability providers.
- bounded refresh semantics - `00001KV0SP0TY` — Remove feature-layer HostAuthority model.
- metadata/result normalization
- no feature-layer authority model for MCP/Plugin permissions Concrete MCP implementation sequence:
2. `00001KTR82RB7` — Implement MCP `2025-11-25` local stdio server bridge.
- explicit MCP config and trust model 1. `00001KVHR3WRF` — MCP local stdio server config and trust policy.
- initialize/capability negotiation - explicit config, command/env/secret redaction, local executable trust boundary, no auto-start.
- tools/resources/prompts list/call/read/get 2. `00001KVHR3WRY` — MCP stdio JSON-RPC lifecycle client.
- bounded result serialization - subprocess lifecycle, initialize/capability negotiation, diagnostics, shutdown.
- list-changed diagnostics/refresh behavior 3. `00001KVHR3WS6` — MCP tools/list registration into ToolRegistry.
3. `00001KV0SP0TY` — Remove feature-layer HostAuthority model. - provider-discovered tools, stable namespacing, schema validation, untrusted metadata normalization, no tools/call yet.
- remove authority/grant terminology from `pod::feature` 4. `00001KVHR3WSD` — MCP tools/call execution through ordinary Tool path.
- keep real permission/trust policy in owning Plugin/MCP/manifest/tool layers - PreToolCall gate before server call, bounded result serialization, history path.
4. Later follow-ups, if needed. 5. `00001KVHR3WSN` — MCP resources/prompts as explicit tool operations.
- richer MCP tasks / task-support integration - resources/list/read and prompts/list/get without hidden context injection.
- remote/HTTP transports 6. `00001KVHR3WSW` — MCP list_changed notification handling.
- OAuth / registry / package distribution - deterministic safe refresh/diagnostic behavior without breaking tool schema or prompt-cache invariants.
- Plugin package/runtime alignment, if an explicit MCP/plugin bridge is later approved
The old broad implementation Ticket `00001KTR82RB7` is superseded by this sequence and should not be used as an implementation work item.
## Terminology
Use `runtime-discovered` or `provider-discovered` for MCP tools/resources/prompts discovered from `tools/list`, `resources/list`, or `prompts/list`. Avoid `dynamic tools` / `dynamic registry` in new MCP design prose because those phrases imply that model-visible tool schemas may change during an active LLM run.
The intended invariant is:
```text
provider-discovered at startup / provider initialization;
registered into the ordinary ToolRegistry before model exposure;
run-stable for the duration of a model request/run;
refreshed only at a safe boundary or reported as a diagnostic.
```
## Later follow-ups
- Richer MCP task/task-support integration if ordinary tool-call fallback is insufficient.
- Streamable HTTP transport.
- OAuth / remote auth.
- Registry/package distribution.
- Explicit MCP/Plugin bridge only if separately approved; do not conflate Plugin packages with MCP local server execution.
## Success criteria ## Success criteria

View File

@ -1,8 +1,8 @@
--- ---
title: 'Implement MCP 2025-11-25 local stdio server-feature bridge' title: 'Implement MCP 2025-11-25 local stdio server-feature bridge'
state: 'planning' state: 'closed'
created_at: '2026-06-10T07:48:49Z' created_at: '2026-06-10T07:48:49Z'
updated_at: '2026-06-13T15:29:21Z' updated_at: '2026-06-20T05:33:15Z'
assignee: null assignee: null
readiness: 'blocked' readiness: 'blocked'
risk_flags: ['mcp', 'prompt-context', 'permission-scope', 'secrets', 'process-exec', 'feature-api', 'trust-boundary'] risk_flags: ['mcp', 'prompt-context', 'permission-scope', 'secrets', 'process-exec', 'feature-api', 'trust-boundary']

View File

@ -0,0 +1,13 @@
Closed as superseded by concrete MCP implementation Tickets.
This Ticket bundled config/trust policy, stdio lifecycle, tools/list registration, tools/call execution, resources/prompts operations, result serialization, and list_changed handling into one broad implementation item. That is too coarse for the current Ticket policy: Tickets should be concrete implementation tasks.
The MCP roadmap now lives in Objective `00001KTR80WMN` (`MCP local stdio integration roadmap`). Concrete follow-up Tickets are:
- `00001KVHR3WRF` — local stdio server config and trust policy;
- `00001KVHR3WRY` — stdio JSON-RPC lifecycle client;
- `00001KVHR3WS6` — server tools registration into ToolRegistry;
- `00001KVHR3WSD` — tools/call execution through ordinary Tool path;
- `00001KVHR3WSN` — resources/prompts as explicit tool operations;
- `00001KVHR3WSW` — list_changed notification handling.
Future MCP work should use those concrete Tickets or similarly scoped follow-ups, not this broad umbrella Ticket.

View File

@ -22,4 +22,34 @@ LocalTicketBackend によって作成されました。
- `00001KSXRQ4G8``00001KT0Z4BK8` は Plugin permission を Plugin layer として扱い、MCP を初期 Plugin packaging/runtime から分離する。 - `00001KSXRQ4G8``00001KT0Z4BK8` は Plugin permission を Plugin layer として扱い、MCP を初期 Plugin packaging/runtime から分離する。
---
<!-- event: state_changed author: hare at: 2026-06-20T05:33:15Z from: planning to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-20T05:33:15Z status: closed -->
## 完了
Closed as superseded by concrete MCP implementation Tickets.
This Ticket bundled config/trust policy, stdio lifecycle, tools/list registration, tools/call execution, resources/prompts operations, result serialization, and list_changed handling into one broad implementation item. That is too coarse for the current Ticket policy: Tickets should be concrete implementation tasks.
The MCP roadmap now lives in Objective `00001KTR80WMN` (`MCP local stdio integration roadmap`). Concrete follow-up Tickets are:
- `00001KVHR3WRF` — local stdio server config and trust policy;
- `00001KVHR3WRY` — stdio JSON-RPC lifecycle client;
- `00001KVHR3WS6` — server tools registration into ToolRegistry;
- `00001KVHR3WSD` — tools/call execution through ordinary Tool path;
- `00001KVHR3WSN` — resources/prompts as explicit tool operations;
- `00001KVHR3WSW` — list_changed notification handling.
Future MCP work should use those concrete Tickets or similarly scoped follow-ups, not this broad umbrella Ticket.
--- ---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260620-045332-1","ticket_id":"00001KVHKWNQA","kind":"accepted_plan","accepted_plan":{"summary":"Component Model Tool authoring用の first-party Rust PDK crate と embedded `rust-component-tool` template を追加する。PDK は guest-side only とし、raw pointer/length ABI を隠し、typed JSON helper / ToolError / ToolContext / WIT binding glue を提供する。crates.io 公開・remote template fetch・authoring CLI implementation は含めない。","branch":"impl/00001KVHKWNQA-plugin-rust-pdk-templates","worktree":"/home/hare/Projects/yoi/.worktree/00001KVHKWNQA-plugin-rust-pdk-templates","role_plan":"Orchestrator は queued acceptance を記録・commit 後、専用 implementation worktree を `.worktree/00001KVHKWNQA-plugin-rust-pdk-templates` に作成し、Coder をその child worktree への narrow write scope で起動する。Coder 実装後、Reviewer が PDK guest-only boundary、Component Model runtime compatibility、template/resource packaging、docs/tests/Nix impact を確認する。"},"author":"yoi-orchestrator","at":"2026-06-20T04:53:32Z"}

View File

@ -0,0 +1,37 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHKWNQA",
"kind": "depends_on",
"target": "00001KVG0HR96",
"note": "PDK targets the Component Model Tool runtime.",
"author": "yoi ticket",
"at": "2026-06-20T04:17:24Z"
},
{
"ticket_id": "00001KVHKWNQA",
"kind": "related",
"target": "00001KVFD3YSV",
"note": "PDK authoring flow should pair with read-only plugin inspection.",
"author": "yoi ticket",
"at": "2026-06-20T04:17:24Z"
},
{
"ticket_id": "00001KVHKWNQA",
"kind": "related",
"target": "00001KVFDX9AF",
"note": "PDK should later wrap https host API ergonomically.",
"author": "yoi ticket",
"at": "2026-06-20T04:17:24Z"
},
{
"ticket_id": "00001KVHKWNQA",
"kind": "related",
"target": "00001KVFDX9AY",
"note": "PDK should later wrap fs host API ergonomically.",
"author": "yoi ticket",
"at": "2026-06-20T04:17:24Z"
}
]
}

View File

@ -0,0 +1,90 @@
---
title: 'Plugin: add Rust PDK and embedded authoring templates for Component Model Tools'
state: 'closed'
created_at: '2026-06-20T04:16:14Z'
updated_at: '2026-06-20T05:53:20Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['plugin', 'pdk', 'component-model', 'authoring', 'templates', 'sdk', 'no-crates-io']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T04:52:58Z'
---
## Background
Yoi can now discover Plugin packages, register Tool surfaces, execute sandboxed WASM/Component Model Tool Plugins, enforce Plugin grants, expose `https` / `fs` host APIs, and inspect Plugin state through CLI. The next gap is authoring: independent Plugin developers should not need to write raw WIT/ABI glue or copy ad-hoc examples by hand.
This Ticket adds a first-party Rust PDK for Component Model Tool Plugins and embeds a starter template into Yoi resources. It deliberately does not publish to crates.io yet and does not fetch remote templates. The PDK/API and WIT world are still young, so out-of-tree authors should initially use a git rev or generated local path dependency rather than depending on a public semver crate.
## Requirements
- Add a Rust PDK crate inside the Yoi workspace.
- Suggested package name: `yoi-plugin-pdk`.
- Suggested path: `crates/plugin-pdk`.
- Target Component Model Tool Plugins, not raw core-Wasm as the primary authoring path.
- PDK must be guest-side only.
- Do not depend on host runtime crates such as `pod`, `llm-worker`, `tui`, or `client`.
- Keep dependencies minimal, e.g. `serde`, `serde_json`, and WIT binding support.
- Do not expose ambient fs/network/env authority.
- Provide ergonomic Tool helpers.
- Typed JSON input parsing.
- Typed JSON output serialization.
- Structured `ToolError` / error-code helpers.
- `ToolContext` containing at least the tool name.
- Helper equivalent to `run_json_tool` that returns the ToolOutput JSON expected by the Yoi runtime.
- Provide Component Model binding glue.
- Re-export or wrap generated WIT bindings enough that an author does not need to hand-write raw pointer/length ABI code.
- Prefer a minimal `Guest` impl helper first; add a macro only if it is simpler and testable.
- Include embedded authoring templates under runtime resources.
- Suggested path: `resources/plugin/templates/rust-component-tool/`.
- Template includes `Cargo.toml`, `src/lib.rs`, `plugin.toml`, and a short README.
- Template should be usable by a future `yoi plugin new` command without network access.
- Template dependency policy:
- In checkout/dev mode, template may use a local path dependency to `crates/plugin-pdk`.
- For out-of-tree authors, docs/template comments should show a git `rev` dependency pattern.
- Do not publish or require crates.io for this Ticket.
- Do not use `curl | sh` or remote template fetch.
- Include at least one example or fixture Plugin using the PDK.
- Echo-style Component Model Tool is enough.
- It should compile to a component artifact through the documented toolchain or, if full component build automation is not yet available, be covered by a clear fixture/test boundary.
- Update Plugin development docs.
- Explain that Component Model + PDK is the preferred authoring path.
- Explain that raw core-Wasm ABI is compatibility/transitional.
- Explain why crates.io publication and remote templates are intentionally deferred.
## Acceptance criteria
- Workspace contains a guest-side `yoi-plugin-pdk` crate.
- A Rust Component Model Tool Plugin can use the PDK to implement a JSON Tool without raw pointer/length ABI code.
- PDK-produced Tool success output is accepted by the existing Yoi Plugin Tool runtime.
- PDK-produced Tool errors are bounded and represented as ordinary Tool result/error content.
- PDK does not grant or imply authority; host-side Plugin grants still decide `https` / `fs` / Tool execution access.
- Embedded `rust-component-tool` template exists in runtime resources and is suitable for future `yoi plugin new` expansion.
- No crates.io publication is required or performed.
- No remote template fetch is implemented.
- Tests cover:
- PDK JSON input/output happy path;
- PDK error output path;
- PDK sample/template compiles or fixture is validated;
- sample Plugin can be executed by Yoi component runtime if feasible in current test infrastructure;
- PDK has no host-runtime crate dependency.
- Validation: focused PDK/plugin tests, `cargo fmt --check`, relevant `cargo check` / `cargo test`, `git diff --check`, and `nix build .#yoi` because workspace/package/resources may change.
## Non-goals
- Publishing `yoi-plugin-pdk` to crates.io.
- Remote template fetch or `curl | sh` setup.
- `yoi plugin new/check/pack` CLI implementation.
- Multi-language PDKs.
- Service / Ingress authoring.
- WebSocket / inbound HTTP bridge support.
- Replacing Plugin grants with PDK-side checks.
## Related work
- `00001KVG0HR9M` — Objective: Plugin platform roadmap.
- `00001KVG0HR96` — Plugin Component Model runtime.
- `00001KVFD3YSV` — Plugin read-only CLI inspection.
- `00001KVFDX9AF` — Plugin https host API.
- `00001KVFDX9AY` — Plugin fs host API.
- `docs/development/plugin-development.md` — current Plugin development guide.

View File

@ -0,0 +1,43 @@
## Resolution
`00001KVHKWNQA` を完了しました。
実装内容:
- Guest-side Rust PDK crate `yoi-plugin-pdk` を追加しました。
- PDK は typed JSON input/output helper、bounded `ToolError`、`ToolContext`、`run_json_tool` 系 helper、`wit_bindgen` re-export、`export_component_tool!` macro を提供します。
- PDK は host/runtime Yoi crates に依存せず、authority を付与しません。Host-side Plugin manifest grants が Tool execution / host API use の authority boundary のままです。
- Embedded Rust Component Tool template を `resources/plugin/templates/rust-component-tool/` に追加しました。
- Template は local checkout/dev path dependency を使い、future out-of-tree git `rev` pattern を docs に記録しています。
- `resources/plugin/wit``wit-bindgen` が parse できる package layout に修正し、host WIT dependency を `resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit` に移動しました。
- WIT keyword `list``%list` escape にし、import name semantics を保持しました。
- Embedded template は empty `[workspace]` により in-tree standalone package として check できます。
- `wit_bindgen::generate!` を実際に `resources/plugin/wit` に対して実行する probe と、embedded template の `wasm32-unknown-unknown` cargo-check probe を追加しました。
- Plugin development docs / design docs / package docs / example source を更新しました。
- `yoi plugin new/check/pack`、remote template fetch、crates.io publication、full packaged component execution はこの Ticket の non-goals / follow-up として残しました。
主な commit:
- `06287aca plugin: add rust pdk template`
- `0a9e585c plugin: fix rust pdk wit template probes`
- `edc53a6b merge: plugin rust pdk templates`
Review:
- r1 は WIT parse failure と embedded template Cargo workspace issue で `request_changes`
- Coder が WIT layout / `%list` / template `[workspace]` / actual probes を追加。
- r2 は `approve`
最終 validation:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p yoi-plugin-pdk`
- `cargo test -p manifest embedded_rust_component_tool_template_is_valid_package_shape`
- `cargo test -p pod pdk_tool_output_shape_is_accepted_by_wasm_decoder`
- `cargo check`
- `cargo tree -p yoi-plugin-pdk --edges normal`
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown`
- `nix build .#yoi --no-link`
Package impact:
- `nix path-info -S .#yoi`: `112156384`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-o9gvGb.log`

View File

@ -0,0 +1,439 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T04:16:14Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T04:52:58Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T04:53:59Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により、この Ticket は Orchestrator routing 対象として明示許可された。
- Ticket body は Rust PDK crate、guest-side only boundary、ergonomic Tool helpers、Component Model binding glue、embedded `rust-component-tool` template、docs/tests/validation/non-goals を実装可能な粒度で定義している。
- Blocking dependency `00001KVG0HR96` Component Model runtime は closed。Component runtime は explicit `wasm-component` metadata、ToolRegistry path、grant enforcement、no ambient WASI、resource limits、WIT files を含めて完了済み。
- Incoming dependency from `00001KVHKWNQS` は、この Ticket が将来の authoring CLI Ticket を unblock する関係であり、本 Ticket の blocker ではない。
- Related Plugin CLI / HTTPS / FS Tickets は closed または non-blocking context。
- 現在 queued はこの Ticket のみ、inprogress は 0 件、child implementation Pods はなし、matching branch/worktree はなし、Orchestrator worktree は clean。
- Risk domain は plugin / pdk / component-model / authoring / templates / sdk / no-crates-io だが、Ticket は no crates.io publication、no remote template fetch、guest-side only、no ambient authority、host-side grants remain authority などの invariants を明示している。bounded context check 後も implementation 前に必要な追加 human decision は見つからなかった。
Evidence checked:
- Ticket `00001KVHKWNQA` body / thread / relations / artifacts。
- `TicketRelationQuery(00001KVHKWNQA)`: outgoing `depends_on` target `00001KVG0HR96` is closed。incoming `00001KVHKWNQS depends_on this` is not a blocker。
- `TicketOrchestrationPlanQuery(00001KVHKWNQA)`: no previous plan records; accepted plan was recorded now。
- Workspace state:
- Orchestrator worktree clean at `9ca2f85b`
- queued: this Ticket only。
- inprogress: 0。
- visible Pods: self + peers only; spawned children 0。
- no matching implementation branch/worktree。
- Code/resource context:
- `resources/plugin/wit/yoi-plugin-tool-v1.wit` and `resources/plugin/wit/yoi-host-v1.wit` exist from the Component runtime work。
- `docs/development/plugin-development.md` is the current Plugin development guide target。
- Cargo workspace has no existing `crates/plugin-pdk` crate。
IntentPacket:
Intent:
- Add a first-party Rust PDK for Component Model Tool Plugins and an embedded starter template so authors can implement Yoi Plugin Tools without raw pointer/length ABI plumbing or remote template fetches。
- Position Component Model + PDK as preferred authoring path while keeping raw core-Wasm ABI as compatibility/transitional runtime support。
Binding decisions / invariants:
- PDK is guest-side only。It must not depend on host runtime crates such as `pod`, `llm-worker`, `tui`, or `client`
- PDK must not grant or imply authority。Host-side Plugin manifest grants remain the authority boundary for Tool execution / HTTPS / FS host APIs。
- No ambient fs/network/env authority is introduced。
- Do not publish to crates.io and do not implement remote template fetch。
- Do not implement `yoi plugin new/check/pack` in this Ticket; embedded resources should be suitable for that future Ticket。
- Template dependency policy must support checkout/dev local path dependency and document a future out-of-tree git `rev` pattern。
- PDK should target the current Component Model Tool world (`yoi:plugin/tool@1.0.0`) and runtime ToolOutput JSON bridge。
- Prefer minimal, testable helper APIs over broad macro magic。
Requirements / acceptance criteria:
- Add workspace crate `yoi-plugin-pdk` under `crates/plugin-pdk` or a justified equivalent。
- Provide typed JSON input parsing, output serialization, structured/bounded `ToolError`, `ToolContext` with at least tool name, and helper equivalent to `run_json_tool` producing ToolOutput JSON accepted by the current runtime。
- Provide WIT binding glue/re-export/wrapper enough that authors do not hand-write raw pointer/length ABI code。
- Add embedded template under `resources/plugin/templates/rust-component-tool/` with `Cargo.toml`, `src/lib.rs`, `plugin.toml`, and README/next steps。
- Include example or fixture Plugin using the PDK。
- Update Plugin development docs and explain publication/template-fetch deferrals。
- Tests cover PDK happy/error paths, template/sample fixture validation, runtime acceptance if feasible, and no host-runtime crate dependency。
- Validation includes focused PDK/plugin tests, `cargo fmt --check`, relevant `cargo check`/`cargo test`, `git diff --check`, and `nix build .#yoi` because workspace/package/resources may change。
Escalate if:
- Current WIT/runtime shape cannot support a guest-side helper without changing the Component runtime contract。
- A full sample component build requires external tooling that cannot be kept deterministic in this repo。
- Keeping the PDK guest-only would require undesirable dependencies on host crates。
- Template/resource packaging needs a broader CLI/resource architecture decision。
Next action:
- Record `queued -> inprogress` and commit Ticket records before creating the implementation worktree and spawning Coder。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T04:54:04Z from: queued to: inprogress reason: orchestrator_acceptance_plugin_rust_pdk_templates field: state -->
## State changed
Ticket body/thread, relation metadata, Component Model runtime dependency state, related completed Tickets, Orchestrator worktree state, visible Pods, existing branch/worktree, and bounded resource/code context were checked. Dependency `00001KVG0HR96` is closed, no inprogress/capacity blocker exists, and no missing planning decision was found. Accepting this queued Ticket for implementation before worktree/Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T04:54:51Z -->
## Implementation report
Implementation orchestration start:
- Acceptance records were committed on Orchestrator branch: `5f7f81bd ticket: accept plugin rust pdk templates`.
- Created implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVHKWNQA-plugin-rust-pdk-templates` on branch `impl/00001KVHKWNQA-plugin-rust-pdk-templates` at `5f7f81bd`.
- Spawned Coder Pod `yoi-coder-00001KVHKWNQA` 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, merge, Ticket close, and cleanup.
Next action:
- Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T05:16:16Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVHKWNQA`.
Implementation commit:
- `06287aca plugin: add rust pdk template`
Changed areas reported:
- Added workspace crate `crates/plugin-pdk` / `yoi-plugin-pdk`:
- guest-side PDK helpers;
- typed JSON input parsing via `parse_json_input` / `run_json_tool`;
- typed JSON output via `ToolOutput::json`;
- bounded structured `ToolError` and error-code helpers;
- `ToolContext` with selected tool name;
- `wit_bindgen` re-export and `export_component_tool!` macro to avoid raw pointer/length ABI plumbing;
- tests for happy path, error path, oversized output, template validation, and host-runtime dependency exclusion。
- Added embedded starter template under `resources/plugin/templates/rust-component-tool/` with `Cargo.toml`, `src/lib.rs`, `plugin.toml`, and `README.md`
- Added embedded template constants in `crates/manifest/src/plugin.rs` for future authoring CLI use without remote fetching。
- Updated Component Model example to use the PDK。
- Added runtime decoder test confirming PDK-produced ToolOutput JSON shape is accepted。
- Updated Plugin development/design/package docs。
- Updated workspace/package metadata: root `Cargo.toml`, `Cargo.lock`, `package.nix` cargo hash。
Coder validation reported as passing:
- `cargo test -p yoi-plugin-pdk`
- `cargo test -p manifest embedded_rust_component_tool_template_is_valid_package_shape`
- `cargo test -p pod pdk_tool_output_shape_is_accepted_by_wasm_decoder`
- `cargo check`
- `cargo fmt --check`
- `git diff --check`
- `git diff --check --cached`
- `cargo tree -p yoi-plugin-pdk --edges normal`
- `nix build .#yoi --no-link`
Known deferrals reported:
- No `yoi plugin new/check/pack`, remote template fetch, or crates.io publication。
- Full deterministic sample component build/pack execution remains deferred to future authoring CLI/tooling; current sample/template are fixture-validated and use current WIT/PDK shape。
- Raw core-Wasm ABI remains compatibility/transitional runtime support。
Orchestrator evidence checked before review dispatch:
- Implementation worktree is clean on `impl/00001KVHKWNQA-plugin-rust-pdk-templates`
- HEAD is `06287aca`
- Diff from acceptance `5f7f81bd..HEAD` is one implementation commit touching 17 files, about 857 insertions / 35 deletions。
- `git diff --check 5f7f81bd..HEAD` produced no diagnostics。
Next action:
- Dispatch Reviewer for r1 review against Ticket requirements, with focus on guest-only PDK boundary, no host-runtime deps, authority/grant wording, template/resource packaging, sample/test boundary honesty, docs, and Nix/workspace changes。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-20T05:16:53Z -->
## Plan
Review dispatch:
- Spawned Reviewer Pod `yoi-reviewer-00001KVHKWNQA-r1` against implementation branch `impl/00001KVHKWNQA-plugin-rust-pdk-templates`
- Review target commit: `06287aca plugin: add rust pdk template`
- Review baseline: `5f7f81bd`
- Reviewer task focuses on guest-only PDK boundary, absence of host-runtime deps, authority/grant wording, no ambient fs/network/env, template/resource packaging, current WIT/runtime compatibility, sample/test honesty, docs, and Nix/workspace changes。
- Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。
---
<!-- event: review author: yoi-reviewer-00001KVHKWNQA-r1 at: 2026-06-20T05:21:50Z status: request_changes -->
## Review: request changes
Verdict: `request_changes`
確認範囲:
- Ticket body/thread / Orchestrator IntentPacket
- Diff: `5f7f81bd..06287aca`
- 主な対象: `crates/plugin-pdk/*`, embedded template `resources/plugin/templates/rust-component-tool/*`, `resources/plugin/wit/*`, `crates/manifest/src/plugin.rs`, `crates/pod/src/feature/plugin.rs`, workspace/package docs/Nix/Cargo paths。
良い点:
- PDK は guest-side only として提示され、Yoi host/runtime crates への normal dependency は見当たらない。
- docs/templates は host-side Plugin grants が authority boundary であること、crates.io publication / remote template fetch を要求しないことを概ね維持している。
- `ToolError` / `ToolOutput` bounds と runtime decoder test は現在の ToolOutput JSON bridge と整合している。
Blocking issues:
1. PDK/template authoring path が現在の WIT で実際に compile/parse できない。
- Template/example/docs は次の generated binding path を示している。
- `resources/plugin/templates/rust-component-tool/src/lib.rs`
- `docs/examples/plugin-component-tool/lib.rs`
- `crates/plugin-pdk/src/lib.rs`
- しかし `resources/plugin/wit/yoi-host-v1.wit` の interface `fs``list: func(...)` を使っており、`wit-bindgen` probe で `list` が keyword として扱われ parse error になった。
- これは “author does not need to hand-write raw pointer/length ABI code” と “template/sample compile or honest validated fixture boundary” の Ticket contract に反する。
2. Embedded template を source tree 内で Cargo package として check できない。
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown` が root workspace 配下の non-member package として失敗した。
- Template を workspace member にするか、workspace exclude / embedded template 側の empty `[workspace]` 等で standalone fixture として check 可能にする必要がある。
- 現在の shape tests は `wit_bindgen::generate!` や WIT parse を実行していないため、この問題を捕捉できていない。
Required fix:
- Current WIT を `wit-bindgen` が parse できるよう修正する。`list` は WIT keyword なので `%list` escape など、runtime import name semantics を壊さない形を優先すること。
- Embedded template を in-place または controlled fixture copy で Cargo check できるようにする。
- `wit_bindgen::generate!` against `resources/plugin/wit` を実際に通す test/probe を追加する。string-shape assertion だけでは不可。
- Template/example/PDK docs が full build boundary と deferral を正直に表現していることを確認する。
Reviewer validation:
- Passed:
- `cargo fmt --check`
- `git diff --check 5f7f81bd..HEAD`
- `cargo test -p yoi-plugin-pdk`
- `cargo test -p manifest embedded_rust_component_tool_template_is_valid_package_shape`
- `cargo test -p pod pdk_tool_output_shape_is_accepted_by_wasm_decoder`
- `cargo check`
- `cargo tree -p yoi-plugin-pdk --edges normal`
- `nix build .#yoi --no-link`
- Failed reviewer probes:
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown`: workspace membership/standalone failure。
- copied template probe with adjusted relative paths and empty `[workspace]`: WIT parse failure at `resources/plugin/wit/yoi-host-v1.wit` because `list` is keyword。
Worktree status at review end: source tree clean; ignored artifacts under `target/` only。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T05:40:15Z -->
## Implementation report
Coder r1-fix report received from `yoi-coder-00001KVHKWNQA`.
New fix commit:
- `0a9e585c plugin: fix rust pdk wit template probes`
Branch commits now:
- `0a9e585c plugin: fix rust pdk wit template probes`
- `06287aca plugin: add rust pdk template`
Fix summary:
- Moved host WIT to dependency package path `resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit` so `resources/plugin/wit` is a valid WIT package directory with `yoi:host` resolved as dependency。
- Escaped WIT keyword function as `%list` while preserving import name semantics。
- Added empty `[workspace]` to embedded template `Cargo.toml` so it can be checked in-place without becoming an undeclared member of Yoi root workspace。
- Updated template/example/docs/PDK snippets to the compile-tested binding pattern with PDK `wit_bindgen` re-export, `generate_all`, and `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`
- Added compile-time probe `crates/plugin-pdk/tests/wit_bindgen_probe.rs` that runs `wit_bindgen::generate!` against `resources/plugin/wit`
- Added embedded template cargo-check probe for `wasm32-unknown-unknown` in `crates/plugin-pdk/tests/template.rs`
- Added `%list` WIT assertion and updated host WIT include path。
- Updated `Cargo.lock` and `package.nix` for new dev dependency edge / cargo hash。
Coder validation reported as passing:
- `cargo test -p yoi-plugin-pdk` including PDK tests, WIT constants, actual `wit_bindgen::generate!` probe, and embedded template wasm32 check probe。
- `cargo test -p manifest embedded_rust_component_tool_template_is_valid_package_shape`
- `cargo test -p pod pdk_tool_output_shape_is_accepted_by_wasm_decoder`
- `cargo check`
- `cargo tree -p yoi-plugin-pdk --edges normal`
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown`
- `cargo fmt --check`
- `git diff --check 5f7f81bd..HEAD`
- `git diff --check`
- `git diff --check --cached`
- `nix build .#yoi --no-link`
Operational note:
- `nix build .#yoi --no-link` initially hit disk exhaustion after accepting the updated vendor hash; Coder removed local implementation worktree `target/` cache and reran successfully。
Remaining deferrals:
- No `yoi plugin new/check/pack`
- No crates.io publication or remote template fetching。
- Full packaged component execution remains deferred; the current WIT parses with `wit-bindgen` and embedded Rust template compiles to the wasm target boundary。
Orchestrator evidence checked before r2 review:
- Implementation worktree is clean。
- HEAD is `0a9e585c`
- Fix diff from `06287aca..HEAD` touches 12 files, about 105 insertions / 13 deletions。
- Overall `git diff --check 5f7f81bd..HEAD` produced no diagnostics。
Next action:
- Route back to Reviewer for r2 verification of the WIT parse/template compile blockers and final acceptance criteria。
---
<!-- event: review author: yoi-reviewer-00001KVHKWNQA-r1 at: 2026-06-20T05:47:13Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket body/thread / Orchestrator IntentPacket
- r2 fix diff: `06287aca..0a9e585c`
- 全体実装 diff: `5f7f81bd..0a9e585c`
- 主な対象: `resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit`, `resources/plugin/wit/yoi-plugin-tool-v1.wit`, embedded template, `crates/plugin-pdk/*`, `crates/manifest/src/plugin.rs`, `crates/pod/src/feature/plugin.rs`, docs, `package.nix`
Previous blockers verification:
- Host WIT は `resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit` に配置され、`resources/plugin/wit` は current package directory として `wit-bindgen` が parse できる形になった。
- Keyword function は `%list` として escape され、import name semantics を保持している。
- Embedded template の `Cargo.toml` には empty `[workspace]` があり、root workspace 配下の non-member package として拒否されない。
- `crates/plugin-pdk/tests/wit_bindgen_probe.rs` は実際に `wit_bindgen::generate!``resources/plugin/wit` に対して実行している。
- Embedded template は `wasm32-unknown-unknown` target で cargo-check できる。
Overall Ticket fit:
- PDK は guest-side に留まり、host/runtime Yoi crates への dependency は見当たらない。
- PDK / template は authority を付与せず、host-side Plugin manifest grants が authority boundary であることを維持している。
- Ambient fs/network/env authority は露出していない。
- crates.io publication / remote template fetch / `yoi plugin new/check/pack` は実装していない。
- Docs は full build/pack boundary と deferral を概ね正直に記述している。
Blocking issues: none。
Non-blocking concerns / follow-ups:
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown` を直接実行すると template-local `Cargo.lock``target/` ができる。Reviewer は検証後に untracked artifacts を削除した。テスト自体は temporary target dir を使い template lockfile を消すため blocking ではないが、将来 docs/CI では explicit `--target-dir` を推奨するとよい。
- Reviewer r2 の `nix build .#yoi --no-link` は final artifact copy 中に `No space left on device` で environment-fail。cargo hash/source-filter/code diagnostic ではない。Coder 側では同コマンド成功済み。Orchestrator final validation で再確認する。
Reviewer validation:
- `cargo test -p yoi-plugin-pdk`: passed
- `cargo test -p manifest embedded_rust_component_tool_template_is_valid_package_shape`: passed
- `cargo test -p pod pdk_tool_output_shape_is_accepted_by_wasm_decoder`: passed
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown`: passed
- `cargo tree -p yoi-plugin-pdk --edges normal`: passed; host/runtime Yoi crate dependencyなし
- `cargo fmt --check`: passed
- `git diff --check 5f7f81bd..HEAD`: passed
- `git diff --check`: passed
- `git diff --check --cached`: passed
- `cargo check`: passed
- `nix build .#yoi --no-link`: attempted, environment failed with `No space left on device` during final artifact copying
Worktree status at review end: source tree clean; ignored artifacts under normal `target/` only。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T05:51:33Z -->
## Implementation report
Merge and final validation report:
Merged implementation branch into Orchestrator branch:
- Merge commit: `edc53a6b merge: plugin rust pdk templates`
- Implementation commits included:
- `06287aca plugin: add rust pdk template`
- `0a9e585c plugin: fix rust pdk wit template probes`
Reviewer outcome:
- r1 requested changes for WIT parse failure (`list` keyword) and embedded template Cargo workspace check failure。
- Coder fixed WIT package layout / `%list` escaping, added actual `wit_bindgen::generate!` probe, and made the embedded template standalone-checkable。
- r2 approved with no blocking issues。
Orchestrator validation after merge passed:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p yoi-plugin-pdk`
- `cargo test -p manifest embedded_rust_component_tool_template_is_valid_package_shape`
- `cargo test -p pod pdk_tool_output_shape_is_accepted_by_wasm_decoder`
- `cargo check`
- `cargo tree -p yoi-plugin-pdk --edges normal`
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-o9gvGb.log`
Package impact:
- `nix path-info -S .#yoi`: `112156384`
Cleanup note:
- Direct template cargo-check creates local `resources/plugin/templates/rust-component-tool/Cargo.lock` and `target/`; Orchestrator removed those untracked artifacts after validation. Worktree is clean。
Final state:
- Orchestrator worktree clean at `edc53a6b` after validation。
- Implementation worktree remains available for cleanup after Ticket completion records are committed。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T05:51:42Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Implementation was merged into Orchestrator branch at `edc53a6b`, r2 review approved, and final Orchestrator validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, focused PDK/manifest/pod tests, `cargo check`, PDK dependency tree check, embedded template wasm32 check, and `nix build .#yoi --no-link`.
---
<!-- event: state_changed author: hare at: 2026-06-20T05:53:20Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-20T05:53:20Z status: closed -->
## 完了
## Resolution
`00001KVHKWNQA` を完了しました。
実装内容:
- Guest-side Rust PDK crate `yoi-plugin-pdk` を追加しました。
- PDK は typed JSON input/output helper、bounded `ToolError`、`ToolContext`、`run_json_tool` 系 helper、`wit_bindgen` re-export、`export_component_tool!` macro を提供します。
- PDK は host/runtime Yoi crates に依存せず、authority を付与しません。Host-side Plugin manifest grants が Tool execution / host API use の authority boundary のままです。
- Embedded Rust Component Tool template を `resources/plugin/templates/rust-component-tool/` に追加しました。
- Template は local checkout/dev path dependency を使い、future out-of-tree git `rev` pattern を docs に記録しています。
- `resources/plugin/wit``wit-bindgen` が parse できる package layout に修正し、host WIT dependency を `resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit` に移動しました。
- WIT keyword `list``%list` escape にし、import name semantics を保持しました。
- Embedded template は empty `[workspace]` により in-tree standalone package として check できます。
- `wit_bindgen::generate!` を実際に `resources/plugin/wit` に対して実行する probe と、embedded template の `wasm32-unknown-unknown` cargo-check probe を追加しました。
- Plugin development docs / design docs / package docs / example source を更新しました。
- `yoi plugin new/check/pack`、remote template fetch、crates.io publication、full packaged component execution はこの Ticket の non-goals / follow-up として残しました。
主な commit:
- `06287aca plugin: add rust pdk template`
- `0a9e585c plugin: fix rust pdk wit template probes`
- `edc53a6b merge: plugin rust pdk templates`
Review:
- r1 は WIT parse failure と embedded template Cargo workspace issue で `request_changes`
- Coder が WIT layout / `%list` / template `[workspace]` / actual probes を追加。
- r2 は `approve`
最終 validation:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p yoi-plugin-pdk`
- `cargo test -p manifest embedded_rust_component_tool_template_is_valid_package_shape`
- `cargo test -p pod pdk_tool_output_shape_is_accepted_by_wasm_decoder`
- `cargo check`
- `cargo tree -p yoi-plugin-pdk --edges normal`
- `cargo check --manifest-path resources/plugin/templates/rust-component-tool/Cargo.toml --target wasm32-unknown-unknown`
- `nix build .#yoi --no-link`
Package impact:
- `nix path-info -S .#yoi`: `112156384`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-o9gvGb.log`
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260620-052336-1","ticket_id":"00001KVHKWNQS","kind":"blocked_by","related_ticket":"00001KVHKWNQA","note":"Panel Queue was accepted for routing review, but implementation cannot start yet because `00001KVHKWNQS` depends on `00001KVHKWNQA` Rust PDK/templates, which is currently `inprogress` and in a reviewer-requested-changes loop. Leave `00001KVHKWNQS` queued until `00001KVHKWNQA` is closed, then reroute.","author":"yoi-orchestrator","at":"2026-06-20T05:23:36Z"}
{"id":"orch-plan-20260620-055356-2","ticket_id":"00001KVHKWNQS","kind":"accepted_plan","accepted_plan":{"summary":"`yoi plugin new rust-component-tool`, `yoi plugin check`, `yoi plugin pack` を追加する。new は embedded template only、check/pack は Plugin code を実行せず、directory/package safety、manifest/runtime/schema/permission diagnostics、deterministic digest/package output、JSON reports、enablement guidance を提供する。","branch":"impl/00001KVHKWNQS-plugin-authoring-cli","worktree":"/home/hare/Projects/yoi/.worktree/00001KVHKWNQS-plugin-authoring-cli","role_plan":"Orchestrator は acceptance records を commit 後、専用 implementation worktree `.worktree/00001KVHKWNQS-plugin-authoring-cli` を作成し、Coder をその child worktree への narrow write scope で起動する。Coder 実装後、Reviewer が non-execution checks、archive safety、deterministic pack/digest、JSON report stability、template use、workspace/config non-mutation、Nix/resource packaging を確認する。"},"author":"yoi-orchestrator","at":"2026-06-20T05:53:56Z"}

View File

@ -0,0 +1,29 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHKWNQS",
"kind": "depends_on",
"target": "00001KVHKWNQA",
"note": "Authoring new/check/pack uses the Rust PDK and embedded templates.",
"author": "yoi ticket",
"at": "2026-06-20T04:17:24Z"
},
{
"ticket_id": "00001KVHKWNQS",
"kind": "related",
"target": "00001KVFD3YSV",
"note": "Authoring check/pack diagnostics should align with plugin list/show inspection.",
"author": "yoi ticket",
"at": "2026-06-20T04:17:24Z"
},
{
"ticket_id": "00001KVHKWNQS",
"kind": "related",
"target": "00001KVG0HR96",
"note": "Authoring CLI validates Component Model Tool package metadata.",
"author": "yoi ticket",
"at": "2026-06-20T04:17:24Z"
}
]
}

View File

@ -0,0 +1,93 @@
---
title: 'Plugin: add authoring CLI new/check/pack'
state: 'closed'
created_at: '2026-06-20T04:16:14Z'
updated_at: '2026-06-20T06:55:06Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T05:23:14Z'
---
## Background
After the Rust PDK and embedded templates exist, independent Plugin developers need first-party CLI tooling to scaffold, validate, and package Plugins without relying on remote shell scripts or hand-written ZIP commands.
This Ticket adds authoring commands for local Plugin development. It complements read-only operational inspection (`yoi plugin list/show`) but serves a different audience: Plugin authors preparing a package before enabling it in Yoi.
## Requirements
- Add Plugin authoring subcommands to the product CLI.
- `yoi plugin new rust-component-tool <path-or-name>`
- `yoi plugin check <path-or-package>`
- `yoi plugin pack <path> [--output <file>]`
- `new` uses embedded templates only.
- No remote template fetch.
- No `curl | sh` flow.
- Generated files should include `Cargo.toml`, `src/lib.rs`, `plugin.toml`, and README/next-steps.
- Generated dependency should be appropriate for the current checkout/release mode: local path in checkout mode, or documented git rev/tag pattern if out-of-tree.
- `check` validates a Plugin directory or `.yoi-plugin` package without executing Plugin code.
- Parse `plugin.toml`.
- Validate package id/version/source-compatible shape.
- Validate runtime kind and referenced artifact presence.
- Validate Component Model world metadata where possible.
- Validate Tool schema shape.
- Validate requested permissions / host API declarations.
- Validate archive safety for packages: path traversal, root escape, unsupported compression, bounded file count/size.
- Calculate and print deterministic digest.
- Produce actionable diagnostics and a suggested enablement/grant snippet.
- `pack` creates a deterministic `.yoi-plugin` package.
- Include required manifest/runtime files.
- Use currently supported archive format, including stored entries if compression is not supported.
- Reject unsafe paths / root escapes.
- Print output path and digest.
- Do not modify workspace enablement config.
- Provide JSON output for automation where useful.
- At minimum `check --json` and `pack --json`.
- Keep commands safe.
- `check` and `pack` do not execute Plugin code.
- `new` only writes into the requested destination and refuses to overwrite non-empty directories unless an explicit safe option is added.
- No secrets are generated or embedded.
- Integrate with existing inspection language.
- Diagnostics/statuses should align with `yoi plugin list/show` where possible.
## Acceptance criteria
- `yoi plugin new rust-component-tool ./my-plugin` creates a usable template without network access.
- `yoi plugin check ./my-plugin` validates the generated template and reports next steps/digest/enablement guidance.
- `yoi plugin pack ./my-plugin` creates a `.yoi-plugin` package that Yoi discovery can read.
- `check` can validate an existing `.yoi-plugin` archive.
- `check --json` returns a stable typed report suitable for tests/agents.
- `pack --json` returns output path and digest.
- Unsafe package paths / traversal / unsupported compression / missing manifest / missing runtime artifact are rejected with clear diagnostics.
- Commands do not execute Plugin code or mutate enablement config.
- Tests cover:
- `new` generated file set;
- refusal to overwrite non-empty destination;
- `check` valid directory;
- `check` invalid manifest;
- `check` missing runtime artifact;
- `check` unsafe package archive;
- `pack` deterministic digest;
- `pack` package is discoverable by existing Plugin discovery;
- JSON report shape.
- Validation: focused CLI/plugin authoring tests, relevant `cargo check` / `cargo test`, `cargo fmt --check`, `git diff --check`, and `nix build .#yoi` because product CLI/resources/packaging behavior changes.
## Non-goals
- Remote template fetching.
- Publishing or installing from a registry.
- Enabling/disabling Plugins in workspace config.
- Executing Plugin code during `check`.
- crates.io publication of PDK.
- Service / Ingress scaffolding.
- Multi-language templates beyond Rust Component Model Tool.
## Related work
- `00001KVG0HR9M` — Objective: Plugin platform roadmap.
- `00001KVHKWNQA` — Rust PDK and embedded authoring templates.
- `00001KVFD3YSV` — Plugin read-only CLI inspection list/show.
- `00001KVG0HR96` — Plugin Component Model runtime.
- `docs/development/plugin-development.md` — current Plugin development guide.

View File

@ -0,0 +1,41 @@
## Resolution
`00001KVHKWNQS` を完了しました。
実装内容:
- `yoi plugin new rust-component-tool <path-or-name>` を追加しました。
- `yoi plugin check <path-or-package> [--json]` を追加しました。
- `yoi plugin pack <path> [--output <file>] [--json]` を追加しました。
- Safe directory/package reading、deterministic digesting、deterministic `.yoi-plugin` writing、symlink/root-escape rejection を含む materialized package helpers を追加しました。
- `check` / `pack` は Plugin code を実行せず、既存 static Plugin inspection を再利用して manifest/runtime/schema/permission/host API declarations を検査します。
- Embedded Rust Component Tool template を `new` で利用し、generated template を check/pack できるよう placeholder `plugin.component.wasm` を追加しました。
- Placeholder artifact は `check` で検出され、generated template / packed archive は `partial` と bounded diagnostic を返します。placeholder が残る間は enablement-ready guidance を出しません。
- `plugin new` は existing destination symlink を拒否し、write-through を防ぎます。
- JSON report shape、human output、CLI help/docs を更新しました。
- Focused tests と CLI smoke coverage を追加しました。
主な commit:
- `945ecdf6 plugin: add authoring cli`
- `699db538 plugin: harden authoring checks`
- `87704ad4 merge: plugin authoring cli`
Review:
- r1 は destination symlink write-through と placeholder artifact の enablement-ready 表示で `request_changes`
- Coder が symlink refusal、placeholder detection、`partial` status/diagnostics、tests/docs を追加。
- r2 は `approve`
最終 validation:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo check -p yoi`
- `cargo test -p yoi plugin_cli`
- `cargo test -p yoi-plugin-pdk template`
- `nix build .#yoi --no-link`
補足:
- 初回 `nix build .#yoi --no-link``aws-lc-sys` build 中に `No space left on device` で environment failure。
- Orchestrator worktree の Cargo build artifacts を `cargo clean` で削除してから再実行し、Nix build は成功しました。
- `nix path-info -S .#yoi`: `112260512`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-Q0KE3A.log`

View File

@ -0,0 +1,427 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T04:16:14Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T05:23:14Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T05:23:44Z -->
## Decision
Routing decision: blocked_pending_dependency
Panel Queue により、この Ticket は Orchestrator routing 対象として明示許可されたが、実装開始はまだ行わない。
Reason:
- `00001KVHKWNQS``00001KVHKWNQA``depends_on` している。
- `00001KVHKWNQA` は現在 `inprogress` で、Rust PDK / embedded template 実装が r1 review の `request_changes` 修正ループ中。
- この Ticket の `new/check/pack` は PDK crate / embedded template を使うため、PDK/template が closed になるまで authoring CLI implementation の worktree/Pod side effects は開始しない。
- Orchestrator workspace は clean、queued はこの Ticket のみ、inprogress は `00001KVHKWNQA` のみ、matching branch/worktree は存在しない。
Action:
- `00001KVHKWNQS``queued` のまま保持する。
- `00001KVHKWNQA` が closed になった後、改めて body/thread/relations/workspace state を確認して routing する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T05:54:38Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により、この Ticket は Orchestrator routing 対象として明示許可された。
- 前回は `00001KVHKWNQA` Rust PDK/templates が inprogress だったため blocked/queued hold としたが、現在 `00001KVHKWNQA` は closed。
- Ticket body は `new` / `check` / `pack` の CLI surface、non-execution safety、archive safety、deterministic digest/package、JSON reports、diagnostics、tests、validation、non-goals を実装可能な粒度で定義している。
- Related Plugin CLI inspection (`00001KVFD3YSV`) と Component runtime (`00001KVG0HR96`) は closed。
- 現在 queued はこの Ticket のみ、inprogress は 0 件、child implementation Pods はなし、matching branch/worktree はなし、Orchestrator worktree は clean。
- Risk domain は plugin / CLI / authoring / templates / package-validation / packaging / read-only-check だが、Ticket は check/pack が Plugin code を実行しない、new は embedded templates only、enablement config を mutate しない、safe overwrite refusal、archive traversal/root-escape rejection などの invariants を明示している。bounded context check 後も implementation 前に必要な追加 human decision は見つからなかった。
Evidence checked:
- Ticket `00001KVHKWNQS` body / thread / relations / artifacts。
- `TicketRelationQuery(00001KVHKWNQS)`: outgoing `depends_on 00001KVHKWNQA` is now closed。Related records are closed context。
- `TicketOrchestrationPlanQuery(00001KVHKWNQS)`: previous `blocked_by` plan is resolved by `00001KVHKWNQA` closure; accepted plan recorded now。
- Workspace state:
- Orchestrator worktree clean at `902b383d`
- queued: this Ticket only。
- inprogress: 0。
- visible Pods: self + peers only; spawned children 0。
- no matching implementation branch/worktree。
- Code/resource context:
- Rust PDK/template resources are now merged from `00001KVHKWNQA`
- Component Model runtime and Plugin CLI inspection work are closed and available as implementation context。
IntentPacket:
Intent:
- Add first-party local Plugin authoring CLI commands: `yoi plugin new rust-component-tool <path-or-name>`, `yoi plugin check <path-or-package>`, and `yoi plugin pack <path> [--output <file>]`
- Make local authoring safe and deterministic without remote scripts, without executing Plugin code during validation, and without mutating workspace enablement config。
Binding decisions / invariants:
- `new` uses embedded templates only; no network, no remote template fetch, no `curl | sh` flow。
- `new` writes only to the requested destination and refuses non-empty destinations unless a narrow explicit safe option is intentionally added。
- Generated Rust Component Tool template should use the current PDK/template resources and current checkout/release dependency policy。
- `check` and `pack` must not execute Plugin code or instantiate components。
- `check` validates directory and `.yoi-plugin` package inputs with bounded diagnostics and stable JSON report shape for `--json`
- `pack` creates deterministic `.yoi-plugin` output and prints digest/path; `pack --json` returns stable typed output。
- `check` validates manifest/runtime/schema/permission/host API declarations, referenced artifact presence, archive safety, and deterministic digest where applicable。
- `pack` rejects unsafe paths/root escapes and unsupported package shapes; use currently supported archive format/constraints。
- Commands do not mutate enablement/workspace config and do not generate/embed secrets。
- Diagnostics/status language should align with existing `yoi plugin list/show` where possible。
- Do not implement registry publish/install, enabling/disabling config, Plugin execution, Service/Ingress scaffolding, or extra language templates。
Requirements / acceptance criteria:
- `yoi plugin new rust-component-tool ./my-plugin` creates the expected file set from embedded template without network access。
- `yoi plugin check ./my-plugin` validates the generated template and reports next steps/digest/enablement guidance。
- `yoi plugin pack ./my-plugin` creates a `.yoi-plugin` package discoverable by existing Plugin discovery。
- `check` validates existing `.yoi-plugin` archive inputs and rejects unsafe archives。
- `check --json` and `pack --json` provide stable typed reports for tests/agents。
- Tests cover generated file set, overwrite refusal, valid/invalid check paths, missing runtime artifact, unsafe package archive, deterministic pack digest, discoverability by existing Plugin discovery, and JSON report shape。
- Validation includes focused CLI/plugin authoring tests, relevant `cargo check` / `cargo test`, `cargo fmt --check`, `git diff --check`, and `nix build .#yoi` because CLI/resources/package behavior changes。
Escalate if:
- Current package/archive format lacks enough existing helpers and needs a broader package-format decision。
- `check` cannot validate Component world metadata without executing code and needs explicit scope narrowing。
- Deterministic packaging conflicts with current discovery/package reader behavior。
- Embedded template resource packaging/source filtering needs broader product decision。
Next action:
- Record `queued -> inprogress` and commit Ticket records before creating the implementation worktree and spawning Coder。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T05:54:45Z from: queued to: inprogress reason: orchestrator_acceptance_plugin_authoring_cli field: state -->
## State changed
Ticket body/thread, relation metadata, resolved Rust PDK dependency, related closed Tickets, Orchestrator worktree state, visible Pods, existing branch/worktree, and bounded CLI/resource/package context were checked. Dependency `00001KVHKWNQA` is closed, no inprogress/capacity blocker exists, and no missing planning decision was found. Accepting this queued Ticket for implementation before worktree/Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T05:55:31Z -->
## Implementation report
Implementation orchestration start:
- Acceptance records were committed on Orchestrator branch: `d1095f85 ticket: accept plugin authoring cli`
- Created implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVHKWNQS-plugin-authoring-cli` on branch `impl/00001KVHKWNQS-plugin-authoring-cli` at `d1095f85`
- Spawned Coder Pod `yoi-coder-00001KVHKWNQS` 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, merge, Ticket close, and cleanup。
Next action:
- Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T06:25:39Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVHKWNQS`.
Implementation commit:
- `945ecdf6 plugin: add authoring cli`
Changed areas reported:
- Added `yoi plugin new rust-component-tool <path-or-name>`
- Added `yoi plugin check <path-or-package> [--json]`
- Added `yoi plugin pack <path> [--output <file>] [--json]`
- Added manifest-level materialized package helpers for safe directory/package reading, deterministic digesting, deterministic stored `.yoi-plugin` writing, and symlink/root-escape rejection。
- Reused existing static Plugin inspection for Tool schema / permission / host API declaration checks without Plugin execution。
- Added embedded `plugin.component.wasm` placeholder to Rust component Tool template so generated templates can be checked/packed immediately; docs instruct replacing it before enabling/execution。
- Updated CLI parsing/help and Plugin development docs。
- Added focused tests for generated file set, overwrite refusal, valid directory check, invalid manifest, missing runtime artifact, unsafe package archive, deterministic pack digest, package discoverability, and JSON report shape。
Coder validation reported:
- `cargo fmt`
- `cargo fmt --check`
- `cargo check -p yoi`
- `cargo test -p yoi plugin_cli`
- `cargo test -p yoi-plugin-pdk template`
- `git diff --check`
- CLI smoke:
- `cargo run -q -p yoi -- plugin new rust-component-tool ... --json`
- `cargo run -q -p yoi -- plugin check ... --json`
- `cargo run -q -p yoi -- plugin pack ... --output ... --json`
- invalid check reports `"status": "rejected"` and exits non-zero。
- `nix build .#yoi --no-link` was attempted but failed due environment/storage: `No space left on device`
- No Cargo dependency or `Cargo.lock` changes were introduced。
Known risks / deferrals reported:
- Component validation remains metadata/static only; no component instantiation/execution is performed by design。
- Template includes a placeholder artifact solely for deterministic authoring check/pack; docs instruct replacing it before enabling/execution。
Orchestrator evidence checked before review dispatch:
- Implementation worktree is clean on `impl/00001KVHKWNQS-plugin-authoring-cli`
- HEAD is `945ecdf6`
- Diff from acceptance `d1095f85..HEAD` is one implementation commit touching 6 files, about 1274 insertions / 24 deletions。
- `git diff --check d1095f85..HEAD` produced no diagnostics。
Next action:
- Dispatch Reviewer for r1 review against Ticket requirements, with focus on non-execution safety, archive/path traversal safety, deterministic pack/digest behavior, placeholder artifact policy, JSON report stability, discoverability, workspace/config non-mutation, CLI UX, tests, and Nix/package validation feasibility。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-20T06:26:24Z -->
## Plan
Review dispatch:
- Spawned Reviewer Pod `yoi-reviewer-00001KVHKWNQS-r1` against implementation branch `impl/00001KVHKWNQS-plugin-authoring-cli`
- Review target commit: `945ecdf6 plugin: add authoring cli`
- Review baseline: `d1095f85`
- Reviewer task focuses on non-execution safety, archive/path traversal safety, deterministic pack/digest behavior, placeholder artifact policy, JSON report stability, discoverability, workspace/config non-mutation, CLI UX, tests, and Nix/package validation feasibility。
- Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。
---
<!-- event: review author: yoi-reviewer-00001KVHKWNQS-r1 at: 2026-06-20T06:36:52Z status: request_changes -->
## Review: request changes
Verdict: `request_changes`
確認範囲:
- Ticket body/thread / Orchestrator IntentPacket
- Implementation diff: `d1095f85..945ecdf6`
- 主な対象: `crates/yoi/src/plugin_cli.rs`, `crates/yoi/src/main.rs`, `crates/manifest/src/plugin.rs`, `resources/plugin/templates/rust-component-tool/*`, `docs/development/plugin-development.md`, `package.nix`
Blocking issues:
1. `yoi plugin new` が symlink destination を辿り、物理的な requested destination 外へ書き込める。
- `materialize_template``fs::metadata(destination)` / `fs::read_dir(destination)` を使い、destination symlink を拒否せず `destination.join(relative)` に書く。
- Reviewer smoke: `linkdest -> target` に対して `plugin new rust-component-tool "$tmp/linkdest"` を実行すると、`target/plugin.toml` が作られた。
- Ticket invariant: `new` は requested destination にだけ書き、non-empty destination を拒否する。symlink destination はこの境界を破る。
2. Generated placeholder component が `check` で enablement-ready に見える。
- Template は text placeholder `resources/plugin/templates/rust-component-tool/plugin.component.wasm` を含む。
- `new` output は next steps で一度 warning するが、fresh generated template に対する `check --json``status=active diagnostics=0` になり、generic enablement guidance を出す。
- Reviewer smoke: generated template check output に placeholder/non-executable warning がなく、`To enable after review...` guidance が出る。
- Ticket/reviewer contract: check/pack may pass, but output must not imply executable component is ready for enablement if placeholder is not executable。
Required fixes:
- `plugin new` destination symlink を `symlink_metadata` 等で拒否し、symlink write-through を防ぐ。関連する overwrite/destination tests を追加する。
- Generated placeholder artifact を `check` が検出し、bounded diagnostic / next step を出す。Status は `partial` など enablement-ready でない表現にすることを優先する。少なくとも `active diagnostics=0` と enablement guidance の組み合わせは避ける。
- `check --json` and human output tests should cover generated template placeholder warning/status。
Non-blocking concerns:
- `nix build .#yoi --no-link` は environment storage (`No space left on device`) で失敗。source-filter/resource-packaging issue は見えていないが、最終 Orchestrator validation で再試行する。
- `pack --json` digest is nested under `package.digest`, not top-level. Acceptable if documented/stable, but consider documenting shape。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check d1095f85..HEAD`: passed
- `cargo check -p yoi`: passed
- `cargo test -p yoi plugin_cli`: passed (`15 tests`)
- `cargo test -p yoi-plugin-pdk template`: passed
- CLI smoke for `new`, `check --json`, `pack --json`, packed package check/list discovery, invalid check rejected: passed except blocker findings above。
- `nix build .#yoi --no-link`: failed due `No space left on device`
Worktree status at review end: clean。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T06:46:09Z -->
## Implementation report
Coder r1-fix report received from `yoi-coder-00001KVHKWNQS`.
New fix commit:
- `699db538 plugin: harden authoring checks`
Branch commits now:
- `699db538 plugin: harden authoring checks`
- `945ecdf6 plugin: add authoring cli`
Fix summary:
- `plugin new` rejects an existing destination symlink via `symlink_metadata` before writes。
- Added regression test ensuring symlink destination is refused and target is not written。
- `plugin check` detects embedded generated placeholder `plugin.component.wasm`
- Fresh generated templates report `status: "partial"` instead of `active`
- JSON/human diagnostics include bounded placeholder warning。
- Enablement guidance no longer says a package is ready to enable while placeholder artifact remains。
- `partial` checks exit successfully; `rejected` checks remain non-zero。
- Docs updated for symlink refusal and placeholder/partial behavior。
Coder validation reported as passing:
- `cargo fmt --check`
- `git diff --check d1095f85..HEAD`
- `cargo check -p yoi`
- `cargo test -p yoi plugin_cli`
- `cargo test -p yoi-plugin-pdk template`
- CLI smoke:
- `plugin new ... --json`
- `plugin check <generated> --json` => `partial` with placeholder diagnostic。
- `plugin pack ... --json` => `packed`
- `plugin check <packed .yoi-plugin> --json` => `partial`
- copied packed archive into `.yoi/plugins` and confirmed `plugin list --workspace ... --json` discovers it。
- invalid check after removing runtime artifact => `rejected` and non-zero。
- Freed only child-worktree Cargo build artifacts with `cargo clean` and reran `nix build .#yoi --no-link`; it completed successfully。
Orchestrator evidence checked before r2 review:
- Implementation worktree is clean。
- HEAD is `699db538`
- Fix diff from `945ecdf6..HEAD` touches `crates/yoi/src/plugin_cli.rs` and `docs/development/plugin-development.md`, about 124 insertions / 34 deletions。
- Overall `git diff --check d1095f85..HEAD` produced no diagnostics。
Next action:
- Route back to Reviewer for r2 verification of symlink destination refusal, placeholder partial/check behavior, and final acceptance criteria。
---
<!-- event: review author: yoi-reviewer-00001KVHKWNQS-r1 at: 2026-06-20T06:50:40Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket body/thread / Orchestrator IntentPacket
- r2 fix diff: `945ecdf6..699db538`
- 全体実装 diff: `d1095f85..699db538`
- 主な対象: `crates/yoi/src/plugin_cli.rs`, `docs/development/plugin-development.md`, and prior `new` / `check` / `pack` implementation surface。
Previous blocker verification:
1. `plugin new` symlink destination write-through
- `materialize_template` now uses `fs::symlink_metadata` and rejects destination symlink before writing。
- Regression test `plugin_new_refuses_symlink_destination_without_following_it` verifies refusal and no write-through。
- CLI smoke confirmed symlink destination is rejected and target directory does not receive `plugin.toml`
2. Placeholder component reported as enablement-ready
- `check` now compares runtime component bytes against embedded template placeholder and adds bounded `placeholder` diagnostic。
- Generated template now reports `status=partial` when static validation is otherwise clean but placeholder remains。
- Enablement guidance is suppressed for `partial` and says the package is not ready to enable。
- CLI smoke confirmed generated directory and packed `.yoi-plugin` both report `partial` with placeholder diagnostic; `partial` exits successfully, while invalid/missing paths remain non-zero rejected。
Blocking issues: none。
Non-blocking concerns / follow-ups:
- None required for this Ticket. The hardening keeps status vocabulary aligned with existing Plugin inspection (`active` / `partial` / `rejected`)。
Reviewer validation:
- `cargo fmt --check`: passed
- `git diff --check d1095f85..HEAD`: passed
- `cargo check -p yoi`: passed
- `cargo test -p yoi plugin_cli`: passed (`16 tests`)
- `cargo test -p yoi-plugin-pdk template`: passed (`2 template tests`)
- CLI smoke after `cargo build -p yoi`: passed for `new`, generated `check --json partial`, human `[partial]`, `pack --json`, packed package `check --json partial`, invalid check rejected/non-zero, symlink destination refusal, and `plugin list --workspace ... --json` discoverability as `disabled`
- `nix build .#yoi --no-link`: passed
Worktree status at review end: source tree clean。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T06:54:35Z -->
## Implementation report
Merge and final validation report:
Merged implementation branch into Orchestrator branch:
- Merge commit: `87704ad4 merge: plugin authoring cli`
- Implementation commits included:
- `945ecdf6 plugin: add authoring cli`
- `699db538 plugin: harden authoring checks`
Reviewer outcome:
- r1 requested changes for symlink destination write-through and placeholder component being reported as enablement-ready。
- Coder added symlink destination refusal, placeholder detection, `partial` status / diagnostics, and tests。
- r2 approved with no blocking issues。
Orchestrator validation after merge:
- Passed before Nix:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo check -p yoi`
- `cargo test -p yoi plugin_cli`
- `cargo test -p yoi-plugin-pdk template`
- Initial `nix build .#yoi --no-link` failed with environment storage exhaustion while building `aws-lc-sys` (`No space left on device`), not a source/package diagnostic。
- Orchestrator freed only Orchestrator-worktree Cargo build artifacts with `cargo clean` (`43.3GiB`) and reran:
- `nix build .#yoi --no-link`: passed。
- `nix path-info -S .#yoi`: `112260512`
Validation log for first grouped run:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-Q0KE3A.log`
Final state:
- Orchestrator worktree clean at `87704ad4` after successful Nix validation。
- Implementation worktree remains available for cleanup after Ticket completion records are committed。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T06:54:45Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Implementation was merged into Orchestrator branch at `87704ad4`, r2 review approved, and final Orchestrator validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo check -p yoi`, focused `yoi plugin_cli` / `yoi-plugin-pdk template` tests, and `nix build .#yoi --no-link` after freeing Orchestrator worktree build artifacts.
---
<!-- event: state_changed author: hare at: 2026-06-20T06:55:06Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-20T06:55:06Z status: closed -->
## 完了
## Resolution
`00001KVHKWNQS` を完了しました。
実装内容:
- `yoi plugin new rust-component-tool <path-or-name>` を追加しました。
- `yoi plugin check <path-or-package> [--json]` を追加しました。
- `yoi plugin pack <path> [--output <file>] [--json]` を追加しました。
- Safe directory/package reading、deterministic digesting、deterministic `.yoi-plugin` writing、symlink/root-escape rejection を含む materialized package helpers を追加しました。
- `check` / `pack` は Plugin code を実行せず、既存 static Plugin inspection を再利用して manifest/runtime/schema/permission/host API declarations を検査します。
- Embedded Rust Component Tool template を `new` で利用し、generated template を check/pack できるよう placeholder `plugin.component.wasm` を追加しました。
- Placeholder artifact は `check` で検出され、generated template / packed archive は `partial` と bounded diagnostic を返します。placeholder が残る間は enablement-ready guidance を出しません。
- `plugin new` は existing destination symlink を拒否し、write-through を防ぎます。
- JSON report shape、human output、CLI help/docs を更新しました。
- Focused tests と CLI smoke coverage を追加しました。
主な commit:
- `945ecdf6 plugin: add authoring cli`
- `699db538 plugin: harden authoring checks`
- `87704ad4 merge: plugin authoring cli`
Review:
- r1 は destination symlink write-through と placeholder artifact の enablement-ready 表示で `request_changes`
- Coder が symlink refusal、placeholder detection、`partial` status/diagnostics、tests/docs を追加。
- r2 は `approve`
最終 validation:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo check -p yoi`
- `cargo test -p yoi plugin_cli`
- `cargo test -p yoi-plugin-pdk template`
- `nix build .#yoi --no-link`
補足:
- 初回 `nix build .#yoi --no-link``aws-lc-sys` build 中に `No space left on device` で environment failure。
- Orchestrator worktree の Cargo build artifacts を `cargo clean` で削除してから再実行し、Nix build は成功しました。
- `nix path-info -S .#yoi`: `112260512`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-Q0KE3A.log`
---

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHQDS6B",
"kind": "related",
"target": "00001KVHKWNQA",
"note": "Observed prerequisite Ticket already queued/in progress should allow dependent queueing.",
"author": "yoi ticket",
"at": "2026-06-20T05:19:32Z"
},
{
"ticket_id": "00001KVHQDS6B",
"kind": "related",
"target": "00001KVHKWNQS",
"note": "Dependent Ticket hit backend conflict despite Panel queue UI allowing it.",
"author": "yoi ticket",
"at": "2026-06-20T05:19:32Z"
}
]
}

View File

@ -0,0 +1,55 @@
---
title: 'Panel Queue action should allow ready Tickets whose blockers are already queued or in progress'
state: 'done'
created_at: '2026-06-20T05:18:00Z'
updated_at: '2026-06-20T05:19:32Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['ticket', 'panel', 'queue', 'dependency', 'blocker', 'orchestrator']
---
## Background
Panel ViewModel already treats a ready Ticket as queueable when all blocking relations point at Tickets that are already `queued` or `inprogress`. That matches the intended workflow: once prerequisite work is queued/accepted by Orchestrator, dependent ready Tickets should also be queueable so the human does not need to return after every prerequisite completes.
However, pressing Enter in Panel still calls the backend `queue_ready` gate, and that backend rejected any unresolved relation blocker regardless of blocker state. This caused a `ticket conflict` even though the UI correctly showed Queue as available.
Concrete example:
- `00001KVHKWNQS` depends on `00001KVHKWNQA`.
- `00001KVHKWNQA` is already `inprogress`.
- Panel shows `00001KVHKWNQS` as queueable.
- Enter/Queue fails with backend ticket conflict.
## Requirements
- Backend `queue_ready` must match the Panel gating rule.
- A ready Ticket may transition `ready -> queued` when every relation blocker is already `queued` or `inprogress`.
- A ready Ticket must still be blocked when any relation blocker is `planning` or `ready`.
- This change applies to queueing only.
- `queued -> inprogress` acceptance remains blocked while unresolved dependency/blocker relations exist; Orchestrator must preserve execution order after queueing.
- Existing Panel display behavior should remain aligned with backend behavior.
## Implementation summary
- Added backend helper `relation_blocker_allows_queue`.
- Updated `LocalTicketBackend::queue_ready` to filter relation blockers by that helper before rejecting.
- Kept `queued -> inprogress` relation blocker validation unchanged.
- Added backend test coverage for ready Tickets with queued/inprogress blockers.
- Re-ran existing Panel ViewModel test that already asserted ready+queued dependency appears queueable.
## Acceptance criteria
- `ready` Ticket with `depends_on` target in `queued` state can be queued.
- `ready` Ticket with incoming `blocks` blocker in `inprogress` state can be queued.
- `ready` Ticket with dependency/blocker still in `planning` remains rejected.
- Panel and backend queue behavior are consistent.
- Orchestrator acceptance ordering remains guarded by existing `queued -> inprogress` relation check.
## Validation
- `cargo test -p ticket queue_gate --lib`
- `cargo test -p tui workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued --lib`
- `cargo check -p ticket -p tui`
- `cargo fmt`
- `git diff --check`

View File

@ -0,0 +1,7 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:18:00Z -->
## 作成
LocalTicketBackend によって作成されました。
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260620-060022-1","ticket_id":"00001KVHR3WRF","kind":"waiting_capacity_note","note":"Panel Queue was accepted for routing review, but implementation is held because `00001KVHKWNQS` is currently inprogress with active Coder work. Leave this MCP foundation Ticket queued; reroute when current implementation capacity is free.","author":"yoi-orchestrator","at":"2026-06-20T06:00:22Z"}
{"id":"orch-plan-20260620-065554-2","ticket_id":"00001KVHR3WRF","kind":"accepted_plan","accepted_plan":{"summary":"Named local stdio MCP server configuration and trust policy metadataを追加する。This Ticket only parses/validates config and diagnostics; it must not spawn subprocesses or implement JSON-RPC lifecycle. Command/env/secret fields must fail closed, redact sensitive values, and document that local MCP executables run with user OS permissions outside Yoi feature sandbox authority.","branch":"impl/00001KVHR3WRF-mcp-stdio-config-trust","worktree":"/home/hare/Projects/yoi/.worktree/00001KVHR3WRF-mcp-stdio-config-trust","role_plan":"Orchestrator は acceptance records を commit 後、専用 implementation worktree `.worktree/00001KVHR3WRF-mcp-stdio-config-trust` を作成し、Coder をその child worktree への narrow write scope で起動する。Coder 実装後、Reviewer が config schema、trust boundary docs、secret redaction、fail-closed validation、no auto-start/no process execution、Profile/config layering separation を確認する。"},"author":"yoi-orchestrator","at":"2026-06-20T06:55:54Z"}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHR3WRF",
"kind": "related",
"target": "00001KTR81P9X",
"note": "MCP implementation builds on the protocol-backed provider feature substrate.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
}
]
}

View File

@ -0,0 +1,49 @@
---
title: 'MCP: add local stdio server config and trust policy'
state: 'closed'
created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T07:28:55Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T05:58:46Z'
---
## Background
MCP integration starts with explicit local stdio server configuration and trust policy. Yoi must not auto-start MCP servers from workspace presence, package discovery, or Plugin packages. A configured MCP local stdio server is a local executable running with the user's OS permissions; Yoi feature authority does not sandbox its OS-level side effects.
This Ticket only defines/parses/validates config and diagnostics. It does not spawn MCP processes or implement JSON-RPC lifecycle.
## Requirements
- Add typed Profile/config support for named local stdio MCP servers.
- Config fields must cover command, args, cwd policy, env policy, and explicit secret/env references as needed.
- No package/workspace presence auto-start.
- Validate command/env/secret config fail-closed.
- Define diagnostic surfaces for config parse/validation errors.
- Redact command/env/secret values where needed; do not write plaintext secrets to logs/model context.
- Document local executable trust boundary.
- Keep MCP config/trust separate from Plugin permissions and `pod::feature` authority.
## Acceptance criteria
- A Profile/config can declare a named local stdio MCP server.
- Invalid command/env/secret config is rejected with bounded diagnostic.
- Secrets are not emitted in plaintext diagnostics/log/model context.
- Config alone does not spawn a process.
- Docs explain that configured local MCP servers are not OS-sandboxed by Yoi feature authority.
- Tests cover valid config, invalid config, secret redaction, and no auto-start.
## Non-goals
- Spawning stdio subprocesses.
- MCP initialize/capability negotiation.
- Tool/resource/prompt registration.
- Streamable HTTP/OAuth/remote MCP.
## Related work
- Objective: `00001KTR80WMN`.
- Supersedes part of broad MCP Ticket `00001KTR82RB7`.

View File

@ -0,0 +1,37 @@
## Resolution
`00001KVHR3WRF` を完了しました。
実装内容:
- Typed MCP config schema を `crates/manifest` に追加しました。
- Profile/config で named local stdio MCP server を宣言できるようにしました。
- Config fields は `name`, `command`, `args`, `cwd`, `env.inherit`, `env.set` を含みます。
- Env value は `literal`, `secret_ref`, `env_ref` をサポートします。
- Validation は duplicate names、empty/NUL command/args、cwd policy/path、env var name、secret ref、NUL literal env values などを fail-closed で検査します。
- Diagnostics / `Debug` は secret/env/literal values を plaintext で出さないよう redaction します。
- Profile resolution / child manifest inheritance に MCP config を通しましたが、subprocess spawning / initialize / JSON-RPC lifecycle / tool/resource/prompt registration は実装していません。
- Docs に local stdio MCP server の trust boundary を記録しました。Configured stdio server は user OS permissions で動く local executable であり、Yoi feature authority / Plugin permissions / MCP config validation は OS sandbox ではありません。
主な commit:
- `e0680cce mcp: add stdio server config`
- `9b7c4e27 merge: mcp stdio config trust`
Review:
- r1 は `approve`
- Reviewer は config-only boundary、no process spawning/no auto-start、secret redaction、Profile/config integration、docs trust boundary を確認しました。
最終 validation:
- `cargo fmt --all --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p manifest mcp --lib`
- `cargo check`
- `nix build .#yoi --no-link`
Package impact:
- `nix path-info -S .#yoi`: `112615056`
Known unrelated note:
- Full `cargo test -p manifest --lib` は、branch 外の既存 Plugin template-shape mismatch で失敗するため最終 gate にしませんでした。Reviewer はこの failure が `b0225e48..HEAD` の diff に起因しないことを確認済みです。
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-uxMpR3.log`

View File

@ -0,0 +1,307 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:30:04Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T05:58:46Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T06:00:44Z -->
## Decision
Routing decision: waiting_capacity
Panel Queue により routing 対象として確認したが、現在 `00001KVHKWNQS``inprogress` で Coder 作業中のため、この MCP foundation Ticket は実装開始せず queued のまま保持する。
Evidence:
- Ticket body / relations を確認。
- Orchestrator worktree は clean。
- queued は MCP chain とこの Ticket 群、inprogress は `00001KVHKWNQS` のみ。
- matching branch/worktree は存在しない。
Next:
- `00001KVHKWNQS` が完了して実装 capacity が空いた後、改めて reroute する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T06:56:16Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により、この Ticket は Orchestrator routing 対象として明示許可された。
- Ticket body は local stdio MCP server configuration / trust policy の範囲を、spawning/lifecycle を含まない config parse/validation/diagnostics Ticket として明確に定義している。
- 前回の waiting_capacity note は `00001KVHKWNQS` が inprogress だったためだが、現在 `00001KVHKWNQS` は closed で capacity blocker は解消済み。
- `00001KVHR3WRF` 自身には未解決 blocking relation はない。Incoming `00001KVHR3WRY depends_on this` は後続 Ticket であり blocker ではない。
- 現在 inprogress は 0 件、child implementation Pods はなし、matching branch/worktree はなし、Orchestrator worktree は clean。
- Risk domain は mcp / config / trust-boundary / secrets / process-exec だが、Ticket は no process spawning、no auto-start、secret redaction、local executable trust boundary、Plugin permissions / `pod::feature` authority separation などの invariants を明示している。bounded context check 後も implementation 前に必要な追加 human decision は見つからなかった。
Evidence checked:
- Ticket `00001KVHR3WRF` body / thread / relations / artifacts。
- `TicketRelationQuery(00001KVHR3WRF)`: no outgoing blocking dependency; incoming lifecycle Ticket depends on this。
- `TicketOrchestrationPlanQuery(00001KVHR3WRF)`: previous waiting capacity note resolved by `00001KVHKWNQS` closure; accepted plan recorded now。
- Workspace state:
- Orchestrator worktree clean at `a5df9e37`
- queued: MCP chain remains queued。
- inprogress: 0。
- visible Pods: self + peers only; spawned children 0。
- no matching MCP implementation branch/worktree。
IntentPacket:
Intent:
- Add typed Profile/config support for named local stdio MCP servers and the trust-policy diagnostics around that config。
- This Ticket is intentionally config-only: parse, validate, redact, and document; do not spawn processes or implement JSON-RPC lifecycle。
Binding decisions / invariants:
- No package/workspace presence auto-start。Config alone must not spawn an MCP process。
- Local stdio MCP servers are local executables running with user OS permissions; Yoi feature authority does not sandbox OS-level side effects。
- Keep MCP config/trust policy separate from Plugin permissions and `pod::feature` authority。
- Config must be explicit and named; no implicit discovery or auto-enablement。
- Command/args/cwd/env/secret-related configuration must validate fail-closed with bounded diagnostics。
- Secret/env values must be redacted in diagnostics/log/model-facing surfaces; do not persist plaintext secrets through errors/debug output。
- This Ticket must not implement initialize/capability negotiation, tool/resource/prompt registration, streamable HTTP/OAuth/remote MCP, or subprocess spawning。
Requirements / acceptance criteria:
- A Profile/config can declare a named local stdio MCP server。
- Invalid command/env/secret config is rejected with bounded diagnostic。
- Secrets are not emitted in plaintext diagnostics/log/model context。
- Config alone does not spawn a process。
- Docs explain that configured local MCP servers are not OS-sandboxed by Yoi feature authority。
- Tests cover valid config, invalid config, secret redaction, and no auto-start。
Escalate if:
- Existing Profile/config model cannot represent MCP server declarations without a broader config/profile schema decision。
- Secret reference representation would require changing the local secret-store policy rather than referencing existing secret/env abstractions。
- Tests require actual process spawning; that belongs to a later Ticket, not this one。
Next action:
- Record `queued -> inprogress` and commit Ticket records before creating the implementation worktree and spawning Coder。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T06:56:23Z from: queued to: inprogress reason: orchestrator_acceptance_mcp_stdio_config_trust field: state -->
## State changed
Ticket body/thread, relation metadata, resolved capacity blocker, Orchestrator worktree state, visible Pods, existing branch/worktree, and bounded MCP config/trust context were checked. There is no unresolved blocking dependency, no inprogress/capacity blocker, and no missing planning decision. Accepting this queued Ticket for implementation before worktree/Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T06:57:08Z -->
## Implementation report
Implementation orchestration start:
- Acceptance records were committed on Orchestrator branch: `b0225e48 ticket: accept mcp stdio config trust`
- Created implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVHR3WRF-mcp-stdio-config-trust` on branch `impl/00001KVHR3WRF-mcp-stdio-config-trust` at `b0225e48`
- Spawned Coder Pod `yoi-coder-00001KVHR3WRF` 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, process spawning for this Ticket, merge, Ticket close, and cleanup。
Next action:
- Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T07:18:35Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVHR3WRF`.
Implementation commit:
- `e0680cce mcp: add stdio server config`
Changed areas reported:
- Added typed MCP config schema in `crates/manifest`:
- `mcp.stdio_server` list with explicit `name`, `command`, `args`, `cwd`, and `env`
- explicit env policy with `inherit` and `set`
- env values support `literal`, `secret_ref`, and `env_ref`
- literal env values are redacted in `Debug`
- Added validation for duplicate names, empty/NUL command and args, cwd policy/path, env variable names, secret refs, and bounded diagnostics without secret/env value leakage。
- Integrated MCP config through Profile resolution and child manifest inheritance without adding process spawning。
- Added focused tests for valid config, invalid command/duplicate names, invalid secret refs/redaction, nonexistent command accepted as no auto-start proof, and Lua Profile MCP config resolution。
- Updated docs in `docs/design/profiles-manifests-prompts.md` with local stdio MCP config and trust boundary。
- Updated Nix cargo hash due manifest dependency graph change。
Coder validation reported:
- `cargo test -p manifest mcp --lib`: passed。
- `cargo check`: passed。
- `cargo fmt --all --check`: passed。
- `git diff --check`: passed。
- `nix build .#yoi --no-link`: passed。
- `cargo test -p manifest --lib`: failed on pre-existing/unrelated `plugin::tests::template_includes_current_pdk_version`, reportedly due `resources/plugin-templates/rust/Cargo.toml` PDK version `0.2.0` vs `CARGO_PKG_VERSION` `0.1.0`
Known deferrals:
- No MCP subprocess spawning, initialize/capability negotiation, tool/resource/prompt registration, HTTP/OAuth, or JSON-RPC lifecycle added。
- Command executability intentionally not checked at config-parse time; only structural validation is performed。
Orchestrator evidence checked before review dispatch:
- Implementation worktree is clean。
- HEAD is `e0680cce`
- Diff from acceptance `b0225e48..HEAD` is one implementation commit touching 8 files, about 515 insertions / 10 deletions。
- `git diff --check b0225e48..HEAD` produced no diagnostics。
Next action:
- Dispatch Reviewer for r1 review against Ticket requirements, with focus on config schema fit, validation/redaction boundaries, no process spawning/no auto-start, Profile/config layering, docs trust boundary, and whether the reported `manifest --lib` failure is unrelated or must be fixed。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-20T07:19:20Z -->
## Plan
Review dispatch:
- Spawned Reviewer Pod `yoi-reviewer-00001KVHR3WRF-r1` against implementation branch `impl/00001KVHR3WRF-mcp-stdio-config-trust`
- Review target commit: `e0680cce mcp: add stdio server config`
- Review baseline: `b0225e48`
- Reviewer task focuses on config-only boundary, no process spawning/no auto-start, secret/env redaction, fail-closed validation, Profile/config layering, trust-boundary docs, MCP vs Plugin/feature authority separation, and the reported `cargo test -p manifest --lib` failure。
- Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。
---
<!-- event: review author: yoi-reviewer-00001KVHR3WRF-r1 at: 2026-06-20T07:27:01Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket contract / Orchestrator IntentPacket。
- Diff: `b0225e48..e0680cce`
- 主な対象: `crates/manifest/src/lib.rs`, `crates/manifest/src/config.rs`, `crates/manifest/src/profile.rs`, `crates/manifest/Cargo.toml`, `crates/pod/src/spawn/tool.rs`, `docs/design/profiles-manifests-prompts.md`, `Cargo.lock`, `package.nix`
- Diff 内で `Command::new`, `tokio::process`, `std::process`, spawn, initialize/capability negotiation, JSON-RPC/tool registration などの process/lifecycle implementation 追加を確認。
Blocking issues: none。
Critical risk checks:
- Config-only boundary は維持されている。MCP subprocess spawning、MCP initialize/capability negotiation、JSON-RPC lifecycle、tool/resource/prompt registration は追加されていない。
- `crates/manifest/src/lib.rs` は typed `McpConfig`、named `stdio_server` entries、`command` / `args` / `cwd` / explicit `env.inherit` / `env.set`、`literal` / `secret_ref` / `env_ref` を追加している。
- `McpEnvValue::Debug` は literal env values を redact する。validation errors は literal/env/secret values を echo しない。
- `crates/manifest/src/config.rs` は duplicate server names、empty/NUL names/commands/args、cwd absolute-after-resolution policy、env var names、secret ref IDs、NUL literal env values を bounded diagnostics で検証している。
- Profile integration は通常の Profile resolution / path-resolution flow 経由。Reusable Profiles は absolute MCP cwd paths を拒否する。
- `crates/pod/src/spawn/tool.rs``manifest.mcp.clone()` を inherited child manifest config に載せるだけで、child/grandchild MCP execution は追加しない。
- Docs は stdio config 宣言だけでは subprocess / tool/resource/prompt registration が起きないこと、local stdio MCP server は user OS permissions の通常 executable として動くこと、Yoi feature flags / Plugin permissions / MCP config validation は OS sandbox ではないことを明記している。
Non-blocking concerns / follow-ups:
- `cargo test -p manifest --lib``plugin::tests::embedded_rust_component_tool_template_is_valid_package_shape` で失敗するが、この branch の diff は `crates/manifest/src/plugin.rs` や template files を変更していない。Reviewer は `b0225e48` 時点で既に同じ template resource/test mismatch があるため unrelated/pre-existing と判断した。
Reviewer validation:
- `cargo fmt --all --check`: passed。
- `git diff --check b0225e48..HEAD`: passed。
- `cargo test -p manifest mcp --lib`: 5 MCP-focused tests passed。
- `cargo check`: passed。
- `nix build .#yoi --no-link`: passed。
- `cargo test -p manifest --lib`: unrelated pre-existing plugin template-shape failure only。
Worktree status at review end: clean。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T07:28:20Z -->
## Implementation report
Merge and final validation report:
Merged implementation branch into Orchestrator branch:
- Merge commit: `9b7c4e27 merge: mcp stdio config trust`
- Implementation commit included:
- `e0680cce mcp: add stdio server config`
Reviewer outcome:
- r1 approved with no blocking issues。
- Reviewer confirmed config-only boundary, no subprocess/MCP lifecycle implementation, redaction behavior, Profile/config integration, docs trust boundary, and that `cargo test -p manifest --lib` failure is unrelated/pre-existing plugin template test mismatch。
Orchestrator validation after merge passed:
- `cargo fmt --all --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p manifest mcp --lib`
- `cargo check`
- `nix build .#yoi --no-link`
- `nix path-info -S .#yoi`: `112615056`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-uxMpR3.log`
Known unrelated validation note:
- Full `cargo test -p manifest --lib` was not used as a final gate because Reviewer confirmed an unrelated/pre-existing plugin template-shape failure outside this branch's diff。
Final state:
- Orchestrator worktree clean at `9b7c4e27` after validation。
- Implementation worktree remains available for cleanup after Ticket completion records are committed。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T07:28:26Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Implementation was merged into Orchestrator branch at `9b7c4e27`, review approved, and final Orchestrator validation passed: `cargo fmt --all --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p manifest mcp --lib`, `cargo check`, and `nix build .#yoi --no-link`.
---
<!-- event: state_changed author: hare at: 2026-06-20T07:28:55Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-20T07:28:55Z status: closed -->
## 完了
## Resolution
`00001KVHR3WRF` を完了しました。
実装内容:
- Typed MCP config schema を `crates/manifest` に追加しました。
- Profile/config で named local stdio MCP server を宣言できるようにしました。
- Config fields は `name`, `command`, `args`, `cwd`, `env.inherit`, `env.set` を含みます。
- Env value は `literal`, `secret_ref`, `env_ref` をサポートします。
- Validation は duplicate names、empty/NUL command/args、cwd policy/path、env var name、secret ref、NUL literal env values などを fail-closed で検査します。
- Diagnostics / `Debug` は secret/env/literal values を plaintext で出さないよう redaction します。
- Profile resolution / child manifest inheritance に MCP config を通しましたが、subprocess spawning / initialize / JSON-RPC lifecycle / tool/resource/prompt registration は実装していません。
- Docs に local stdio MCP server の trust boundary を記録しました。Configured stdio server は user OS permissions で動く local executable であり、Yoi feature authority / Plugin permissions / MCP config validation は OS sandbox ではありません。
主な commit:
- `e0680cce mcp: add stdio server config`
- `9b7c4e27 merge: mcp stdio config trust`
Review:
- r1 は `approve`
- Reviewer は config-only boundary、no process spawning/no auto-start、secret redaction、Profile/config integration、docs trust boundary を確認しました。
最終 validation:
- `cargo fmt --all --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p manifest mcp --lib`
- `cargo check`
- `nix build .#yoi --no-link`
Package impact:
- `nix path-info -S .#yoi`: `112615056`
Known unrelated note:
- Full `cargo test -p manifest --lib` は、branch 外の既存 Plugin template-shape mismatch で失敗するため最終 gate にしませんでした。Reviewer はこの failure が `b0225e48..HEAD` の diff に起因しないことを確認済みです。
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/bash-uxMpR3.log`
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260620-060022-1","ticket_id":"00001KVHR3WRY","kind":"blocked_by","related_ticket":"00001KVHR3WRF","note":"Lifecycle client requires explicit local stdio MCP config/trust policy. `00001KVHR3WRF` is queued and not yet implemented; leave this Ticket queued until that dependency is closed.","author":"yoi-orchestrator","at":"2026-06-20T06:00:22Z"}
{"id":"orch-plan-20260620-072936-2","ticket_id":"00001KVHR3WRY","kind":"accepted_plan","accepted_plan":{"summary":"Configured local stdio MCP serverを明示 config から起動し、newline-delimited JSON-RPC over stdioで initialize/capability negotiation/initialized notification/shutdownを行う lifecycle client foundation を実装する。Tools/resources/prompts registration/executionは後続 Ticket のため含めない。","branch":"impl/00001KVHR3WRY-mcp-stdio-lifecycle-client","worktree":"/home/hare/Projects/yoi/.worktree/00001KVHR3WRY-mcp-stdio-lifecycle-client","role_plan":"Orchestrator は acceptance records を commit 後、専用 implementation worktree `.worktree/00001KVHR3WRY-mcp-stdio-lifecycle-client` を作成し、Coder をその child worktree への narrow write scope で起動する。Coder 実装後、Reviewer が process lifecycle safety、JSON-RPC framing、initialize/capability negotiation、stderr bounds/redaction、shutdown/kill fallback、no ToolRegistry/resources/prompts exposure を確認する。"},"author":"yoi-orchestrator","at":"2026-06-20T07:29:36Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHR3WRY",
"kind": "depends_on",
"target": "00001KVHR3WRF",
"note": "Lifecycle client requires explicit local stdio MCP config/trust policy.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
},
{
"ticket_id": "00001KVHR3WRY",
"kind": "related",
"target": "00001KTR81P9X",
"note": "MCP lifecycle uses the protocol-backed provider feature substrate.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
}
]
}

View File

@ -0,0 +1,51 @@
---
title: 'MCP: implement stdio JSON-RPC lifecycle client'
state: 'closed'
created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T07:59:30Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T05:58:54Z'
---
## Background
After MCP local stdio server config exists, Yoi needs a lifecycle client that can start a configured server, speak newline-delimited JSON-RPC over stdio, perform MCP initialize/capability negotiation, and shut down safely.
This Ticket creates the protocol/lifecycle foundation only. It does not expose MCP tools/resources/prompts to the model-visible ToolRegistry.
## Requirements
- Spawn configured local stdio MCP servers from explicit config only.
- Use stdin/stdout newline-delimited JSON-RPC.
- Treat stdout as protocol messages.
- Treat stderr as bounded diagnostics/logging, not automatic protocol failure.
- Implement initialize, capability negotiation, and notifications/initialized.
- Track server name and startup phase in diagnostics.
- Implement graceful shutdown, terminate, and kill fallback.
- Handle process exit/disconnect/startup failure with bounded diagnostics.
- Do not declare sampling or elicitation client capabilities initially.
- Add local mock MCP server tests.
## Acceptance criteria
- Mock local stdio MCP server initializes successfully.
- Initialize failure reports server name and phase.
- stderr is bounded and redacted where needed.
- Shutdown is safe and deterministic.
- Sampling/elicitation are not advertised and fail closed if requested.
- No tools/resources/prompts are registered by this Ticket.
## Non-goals
- tools/list ToolRegistry registration.
- tools/call execution.
- resources/prompts operations.
- Streamable HTTP/OAuth/remote MCP.
## Related work
- Depends on `00001KVHR3WRF`.
- Objective: `00001KTR80WMN`.

View File

@ -0,0 +1,37 @@
## Resolution
`00001KVHR3WRY` を完了しました。
実装内容:
- New internal crate `mcp` を追加しました。
- Explicit MCP stdio server config から resolved stdio server spec を作成する bridge を追加しました。
- Tokio child process による local stdio MCP server lifecycle foundation を実装しました。
- stdin/stdout/stderr handling、newline-delimited JSON-RPC request/response handling、initialize/capability negotiation、`notifications/initialized` を実装しました。
- stdout/stderr/protocol payloads は bounded に扱います。
- stderr は bounded diagnostics/logging として扱い、protocol failure とは別扱いです。
- server name / phase-aware errors を追加しました。
- shutdown は stdin close / wait / terminate / kill fallback で deterministic に行います。
- Server-to-client requests は fail-closed し、sampling/elicitation は advertise せず、unknown request は JSON-RPC error で返します。
- `McpStdioServerSpec``Debug` は custom redacted 実装にし、resolved env/secret-derived values を出さない regression test を追加しました。
- ToolRegistry / tools/resources/prompts registration、remote MCP / Streamable HTTP / OAuth は実装していません。
主な commit:
- `a114fa9d mcp: implement stdio lifecycle client`
- `f396e1a2 mcp: redact stdio server spec debug`
- `9cf5344f merge: mcp stdio lifecycle client`
Review:
- r1 は resolved spec `Debug` による env/secret leak で `request_changes`
- Coder が custom redacted `Debug` と regression test を追加。
- r2 は `approve`
最終 validation:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p mcp`
- `cargo check`
- `cargo tree -p mcp --depth 1`
- `nix build .#yoi --no-link`
Package impact:
- `nix path-info -S .#yoi`: `112615056`

View File

@ -0,0 +1,400 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:30:04Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T05:58:54Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T06:00:44Z -->
## Decision
Routing decision: blocked_pending_dependency
Panel Queue により routing 対象として確認したが、`00001KVHR3WRY` は `00001KVHR3WRF``depends_on` している。`00001KVHR3WRF` はまだ queued で未実装のため、この Ticket は実装開始せず queued のまま保持する。
Next:
- `00001KVHR3WRF` が closed になった後、改めて reroute する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T07:29:56Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Panel Queue により、この Ticket は Orchestrator routing 対象として明示許可された。
- 前回は `00001KVHR3WRF` local stdio config/trust policy が未完了だったため blocked/queued hold としたが、現在 `00001KVHR3WRF` は closed。
- Ticket body は stdio JSON-RPC lifecycle client の scope を initialize/capability negotiation/shutdown/process diagnostics foundation として明確に定義し、ToolRegistry/tools/resources/prompts exposure は non-goal として分離している。
- 現在 inprogress は 0 件、child implementation Pods はなし、matching branch/worktree はなし、Orchestrator worktree は clean。
- Risk domain は mcp / stdio / json-rpc / process-lifecycle / diagnostics だが、Ticket は explicit config only、bounded stderr diagnostics、safe shutdown/kill fallback、sampling/elicitation not advertised、no tools/resources/prompts registration などの invariants を明示している。bounded context check 後も implementation 前に必要な追加 human decision は見つからなかった。
Evidence checked:
- Ticket `00001KVHR3WRY` body / thread / relations / artifacts。
- `TicketRelationQuery(00001KVHR3WRY)`: outgoing `depends_on 00001KVHR3WRF` is now closed。Incoming `00001KVHR3WS6` / `00001KVHR3WSN` are downstream and not blockers。
- `TicketOrchestrationPlanQuery(00001KVHR3WRY)`: previous `blocked_by 00001KVHR3WRF` is resolved; accepted plan recorded now。
- Workspace state:
- Orchestrator worktree clean at `8f5eef94`
- queued: remaining MCP chain Tickets。
- inprogress: 0。
- visible Pods: self + peers only; spawned children 0。
- no matching MCP lifecycle branch/worktree。
IntentPacket:
Intent:
- Implement a local stdio MCP lifecycle client foundation that can spawn an explicitly configured local server, exchange newline-delimited JSON-RPC on stdin/stdout, run initialize/capability negotiation, send `notifications/initialized`, and shut down safely。
- This Ticket must not expose MCP tools/resources/prompts to the model-visible ToolRegistry; it only provides the lifecycle/protocol substrate for later Tickets。
Binding decisions / invariants:
- Only explicit named config from `00001KVHR3WRF` can be used; no implicit discovery or auto-start from workspace/package presence。
- Treat stdout as protocol messages and stderr as bounded diagnostics/logging, not automatic protocol failure。
- Bound stdout/stderr lines, diagnostics, protocol payloads, startup timeout, request timeout, shutdown timeout, and kill fallback。
- Include server name and startup phase in errors/diagnostics。
- Do not advertise sampling or elicitation client capabilities; if server requests them, fail closed / return an error as appropriate。
- Do not register tools/resources/prompts or expose any ToolRegistry surface in this Ticket。
- No remote MCP / Streamable HTTP / OAuth。
- Preserve trust boundary: local stdio server process runs as user OS executable; config validation is not sandbox authority。
- Secret/env values from config must remain redacted in diagnostics/logs/errors。
Requirements / acceptance criteria:
- Mock local stdio MCP server initializes successfully。
- Initialize failure reports server name and phase。
- Stderr is bounded and redacted where needed。
- Shutdown is safe and deterministic, with terminate/kill fallback。
- Sampling/elicitation are not advertised and fail closed if requested。
- No tools/resources/prompts are registered by this Ticket。
- Tests use local mock MCP server processes and cover success/failure/shutdown/diagnostic paths。
Escalate if:
- Existing runtime boundaries make it unclear where lifecycle client should live without dragging ToolRegistry or Plugin feature authority into the config layer。
- Protocol message typing requires a broad MCP provider substrate redesign。
- Deterministic process tests are infeasible without an explicit test harness decision。
Next action:
- Record `queued -> inprogress` and commit Ticket records before creating the implementation worktree and spawning Coder。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T07:30:03Z from: queued to: inprogress reason: orchestrator_acceptance_mcp_stdio_lifecycle_client field: state -->
## State changed
Ticket body/thread, relation metadata, closed MCP config dependency, Orchestrator worktree state, visible Pods, existing branch/worktree, and bounded MCP lifecycle context were checked. Dependency `00001KVHR3WRF` is closed, no inprogress/capacity blocker exists, and no missing planning decision was found. Accepting this queued Ticket for implementation before worktree/Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T07:30:59Z -->
## Implementation report
Implementation orchestration start:
- Acceptance records were committed on Orchestrator branch: `c0e760d7 ticket: accept mcp stdio lifecycle client`
- Created implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVHR3WRY-mcp-stdio-lifecycle-client` on branch `impl/00001KVHR3WRY-mcp-stdio-lifecycle-client` at `c0e760d7`
- Spawned Coder Pod `yoi-coder-00001KVHR3WRY` 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, ToolRegistry/tools/resources/prompts exposure for this Ticket, merge, Ticket close, and cleanup。
Next action:
- Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T07:45:58Z -->
## Implementation report
Coder implementation report received from `yoi-coder-00001KVHR3WRY`.
Implementation commit:
- `a114fa9d mcp: implement stdio lifecycle client`
Changed areas reported:
- Added new internal reusable crate `crates/mcp`
- Implemented stdio MCP lifecycle foundation:
- explicit resolved stdio server spec/config bridge from typed manifest MCP config。
- Tokio child process spawn with `stdin` / `stdout` / `stderr`
- newline-delimited JSON-RPC request/response handling。
- `initialize` capability negotiation。
- `notifications/initialized`
- bounded stdout/stderr/protocol payload handling。
- bounded/redacted stderr diagnostics。
- server-name + phase-aware errors。
- safe shutdown via stdin close, wait, terminate, and kill fallback。
- fail-closed server-to-client request handling including sampling/elicitation。
- Added mock local stdio MCP server test binary scoped to `crates/mcp` tests。
- Updated workspace membership and `package.nix` cargo hash。
Coder validation reported:
- `cargo test -p mcp`: passed。
- `cargo check`: passed。
- `cargo fmt --check`: passed。
- `git diff --check`: passed。
- `nix build .#yoi --no-link`: passed。
Acceptance notes reported:
- Mock stdio MCP server initializes successfully。
- Initialize failure includes server name and `initialize` phase。
- stderr diagnostics are bounded and secret/env values are redacted。
- Shutdown is deterministic with terminate/kill fallback for uncooperative server。
- Client capabilities do not advertise sampling or elicitation。
- Server sampling requests receive JSON-RPC `-32601` fail-closed response。
- No ToolRegistry/tool/resource/prompt registration was added。
Known deferrals:
- No MCP tools/resources/prompts exposure。
- No remote MCP / Streamable HTTP / OAuth。
- Downstream list/call/resource/prompt support remains for later Tickets。
Orchestrator evidence checked before review dispatch:
- Implementation worktree is clean。
- HEAD is `a114fa9d`
- Diff from acceptance `c0e760d7..HEAD` is one implementation commit touching 8 files, about 1369 insertions / 1 deletion。
- `git diff --check c0e760d7..HEAD` produced no diagnostics。
Next action:
- Dispatch Reviewer for r1 review against Ticket requirements, with focus on process lifecycle cleanup, JSON-RPC framing/bounds, stdout/stderr handling, secret redaction, sampling/elicitation fail-closed, no ToolRegistry/resources/prompts exposure, test determinism, and package/Nix impact。
---
<!-- event: plan author: yoi-orchestrator at: 2026-06-20T07:46:38Z -->
## Plan
Review dispatch:
- Spawned Reviewer Pod `yoi-reviewer-00001KVHR3WRY-r1` against implementation branch `impl/00001KVHR3WRY-mcp-stdio-lifecycle-client`
- Review target commit: `a114fa9d mcp: implement stdio lifecycle client`
- Review baseline: `c0e760d7`
- Reviewer task focuses on process lifecycle cleanup, JSON-RPC framing/bounds, initialize negotiation, stderr diagnostics/redaction, sampling/elicitation fail-closed behavior, no ToolRegistry/resources/prompts exposure, crate layering, deterministic tests, and Nix/package impact。
- Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。
---
<!-- event: review author: yoi-reviewer-00001KVHR3WRY-r1 at: 2026-06-20T07:51:30Z status: request_changes -->
## Review: request changes
Verdict: `request_changes`
確認範囲:
- Ticket contract。
- Diff: `c0e760d7..a114fa9d`
- 主な対象: `crates/mcp/src/stdio.rs`, `crates/mcp/src/lib.rs`, `crates/mcp/tests/stdio_lifecycle.rs`, `crates/mcp/tests/fixtures/mock_server.rs`, `crates/mcp/Cargo.toml`, root `Cargo.toml`, `Cargo.lock`, `package.nix`
- ToolRegistry/resource/prompt registration leakage と remote MCP/OAuth/HTTP scope creep を確認。
良い点:
- stdio child spawn、newline JSON-RPC framing、initialize + initialized notification、bounded stdout/stderr handling、stderr-as-diagnostics、fail-closed server-to-client requests、shutdown/terminate/kill fallback、no model-visible registration surface は Ticket intent に概ね沿っている。
Blocking issue:
1. Resolved stdio server spec の derived `Debug` が secret/env values を leak できる。
- `crates/mcp/src/stdio.rs`
- `McpStdioServerSpec` derives `Debug`
- `env: BTreeMap<String, String>` に inherited/env/literal/secret-derived values が入る。
- `Redactions` は stderr/protocol diagnostics には使われているが、resolved spec 自体を `{:?}` で format/log すると env values が plaintext で出る。
- これは Ticket の secret/env redaction 要件に反する。
Required fix:
- `McpStdioServerSpec` から `Debug` を外す、または env values を出さない custom redacted `Debug` を実装する。
- custom `Debug` を残す場合は regression test を追加する。
Non-blocking concerns:
- Initialize response の `protocolVersion` compatibility enforcement は今後 tools/resources/prompts registration 前に判断が必要。Lifecycle slice としては follow-up で可。
- `Drop` cleanup は best-effort `start_kill` / `kill_on_drop`。Downstream code should prefer explicit `shutdown()`
Reviewer validation:
- `git diff --check c0e760d7..HEAD`: passed。
- `cargo fmt --check`: passed。
- `cargo test -p mcp`: passed。
- `cargo check`: passed。
- `nix build .#yoi --no-link`: passed。
- `cargo tree -p mcp --depth 1`: inspected; no product façade dependency found。
Worktree status at review end: clean。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T07:55:55Z -->
## Implementation report
Coder r1-fix report received from `yoi-coder-00001KVHR3WRY`.
New fix commit:
- `f396e1a2 mcp: redact stdio server spec debug`
Branch commits now:
- `f396e1a2 mcp: redact stdio server spec debug`
- `a114fa9d mcp: implement stdio lifecycle client`
Fix summary:
- Removed derived `Debug` from `McpStdioServerSpec`
- Added custom redacted `Debug` implementation。
- Debug output preserves useful non-sensitive fields: `name`, `command`, `args`, `cwd`, env keys, and redaction count。
- Debug output never prints resolved env values or redaction values。
- Added regression test verifying `format!("{:?}", spec)` does not contain literal/inherited/env-ref/secret-ref plaintext values while still showing non-sensitive metadata。
Coder validation reported as passing:
- `cargo fmt --check`
- `cargo test -p mcp`
- `cargo check`
- `git diff --check c0e760d7..HEAD`
- `cargo tree -p mcp --depth 1`
- `nix build .#yoi --no-link`
Remaining deferrals:
- Protocol-version compatibility enforcement before downstream tools/resources/prompts registration。
- Downstream callers should prefer explicit `shutdown()` over best-effort `Drop` cleanup。
Orchestrator evidence checked before r2 review:
- Implementation worktree is clean。
- HEAD is `f396e1a2`
- Fix diff from `a114fa9d..HEAD` touches `crates/mcp/src/stdio.rs` and `crates/mcp/tests/stdio_lifecycle.rs`, about 47 insertions / 1 deletion。
- Overall `git diff --check c0e760d7..HEAD` produced no diagnostics。
Next action:
- Route back to Reviewer for r2 verification of the redacted `Debug` blocker and final acceptance criteria。
---
<!-- event: review author: yoi-reviewer-00001KVHR3WRY-r1 at: 2026-06-20T07:58:02Z status: approve -->
## Review: approve
Verdict: `approve`
確認範囲:
- Ticket authority / Orchestrator IntentPacket。
- r2 fix diff: `a114fa9d..f396e1a2`
- 全体実装 diff: `c0e760d7..f396e1a2`
- 主な対象: `crates/mcp/src/stdio.rs`, `crates/mcp/tests/stdio_lifecycle.rs`, new crate/workspace/package changes, ToolRegistry/resource/prompt registration leakage の有無。
Previous blocker verification:
- `McpStdioServerSpec` no longer derives `Debug`; it is `Clone` only。
- Custom `fmt::Debug` now redacts env values and prints only non-sensitive metadata / env keys / `redaction_count`
- `redactions` contents are not printed。
- Regression test confirms `format!("{spec:?}")` includes useful non-sensitive metadata and excludes plaintext env-like values。
Blocking issues: none。
Non-blocking concerns / follow-ups:
- None blocking for this Ticket。Implementation remains scoped to stdio lifecycle/protocol foundations and does not add ToolRegistry/resource/prompt registration or remote/OAuth surfaces。
Reviewer validation:
- `git diff --check c0e760d7..HEAD`: passed。
- `cargo fmt --check`: passed。
- `cargo test -p mcp`: passed; 5 tests passed including redacted Debug regression。
- `cargo check`: passed。
- `cargo tree -p mcp --depth 1`: inspected; dependencies limited to `libc`, `manifest`, `secrets`, `serde`, `serde_json`, `thiserror`, `tokio`; no product façade dependency。
- `nix build .#yoi --no-link`: passed。
- Additional grep found no added ToolRegistry/resource/prompt registration or remote/OAuth/HTTP scope creep。
Worktree status at review end: clean。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-20T07:59:04Z -->
## Implementation report
Merge and final validation report:
Merged implementation branch into Orchestrator branch:
- Merge commit: `9cf5344f merge: mcp stdio lifecycle client`
- Implementation commits included:
- `a114fa9d mcp: implement stdio lifecycle client`
- `f396e1a2 mcp: redact stdio server spec debug`
Reviewer outcome:
- r1 requested changes for `McpStdioServerSpec` derived `Debug` leaking resolved env/secret-derived values。
- Coder added custom redacted `Debug` and regression test。
- r2 approved with no blocking issues。
Orchestrator validation after merge passed:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p mcp`
- `cargo check`
- `cargo tree -p mcp --depth 1`
- `nix build .#yoi --no-link`
- `nix path-info -S .#yoi`: `112615056`
Validation log:
- inline Bash output for final validation command。
Final state:
- Orchestrator worktree clean at `9cf5344f` after validation。
- Implementation worktree remains available for cleanup after Ticket completion records are committed。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-20T07:59:10Z from: inprogress to: done reason: merged_reviewed_validated field: state -->
## State changed
Implementation was merged into Orchestrator branch at `9cf5344f`, r2 review approved, and final Orchestrator validation passed: `cargo fmt --check`, `git diff --check HEAD^1..HEAD`, `cargo test -p mcp`, `cargo check`, `cargo tree -p mcp --depth 1`, and `nix build .#yoi --no-link`.
---
<!-- event: state_changed author: hare at: 2026-06-20T07:59:30Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-20T07:59:30Z status: closed -->
## 完了
## Resolution
`00001KVHR3WRY` を完了しました。
実装内容:
- New internal crate `mcp` を追加しました。
- Explicit MCP stdio server config から resolved stdio server spec を作成する bridge を追加しました。
- Tokio child process による local stdio MCP server lifecycle foundation を実装しました。
- stdin/stdout/stderr handling、newline-delimited JSON-RPC request/response handling、initialize/capability negotiation、`notifications/initialized` を実装しました。
- stdout/stderr/protocol payloads は bounded に扱います。
- stderr は bounded diagnostics/logging として扱い、protocol failure とは別扱いです。
- server name / phase-aware errors を追加しました。
- shutdown は stdin close / wait / terminate / kill fallback で deterministic に行います。
- Server-to-client requests は fail-closed し、sampling/elicitation は advertise せず、unknown request は JSON-RPC error で返します。
- `McpStdioServerSpec``Debug` は custom redacted 実装にし、resolved env/secret-derived values を出さない regression test を追加しました。
- ToolRegistry / tools/resources/prompts registration、remote MCP / Streamable HTTP / OAuth は実装していません。
主な commit:
- `a114fa9d mcp: implement stdio lifecycle client`
- `f396e1a2 mcp: redact stdio server spec debug`
- `9cf5344f merge: mcp stdio lifecycle client`
Review:
- r1 は resolved spec `Debug` による env/secret leak で `request_changes`
- Coder が custom redacted `Debug` と regression test を追加。
- r2 は `approve`
最終 validation:
- `cargo fmt --check`
- `git diff --check HEAD^1..HEAD`
- `cargo test -p mcp`
- `cargo check`
- `cargo tree -p mcp --depth 1`
- `nix build .#yoi --no-link`
Package impact:
- `nix path-info -S .#yoi`: `112615056`
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260620-060022-1","ticket_id":"00001KVHR3WS6","kind":"blocked_by","related_ticket":"00001KVHR3WRY","note":"Tool registration requires initialized MCP stdio lifecycle. `00001KVHR3WRY` is queued and depends on `00001KVHR3WRF`; leave this Ticket queued until lifecycle is closed.","author":"yoi-orchestrator","at":"2026-06-20T06:00:22Z"}

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHR3WS6",
"kind": "depends_on",
"target": "00001KVHR3WRY",
"note": "Tool registration requires initialized MCP stdio lifecycle.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
},
{
"ticket_id": "00001KVHR3WS6",
"kind": "related",
"target": "00001KTR81P9X",
"note": "MCP tool registration uses feature runtime-discovered contribution plumbing.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
}
]
}

View File

@ -0,0 +1,49 @@
---
title: 'MCP: register server tools into ToolRegistry'
state: 'queued'
created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T06:00:44Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['mcp', 'tools-list', 'tool-registry', 'schema', 'untrusted-metadata']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T05:58:58Z'
---
## Background
Once a configured MCP stdio server can initialize, Yoi should expose provider-discovered MCP tools as ordinary model-visible Yoi tools through the existing ToolRegistry path. Server-provided tool metadata and schemas are untrusted data.
This Ticket only registers tools discovered through `tools/list` at provider initialization / safe refresh boundaries. It does not implement `tools/call` execution and does not allow model-visible tool schema mutation during an active run.
## Requirements
- Call MCP `tools/list` after initialize where supported.
- Handle pagination / bounded listing.
- Normalize MCP tool names into stable namespaced Yoi tool names that include server namespace.
- Validate/normalize tool descriptions and input schemas as untrusted metadata.
- Reject invalid schemas, duplicate names, and collisions fail-closed with diagnostics.
- Register provider-discovered tool contributions through `pod::feature` / normal ToolRegistry path; no private MCP bypass.
- Keep model-visible tool schema run-stable; `list_changed` handling is a later safe-boundary refresh/diagnostic problem, not mid-run mutation.
- Do not register resources/prompts in this Ticket.
## Acceptance criteria
- Provider-discovered MCP mock server tool appears as model-visible Yoi tool with stable namespaced name.
- Invalid schema is rejected with bounded diagnostic.
- Duplicate/colliding names are rejected fail-closed.
- Server metadata cannot weaken Yoi instructions/scope/permissions.
- No `tools/call` request is sent during registration.
- Active-run model-visible schema is not mutated by this registration path.
- Tests cover valid registration, pagination/bounds, invalid schema, duplicate/collision, untrusted metadata normalization, and run-stable schema behavior.
## Non-goals
- MCP tool execution.
- Resources/prompts operations.
- list_changed notifications.
## Related work
- Depends on `00001KVHR3WRY`.
- Objective: `00001KTR80WMN`.

View File

@ -0,0 +1,29 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:30:04Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T05:58:58Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T06:00:44Z -->
## Decision
Routing decision: blocked_pending_dependency
Panel Queue により routing 対象として確認したが、`00001KVHR3WS6` は `00001KVHR3WRY``depends_on` している。MCP tools/list registration は initialized stdio lifecycle を前提にするため、`00001KVHR3WRY` が closed になるまで実装開始せず queued のまま保持する。
Next:
- `00001KVHR3WRY` が closed になった後、改めて reroute する。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260620-060022-1","ticket_id":"00001KVHR3WSD","kind":"blocked_by","related_ticket":"00001KVHR3WS6","note":"tools/call execution requires registered MCP tools. `00001KVHR3WS6` is queued and depends on lifecycle; leave this Ticket queued until tool registration is closed.","author":"yoi-orchestrator","at":"2026-06-20T06:00:22Z"}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHR3WSD",
"kind": "depends_on",
"target": "00001KVHR3WS6",
"note": "tools/call execution requires registered MCP tools.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
}
]
}

View File

@ -0,0 +1,45 @@
---
title: 'MCP: execute tools/call through ordinary Tool path'
state: 'queued'
created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T06:00:44Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['mcp', 'tools-call', 'permission', 'history', 'bounded-output']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T05:59:04Z'
---
## Background
After MCP tools are registered through ToolRegistry, invoking a Yoi MCP-backed tool should call the server's `tools/call` and return a bounded ordinary Tool result. Permission denial must happen before sending a request to the MCP server.
## Requirements
- Route registered MCP tool invocation to MCP `tools/call`.
- Apply existing PreToolCall / Tool permission path before MCP server call.
- If permission is denied, do not send `tools/call` to the server.
- Distinguish normal result, MCP `isError: true`, and JSON-RPC protocol error.
- Serialize MCP result forms boundedly: `content[]`, `structuredContent`, `isError`, `_meta`, and supported rich content summaries.
- Store result through ordinary Tool result/history path.
- Treat all content as untrusted.
## Acceptance criteria
- MCP mock tool returns normal result through ordinary Yoi Tool result.
- MCP `isError: true` is represented distinctly from JSON-RPC protocol failure.
- Permission denied call is not sent to the MCP server.
- Oversize/rich results are bounded/truncated or rejected according to explicit policy.
- Tool history shows ordinary tool call/result, not hidden context injection.
- Tests cover normal result, `isError`, protocol error, permission denial, and output bounds.
## Non-goals
- resources/read or prompts/get.
- list_changed notifications.
- Sampling/elicitation.
## Related work
- Depends on `00001KVHR3WS6`.
- Objective: `00001KTR80WMN`.

View File

@ -0,0 +1,29 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:30:04Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T05:59:04Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T06:00:44Z -->
## Decision
Routing decision: blocked_pending_dependency
Panel Queue により routing 対象として確認したが、`00001KVHR3WSD` は `00001KVHR3WS6``depends_on` している。MCP `tools/call` execution は registered MCP tools を前提にするため、`00001KVHR3WS6` が closed になるまで実装開始せず queued のまま保持する。
Next:
- `00001KVHR3WS6` が closed になった後、改めて reroute する。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260620-060022-1","ticket_id":"00001KVHR3WSN","kind":"blocked_by","related_ticket":"00001KVHR3WRY","note":"Resources/prompts operations require initialized MCP stdio lifecycle. `00001KVHR3WRY` is queued and depends on `00001KVHR3WRF`; leave this Ticket queued until lifecycle is closed.","author":"yoi-orchestrator","at":"2026-06-20T06:00:22Z"}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHR3WSN",
"kind": "depends_on",
"target": "00001KVHR3WRY",
"note": "resources/prompts operations require initialized MCP stdio lifecycle.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
}
]
}

View File

@ -0,0 +1,45 @@
---
title: 'MCP: expose resources and prompts as explicit tool operations'
state: 'queued'
created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T06:00:44Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['mcp', 'resources', 'prompts', 'prompt-context', 'history', 'untrusted-content']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T05:58:57Z'
---
## Background
MCP resources and prompts must not become hidden context injection. They should be exposed as explicit Yoi tool operations whose results are recorded through ordinary Tool result/history paths.
## Requirements
- Expose MCP resources/prompts as explicit namespaced Yoi tool operations: `resources/list`, `resources/read`, `prompts/list`, and `prompts/get`.
- Treat returned content/templates as untrusted tool result data.
- Do not inject resource/prompt content directly into context outside history/tool result.
- Bound result sizes and rich/embedded content serialization.
- Handle pagination/list bounds where applicable.
- Diagnostics identify server/resource/prompt operation without leaking secrets.
## Acceptance criteria
- `resources/list` and `resources/read` can be invoked as explicit tools.
- `prompts/list` and `prompts/get` can be invoked as explicit tools.
- Results are ordinary Tool results and history records.
- No hidden context injection path is introduced.
- Oversize/rich content is bounded.
- Tests cover list/read/get happy paths, untrusted content, bounds, and no hidden injection.
## Non-goals
- MCP tool execution itself.
- list_changed notification refresh.
- Sampling/elicitation.
## Related work
- Depends on `00001KVHR3WRY`.
- Related to `00001KVHR3WSD` for result serialization policy.
- Objective: `00001KTR80WMN`.

View File

@ -0,0 +1,29 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:30:04Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T05:58:57Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T06:00:44Z -->
## Decision
Routing decision: blocked_pending_dependency
Panel Queue により routing 対象として確認したが、`00001KVHR3WSN` は `00001KVHR3WRY``depends_on` している。MCP resources/prompts operations は initialized stdio lifecycle を前提にするため、`00001KVHR3WRY` が closed になるまで実装開始せず queued のまま保持する。
Next:
- `00001KVHR3WRY` が closed になった後、改めて reroute する。
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260620-060022-1","ticket_id":"00001KVHR3WSW","kind":"blocked_by","related_ticket":"00001KVHR3WS6","note":"list_changed handling requires initial tools/list registration. `00001KVHR3WS6` is queued and depends on lifecycle; leave this Ticket queued until tool registration is closed.","author":"yoi-orchestrator","at":"2026-06-20T06:00:22Z"}

View File

@ -0,0 +1,13 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHR3WSW",
"kind": "depends_on",
"target": "00001KVHR3WS6",
"note": "tools/list_changed handling requires initial tools/list registration.",
"author": "yoi ticket",
"at": "2026-06-20T05:33:03Z"
}
]
}

View File

@ -0,0 +1,43 @@
---
title: 'MCP: handle list_changed notifications safely'
state: 'queued'
created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T06:00:44Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['mcp', 'notifications', 'tool-schema', 'prompt-cache', 'refresh']
queued_by: 'workspace-panel'
queued_at: '2026-06-20T05:59:05Z'
---
## Background
MCP servers can notify that tools/resources/prompts lists changed. Yoi must not silently go stale, but it also must not mutate the active run's model-visible tool schema or context in a way that violates history/prompt-cache invariants. This Ticket defines provider tool-list refresh behavior at safe boundaries.
## Requirements
- Handle MCP list-changed notifications: `notifications/tools/list_changed`, `notifications/resources/list_changed`, and `notifications/prompts/list_changed`.
- Choose and implement a safe refresh policy: next-turn refresh, restart/reinitialize-required diagnostic, or bounded live refresh only if it preserves schema/history invariants.
- Do not mutate current LLM context with hidden resource/prompt content.
- Emit bounded diagnostics when refresh cannot be applied safely.
- Tests with mock server notifications.
## Acceptance criteria
- list_changed does not silently stale forever.
- Current run tool schema consistency is not broken.
- Refresh/diagnostic behavior is deterministic and documented.
- Prompt-context/history invariants are preserved.
- Tests cover tools/resources/prompts list_changed and unsafe refresh fallback.
## Non-goals
- Initial tools/list registration.
- Initial resources/prompts operations.
- Remote MCP transports.
## Related work
- Depends on `00001KVHR3WS6` for tool list registration.
- Related to `00001KVHR3WSN` for resources/prompts lists.
- Objective: `00001KTR80WMN`.

View File

@ -0,0 +1,29 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:30:04Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: state_changed author: workspace-panel at: 2026-06-20T05:59:05Z from: ready to: queued reason: queued field: state -->
## State changed
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-20T06:00:44Z -->
## Decision
Routing decision: blocked_pending_dependency
Panel Queue により routing 対象として確認したが、`00001KVHR3WSW` は `00001KVHR3WS6``depends_on` している。list_changed handling は initial tools/list registration を前提にするため、`00001KVHR3WS6` が closed になるまで実装開始せず queued のまま保持する。
Next:
- `00001KVHR3WS6` が closed になった後、改めて reroute する。
---

View File

@ -0,0 +1,161 @@
---
title: 'Dashboard / Console 呼称導入と TUI モジュール境界整理'
state: 'ready'
created_at: '2026-06-20T06:55:49Z'
updated_at: '2026-06-20T06:55:57Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['ux-naming', 'module-boundary', 'public-cli', 'test-coverage']
---
## Background
TUI まわりの user-facing surface 名と code/module 境界を明確にする。
- **Dashboard**: `yoi panel`。workspace-level の Ticket / Pod / Companion / Orchestrator / Intake 状態と action を扱う surface。
- **Console**: 通常の単一 Pod に接続して会話する chat/client surface。
- **TUI**: Dashboard / Console / picker / setup UI などを実装する terminal UI crate/layer の総称。
事前調査では `crates/tui/src/multi_pod.rs` は約 9452 lines で、実態は multi-Pod list というより workspace Dashboard 本体だった。主な責務分布は次の通り。
```text
1- 307 entry/runtime loop
308- 451 background reload/notice handles
452- 792 intake launch/handoff helpers
793- 1160 diagnostics/e2e dashboard structs
1161- 2422 MultiPodApp state + key/mouse/composer methods
2423- 3377 snapshot/lifecycle/load
3378- 4986 companion/orchestrator/ticket actions/queue handoff
4987- 6258 row classification/layout/render
6259- 9452 tests
```
関連ファイル規模の目安:
```text
9452 crates/tui/src/multi_pod.rs
2812 crates/tui/src/workspace_panel.rs
2346 crates/tui/src/single_pod.rs
3689 crates/tui/src/app.rs
1851 crates/tui/src/ui.rs
1209 crates/tui/src/pod_list.rs
556 crates/tui/src/role_session_registry.rs
```
`multi_pod.rs` は unit test / async test も多く、挙動維持の根拠としてテストを活かしながらまとめて整理する。
## Requirements
- `Dashboard` / `Console` / `TUI` の呼称を導入し、help / docs / comments / test names / internal naming を可能な範囲で揃える。
- `Dashboard``yoi panel` の user-facing surface 名とする。
- `Console` は単一 Pod 接続チャットクライアントの user-facing surface 名とする。
- `TUI` は terminal UI implementation layer の総称として扱い、Dashboard / Console の代替 mode 名として乱用しない。
- `yoi panel` command 名は維持する。
- `yoi dashboard` などの不要な互換 alias は追加しない。
- Dashboard の起動 entrypoint を Console / `single_pod` 側から分離する。
- Dashboard から Pod を開いて Console に入る bridge は残すが、narrow API として責務を明確にする。
- `crates/tui/src/multi_pod.rs` を Dashboard module 境界へ寄せる。
- `multi_pod.rs` の巨大化を解消するため、少なくとも次の責務を分離・整理する。
- Dashboard entry/runtime loop
- Dashboard app state / action dispatch
- Dashboard snapshot/lifecycle loading
- Companion / Orchestrator / Intake / Ticket action glue
- Dashboard render/layout/row classification
- Dashboard e2e diagnostics structs
- Dashboard tests
- `workspace_panel.rs` は Dashboard view model builder としての責務を確認し、必要なら名前・配置・参照を Dashboard 語彙に寄せる。
- `single_pod.rs` は Console 側の起動・接続・spawn/resume・chat loop を主責務にする。
- 挙動変更は目的にしない。主眼は naming / module boundary / maintainability refactor。
## Acceptance criteria
- `yoi panel` が Dashboard として説明されている。
- 単一 Pod UI が Console として説明されている。
- `TUI` が Dashboard / Console の総称 implementation layer として整理されている。
- `LaunchMode::Panel``single_pod::run_panel` のような Console module entrypoint に直接流れない。
- Dashboard entrypoint が Dashboard module 側にある。
- Console module は単一 Pod chat/connect/spawn/resume の責務を主に持つ。
- Dashboard から Console を開く bridge の境界が読み取れる。
- `multi_pod.rs` 相当の責務が Dashboard module 配下へ移され、巨大単一ファイル状態が改善されている。
- render/list/layout、action/lifecycle、diagnostics/tests などの境界が reviewer に分かる形になっている。
- 既存の workspace panel / action model の設計と矛盾しない。
- `yoi panel` の Ticket-centric workspace cockpit という意味を保つ。
- `yoi panel` が scheduler/backend であるかのような実装・説明になっていない。
- 既存テストを維持・更新し、refactor による挙動退行が検出できる状態にする。
- reviewer が diff から、単なる rename ではなく Dashboard / Console の責務境界が改善されたことを確認できる。
## Binding decisions / invariants
- **Dashboard = `yoi panel`**
- **Console = single-Pod chat/client surface**
- **TUI = terminal UI implementation layer / crate-level umbrella**
- `panel` / Dashboard は単一 Pod Console の名前には使わない。
- Console は Dashboard の subordinate mode ではなく、別の user-facing surface として扱う。
- Dashboard は Console の一部ではない。
- Dashboard は workspace-level cockpit/action surface であり、scheduler/backend ではない。
- `yoi panel` command 名は維持する。
- 不要な compatibility alias は追加しない。
- テストが維持できる範囲では、過度に分割を避ける必要はない。大きな file move / module split を許容する。
## Implementation latitude
- 最終的な module 構成は実装者判断でよいが、次の方向を推奨する。
```text
crates/tui/src/
console/
mod.rs # user-facing Pod Console launch API
app_loop.rs # current single_pod run loop 相当
nested.rs # Dashboard から Pod Console を開く bridge
terminal.rs # Console fullscreen/mouse helpers
dashboard/
mod.rs # user-facing Dashboard launch API
app.rs # Dashboard app state / selection / key handling
runtime.rs # current multi_pod::run loop 相当
snapshot.rs # Dashboard snapshot loading
lifecycle.rs # companion/orchestrator ensure/observe
actions.rs # Ticket/Intake/Companion/Orchestrator actions
render.rs # layout/draw/list rendering
e2e.rs # dashboard-specific e2e structs/events
tests.rs or tests/
```
- 互換性や reviewability のため、type 名の rename は段階的でもよい。ただし module / docs / entrypoint は Dashboard / Console 語彙へ寄せる。
- `MultiPodApp` などの既存名は必要なら一時的に残せるが、最終的な方向性が Dashboard であることをコード上から読めるようにする。
- tests は同一 commit 内で移動・更新してよい。test coverage 維持を優先する。
- exact string test がある場合は、新しい Dashboard / Console 呼称に合わせて期待値を更新する。
## Readiness
- readiness: implementation_ready
- risk_flags: [ux-naming, module-boundary, public-cli, test-coverage]
Risk flags は stop gate ではなく reviewer focus として扱う。主な確認点は public CLI/help の不要な互換増加、Dashboard / Console 境界、既存 panel authority/action model の保持、テスト維持。
## Escalation conditions
- `Dashboard` / `Console` の二分では説明しきれない第三の user-facing surface が見つかった場合は確認する。
- `yoi panel` command 名を変える必要が出た場合は確認する。
- `yoi dashboard` などの alias 追加が必要だと判断した場合は確認する。
- module split の過程で Ticket state transition authority、Pod lifecycle、Orchestrator handoff の挙動変更が必要になった場合は確認する。
- テスト維持では覆えない user-visible behavior change が必要になった場合は確認する。
## Validation
- `cargo test -p tui`
- `cargo test -p yoi`
- `cargo check --workspace --all-targets`
- `cargo fmt --check`
- `git diff --check`
- 必要に応じて CLI help / docs / resources grep で `Dashboard` / `Console` / `TUI` / `panel` / `multi_pod` の残存表現を確認する。
## Related work
- `00001KTCSRS61` Workspace orchestration panel design
- `00001KTCSRS62` Workspace panel action model
- `00001KSKBPYER` Multi-Pod view UI
- `crates/tui/src/multi_pod.rs`
- `crates/tui/src/single_pod.rs`
- `crates/tui/src/workspace_panel.rs`
- `crates/tui/src/lib.rs`
- `yoi panel`

View File

@ -0,0 +1,23 @@
<!-- event: create author: LocalTicketBackend at: 2026-06-20T06:55:49Z -->
## 作成
LocalTicketBackend によって作成されました。
---
<!-- event: intake_summary author: ticket-intake at: 2026-06-20T06:55:57Z -->
## Intake summary
ユーザー合意に基づき、Dashboard / Console 呼称導入、`yoi panel` Dashboard 境界、単一 Pod Console 境界、`multi_pod.rs` の Dashboard module への移設・分割、docs/help/tests 更新を 1 つの concrete implementation Ticket として整理した。readiness は `implementation_ready`。risk flags は stop gate ではなく reviewer focus として扱う。
---
<!-- event: state_changed author: ticket-intake at: 2026-06-20T06:55:57Z from: planning to: ready reason: planning_ready field: state -->
## State changed
要件・受け入れ条件・binding decisions・validation が揃っており、Orchestrator が実装 routing 可能。
---

28
Cargo.lock generated
View File

@ -2032,6 +2032,7 @@ dependencies = [
"llm-worker", "llm-worker",
"mlua", "mlua",
"protocol", "protocol",
"secrets",
"serde", "serde",
"serde_ignored", "serde_ignored",
"serde_json", "serde_json",
@ -2077,6 +2078,19 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "mcp"
version = "0.1.0"
dependencies = [
"libc",
"manifest",
"secrets",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@ -2623,6 +2637,7 @@ dependencies = [
"wasmtime", "wasmtime",
"wat", "wat",
"workflow", "workflow",
"yoi-plugin-pdk",
] ]
[[package]] [[package]]
@ -5388,6 +5403,7 @@ version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [ dependencies = [
"bitflags 2.11.0",
"wit-bindgen-rust-macro", "wit-bindgen-rust-macro",
] ]
@ -5553,6 +5569,18 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "yoi-plugin-pdk"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"toml",
"wit-bindgen",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"

View File

@ -8,7 +8,9 @@ members = [
"crates/session-store", "crates/session-store",
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/mcp",
"crates/pod", "crates/pod",
"crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/pod-store", "crates/pod-store",
"crates/protocol", "crates/protocol",
@ -33,7 +35,9 @@ default-members = [
"crates/session-store", "crates/session-store",
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/mcp",
"crates/pod", "crates/pod",
"crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/pod-store", "crates/pod-store",
"crates/protocol", "crates/protocol",
@ -60,11 +64,13 @@ client = { path = "crates/client" }
llm-worker = { path = "crates/llm-worker", version = "0.2" } llm-worker = { path = "crates/llm-worker", version = "0.2" }
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" } llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
manifest = { path = "crates/manifest" } manifest = { path = "crates/manifest" }
mcp = { path = "crates/mcp" }
lint-common = { path = "crates/lint-common" } lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" } memory = { path = "crates/memory" }
ticket = { path = "crates/ticket" } ticket = { path = "crates/ticket" }
project-record = { path = "crates/project-record" } project-record = { path = "crates/project-record" }
pod = { path = "crates/pod" } pod = { path = "crates/pod" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
yoi = { path = "crates/yoi" } yoi = { path = "crates/yoi" }
pod-registry = { path = "crates/pod-registry" } pod-registry = { path = "crates/pod-registry" }
pod-store = { path = "crates/pod-store" } pod-store = { path = "crates/pod-store" }

View File

@ -13,6 +13,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_ignored = "0.1.14" serde_ignored = "0.1.14"
sha2 = "0.10" sha2 = "0.10"
secrets = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View File

@ -6,7 +6,7 @@
//! via [`PodManifestConfig::merge`] and the final config is converted to //! via [`PodManifestConfig::merge`] and the final config is converted to
//! a validated [`PodManifest`] via `TryFrom`. //! a validated [`PodManifest`] via `TryFrom`.
use std::collections::HashMap; use std::collections::{BTreeSet, HashMap};
use std::num::NonZeroU32; use std::num::NonZeroU32;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -17,10 +17,10 @@ use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig, CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue,
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig, McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig,
TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
WorkerManifest, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest,
}; };
/// Partial-form Pod manifest. Every field is optional; one or more /// Partial-form Pod manifest. Every field is optional; one or more
@ -57,6 +57,10 @@ pub struct PodManifestConfig {
/// separate step and does not run during config merge. /// separate step and does not run during config merge.
#[serde(default)] #[serde(default)]
pub plugins: PluginConfig, pub plugins: PluginConfig,
/// Explicit Model Context Protocol provider declarations. Config parsing
/// never starts a local MCP subprocess.
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfigPartial>, pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`]. /// First-class web tool opt-in. See [`WebConfig`].
@ -322,6 +326,11 @@ pub enum ResolveError {
MissingField(&'static str), MissingField(&'static str),
#[error("path must be absolute ({field}): {}", .path.display())] #[error("path must be absolute ({field}): {}", .path.display())]
RelativePath { field: &'static str, path: PathBuf }, RelativePath { field: &'static str, path: PathBuf },
#[error("invalid MCP config ({field}): {message}")]
InvalidMcpConfig {
field: &'static str,
message: String,
},
} }
/// Reject manifest fields that were intentionally removed and must not be /// Reject manifest fields that were intentionally removed and must not be
@ -436,6 +445,11 @@ impl PodManifestConfig {
*dir = join_if_relative(base, dir); *dir = join_if_relative(base, dir);
} }
} }
for server in &mut self.mcp.stdio_servers {
if let Some(McpStdioCwdPolicy::Path { path }) = &mut server.cwd {
*path = join_if_relative(base, path);
}
}
self self
} }
@ -458,6 +472,7 @@ impl PodManifestConfig {
), ),
feature: self.feature.merge(upper.feature), feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins), plugins: merge_plugin_config(self.plugins, upper.plugins),
mcp: merge_mcp_config(self.mcp, upper.mcp),
compaction: merge_option( compaction: merge_option(
self.compaction, self.compaction,
upper.compaction, upper.compaction,
@ -487,6 +502,11 @@ fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginCon
base base
} }
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
base.stdio_servers.extend(upper.stdio_servers);
base
}
impl WebConfig { impl WebConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> Self {
Self { Self {
@ -708,6 +728,149 @@ fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<()
Ok(()) Ok(())
} }
pub(crate) fn validate_mcp_config(mcp: &McpConfig) -> Result<(), ResolveError> {
let mut names = BTreeSet::new();
for server in &mcp.stdio_servers {
if server.name.trim().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.name",
"server name must not be empty",
));
}
if contains_nul(&server.name) {
return Err(invalid_mcp(
"mcp.stdio_server.name",
"server name must not contain NUL",
));
}
if !names.insert(server.name.as_str()) {
return Err(invalid_mcp(
"mcp.stdio_server.name",
format!(
"duplicate stdio server name `{}`",
bounded_label(&server.name)
),
));
}
if server.command.trim().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.command",
"command must not be empty",
));
}
if contains_nul(&server.command) {
return Err(invalid_mcp(
"mcp.stdio_server.command",
"command must not contain NUL",
));
}
for arg in &server.args {
if contains_nul(arg) {
return Err(invalid_mcp(
"mcp.stdio_server.args",
"argument must not contain NUL",
));
}
}
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd {
if path.as_os_str().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.cwd.path",
"cwd path must not be empty",
));
}
if !path.is_absolute() {
return Err(invalid_mcp(
"mcp.stdio_server.cwd.path",
"cwd path must be absolute after profile/manifest path resolution",
));
}
}
for name in &server.env.inherit {
validate_env_name("mcp.stdio_server.env.inherit", name)?;
}
for (name, value) in &server.env.set {
validate_env_name("mcp.stdio_server.env.set", name)?;
match value {
McpEnvValue::Literal { value } => {
if contains_nul(value) {
return Err(invalid_mcp(
"mcp.stdio_server.env.set",
"literal env value must not contain NUL",
));
}
}
McpEnvValue::SecretRef { ref_ } => {
if secrets::validate_id(ref_).is_err() {
return Err(invalid_mcp(
"mcp.stdio_server.env.set.secret_ref",
"secret_ref must be a valid local secret id",
));
}
}
McpEnvValue::EnvRef { name } => {
validate_env_name("mcp.stdio_server.env.set.env_ref", name)?;
}
}
}
}
Ok(())
}
fn validate_env_name(field: &'static str, name: &str) -> Result<(), ResolveError> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Err(invalid_mcp(
field,
"environment variable name must not be empty",
));
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return Err(invalid_mcp(
field,
"environment variable name must start with ASCII letter or underscore",
));
}
if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
return Err(invalid_mcp(
field,
"environment variable name must contain only ASCII letters, digits, and underscore",
));
}
Ok(())
}
fn invalid_mcp(field: &'static str, message: impl Into<String>) -> ResolveError {
ResolveError::InvalidMcpConfig {
field,
message: message.into(),
}
}
fn contains_nul(value: &str) -> bool {
value.as_bytes().contains(&0)
}
fn bounded_label(value: &str) -> String {
const MAX: usize = 80;
let mut out = String::new();
for (idx, ch) in value.chars().enumerate() {
if idx >= MAX {
out.push('…');
break;
}
if ch.is_control() {
out.push('?');
} else {
out.push(ch);
}
}
out
}
impl TryFrom<PodManifestConfig> for PodManifest { impl TryFrom<PodManifestConfig> for PodManifest {
type Error = ResolveError; type Error = ResolveError;
@ -842,6 +1005,8 @@ impl TryFrom<PodManifestConfig> for PodManifest {
} }
} }
validate_mcp_config(&cfg.mcp)?;
Ok(PodManifest { Ok(PodManifest {
pod: PodMeta { name, prompt_pack }, pod: PodMeta { name, prompt_pack },
model: cfg.model, model: cfg.model,
@ -852,6 +1017,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
permissions, permissions,
feature: FeatureConfig::from(cfg.feature), feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins, plugins: cfg.plugins,
mcp: cfg.mcp,
compaction, compaction,
web: cfg.web, web: cfg.web,
memory: cfg.memory, memory: cfg.memory,
@ -899,6 +1065,7 @@ mod tests {
permissions: None, permissions: None,
feature: FeatureConfigPartial::default(), feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(), plugins: PluginConfig::default(),
mcp: McpConfig::default(),
session: None, session: None,
compaction: None, compaction: None,
web: None, web: None,
@ -915,6 +1082,139 @@ mod tests {
assert!(manifest.permissions.is_none()); assert!(manifest.permissions.is_none());
} }
#[test]
fn resolve_mcp_stdio_config_preserves_explicit_policy() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "filesystem".into(),
command: "node".into(),
args: vec!["server.js".into(), "--root".into()],
cwd: Some(McpStdioCwdPolicy::Path { path: abs("/mcp") }),
env: crate::McpEnvConfig {
inherit: vec!["PATH".into()],
set: std::collections::BTreeMap::from([
(
"SAFE_MODE".into(),
McpEnvValue::Literal { value: "1".into() },
),
(
"TOKEN".into(),
McpEnvValue::SecretRef {
ref_: "providers/mcp-token".into(),
},
),
(
"UPSTREAM".into(),
McpEnvValue::EnvRef {
name: "MCP_UPSTREAM_TOKEN".into(),
},
),
]),
},
});
let manifest: PodManifest = cfg.try_into().unwrap();
assert_eq!(manifest.mcp.stdio_servers.len(), 1);
let server = &manifest.mcp.stdio_servers[0];
assert_eq!(server.name, "filesystem");
assert_eq!(server.command, "node");
assert_eq!(server.env.inherit, ["PATH"]);
assert!(matches!(
server.env.set["TOKEN"],
McpEnvValue::SecretRef { .. }
));
}
#[test]
fn resolve_mcp_rejects_empty_command_and_duplicates() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "dup".into(),
command: "".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::InvalidMcpConfig {
field: "mcp.stdio_server.command",
..
}
));
let mut cfg = minimal_valid();
for command in ["one", "two"] {
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "dup".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
}
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::InvalidMcpConfig {
field: "mcp.stdio_server.name",
..
}
));
}
#[test]
fn resolve_mcp_rejects_invalid_env_and_secret_ref_without_leaking_values() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "secret".into(),
command: "no-such-command-is-not-started".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig {
inherit: Vec::new(),
set: std::collections::BTreeMap::from([(
"TOKEN".into(),
McpEnvValue::SecretRef {
ref_: "bad secret id with spaces".into(),
},
)]),
},
});
let err = PodManifest::try_from(cfg).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("secret_ref"));
assert!(!rendered.contains("bad secret id with spaces"));
let value = McpEnvValue::Literal {
value: "plaintext-secret-value".into(),
};
assert!(!format!("{value:?}").contains("plaintext-secret-value"));
}
#[test]
fn resolve_mcp_accepts_nonexistent_command_without_autostart() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "later".into(),
command: "definitely-not-a-command-yoi-must-spawn".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
let manifest: PodManifest = cfg.try_into().unwrap();
assert_eq!(
manifest.mcp.stdio_servers[0].command,
"definitely-not-a-command-yoi-must-spawn"
);
}
#[test] #[test]
fn resolve_session_record_event_trace() { fn resolve_session_record_event_trace() {
let mut cfg = minimal_valid(); let mut cfg = minimal_valid();

View File

@ -24,10 +24,12 @@ pub use profile::{
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::num::NonZeroU32; use std::num::NonZeroU32;
use std::path::PathBuf; use std::path::PathBuf;
use serde::de::Error as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Declarative configuration for a Pod. /// Declarative configuration for a Pod.
@ -62,6 +64,12 @@ pub struct PodManifest {
/// source-qualified entries listed here may resolve to active plugin metadata. /// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)] #[serde(default)]
pub plugins: plugin::PluginConfig, pub plugins: plugin::PluginConfig,
/// Explicit external Model Context Protocol provider configuration. This
/// is config data only: declaring a server never starts a subprocess or
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
/// consumer boundary.
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)] #[serde(default)]
pub compaction: Option<CompactionConfig>, pub compaction: Option<CompactionConfig>,
/// Memory subsystem configuration. Presence of `[memory]` configures memory /// Memory subsystem configuration. Presence of `[memory]` configures memory
@ -194,6 +202,92 @@ pub struct SkillsConfig {
pub directories: Vec<PathBuf>, pub directories: Vec<PathBuf>,
} }
/// Explicit Model Context Protocol configuration.
///
/// The manifest layer records local stdio MCP server declarations but never
/// starts them. Future lifecycle code must opt in to spawning and must keep MCP
/// process authority separate from Plugin permissions and `pod::feature` flags.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpConfig {
/// Named local stdio servers. The list form keeps declarations explicit and
/// lets validation reject duplicate names after profile/override merging.
#[serde(default, rename = "stdio_server")]
pub stdio_servers: Vec<McpStdioServerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpStdioServerConfig {
/// Stable profile-local name used by later lifecycle/tool-surface code.
pub name: String,
/// Executable path/name passed directly to process-spawn code in a later
/// ticket. This is not a shell string and is not executed by config parsing.
pub command: String,
#[serde(default)]
pub args: Vec<String>,
/// Optional working-directory policy for the future subprocess. Omitted
/// means no config-level cwd override. Relative `path` values are resolved
/// against the manifest/profile layer before final validation.
#[serde(default)]
pub cwd: Option<McpStdioCwdPolicy>,
/// Explicit environment policy. There is no implicit environment discovery;
/// future spawn code should inherit only names listed here and set only
/// entries declared here.
#[serde(default)]
pub env: McpEnvConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpStdioCwdPolicy {
/// Leave cwd selection to the lifecycle caller.
Inherit,
/// Use this absolute (after path resolution) working directory.
Path { path: PathBuf },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpEnvConfig {
/// Host environment variable names to copy explicitly at spawn time.
#[serde(default)]
pub inherit: Vec<String>,
/// Environment variables to set explicitly.
#[serde(default)]
pub set: BTreeMap<String, McpEnvValue>,
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpEnvValue {
/// Literal value. Use only for non-secret values; Debug/diagnostics redact
/// it defensively because env values often become credentials over time.
Literal { value: String },
/// Local secret-store id. The plaintext is resolved only by a future runtime
/// consumer and is never loaded during manifest/profile parsing.
#[serde(rename = "secret_ref")]
SecretRef {
#[serde(rename = "ref")]
ref_: String,
},
/// Name of a host environment variable to read explicitly at spawn time.
EnvRef { name: String },
}
impl fmt::Debug for McpEnvValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Literal { .. } => f
.debug_struct("Literal")
.field("value", &"[redacted]")
.finish(),
Self::SecretRef { ref_ } => f.debug_struct("SecretRef").field("ref_", ref_).finish(),
Self::EnvRef { name } => f.debug_struct("EnvRef").field("name", name).finish(),
}
}
}
/// Configuration for WebSearch and WebFetch built-in tools. /// Configuration for WebSearch and WebFetch built-in tools.
/// ///
/// Network tools are fail-closed: absent config or `enabled = false` disables /// Network tools are fail-closed: absent config or `enabled = false` disables
@ -712,7 +806,10 @@ impl PodManifest {
/// Parse a manifest from a TOML string. /// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> { pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?; config::reject_removed_manifest_fields(s)?;
toml::from_str(s) let manifest: Self = toml::from_str(s)?;
config::validate_mcp_config(&manifest.mcp)
.map_err(|error| toml::de::Error::custom(error.to_string()))?;
Ok(manifest)
} }
} }

View File

@ -15,6 +15,48 @@ const ZIP_COMPRESSION_STORED: u16 = 0;
const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000; const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000;
const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000; const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PluginTemplateResource {
pub path: &'static str,
pub contents: &'static str,
}
/// Embedded starter template for Rust Component Model Tool Plugins.
///
/// The template is data only: it performs no filesystem/network operations and
/// grants no authority. Future authoring CLI commands can materialize these
/// files into a chosen destination after applying their own overwrite policy.
pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
PluginTemplateResource {
path: "Cargo.toml",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-tool/Cargo.toml"
),
},
PluginTemplateResource {
path: "src/lib.rs",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-tool/src/lib.rs"
),
},
PluginTemplateResource {
path: "plugin.toml",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-tool/plugin.toml"
),
},
PluginTemplateResource {
path: "plugin.component.wasm",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm"
),
},
PluginTemplateResource {
path: "README.md",
contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"),
},
];
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct PluginConfig { pub struct PluginConfig {
@ -482,6 +524,23 @@ pub struct DiscoveredPluginPackage {
pub entries: BTreeSet<String>, pub entries: BTreeSet<String>,
} }
/// Fully materialized package content used by local authoring checks and pack.
///
/// 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)]
pub struct MaterializedPluginPackage {
pub package: DiscoveredPluginPackage,
pub files: BTreeMap<String, Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackedPluginPackage {
pub output_path: PathBuf,
pub package: DiscoveredPluginPackage,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PluginDiscoveryReport { pub struct PluginDiscoveryReport {
pub packages: Vec<DiscoveredPluginPackage>, pub packages: Vec<DiscoveredPluginPackage>,
@ -1326,7 +1385,146 @@ fn read_package(
.with_source(source) .with_source(source)
.with_package(label) .with_package(label)
})?; })?;
let archive = parse_stored_zip(&bytes, label, source, limits)?; materialize_archive(path, label, source, &bytes, limits)
.map(|materialized| materialized.package)
}
pub fn read_plugin_package_file(
path: &Path,
source: PluginSourceKind,
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let label = package_label(path);
let metadata = fs::metadata(path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
if !metadata.is_file() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Discovery,
"plugin package candidate is not a regular file",
)
.with_source(source)
.with_package(label));
}
if metadata.len() > limits.max_package_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package exceeds the configured package size bound",
)
.with_source(source)
.with_package(label));
}
let bytes = fs::read(path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package content could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
materialize_archive(path, &label, source, &bytes, limits)
}
pub fn read_plugin_directory(
path: &Path,
source: PluginSourceKind,
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let label = package_label(path);
let root = fs::canonicalize(path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
let metadata = fs::metadata(&root).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.clone())
})?;
if !metadata.is_dir() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Discovery,
"plugin directory input is not a directory",
)
.with_source(source)
.with_package(label));
}
let mut files = BTreeMap::new();
collect_directory_files(&root, &root, &label, source, limits, &mut files)?;
materialize_files(path, label, source, files, limits)
}
pub fn write_plugin_package_file(
materialized: &MaterializedPluginPackage,
output_path: &Path,
limits: &PluginDiscoveryLimits,
) -> Result<PackedPluginPackage, PluginDiagnostic> {
write_stored_zip_file(output_path, &materialized.files, limits)?;
let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?;
Ok(PackedPluginPackage {
output_path: output_path.to_path_buf(),
package: package.package,
})
}
impl DiscoveredPluginPackage {
pub fn source(&self) -> PluginSourceKind {
self.identity.source
}
}
fn materialize_archive(
path: &Path,
label: &str,
source: PluginSourceKind,
bytes: &[u8],
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let archive = parse_stored_zip(bytes, label, source, limits)?;
materialize_files(path, label.to_string(), source, archive.files, limits)
}
fn materialize_files(
path: &Path,
label: String,
source: PluginSourceKind,
files: BTreeMap<String, Vec<u8>>,
limits: &PluginDiscoveryLimits,
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
let archive = StoredArchive {
files: files.clone(),
};
let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| { let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| {
PluginDiagnostic::new( PluginDiagnostic::new(
PluginDiagnosticKind::Missing, PluginDiagnosticKind::Missing,
@ -1334,7 +1532,7 @@ fn read_package(
"plugin package is missing root plugin.toml", "plugin package is missing root plugin.toml",
) )
.with_source(source) .with_source(source)
.with_package(label) .with_package(label.clone())
})?; })?;
if manifest_bytes.len() > limits.max_manifest_size_bytes { if manifest_bytes.len() > limits.max_manifest_size_bytes {
return Err(PluginDiagnostic::new( return Err(PluginDiagnostic::new(
@ -1352,7 +1550,7 @@ fn read_package(
"plugin.toml is not valid UTF-8", "plugin.toml is not valid UTF-8",
) )
.with_source(source) .with_source(source)
.with_package(label) .with_package(label.clone())
})?; })?;
let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| { let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| {
PluginDiagnostic::new( PluginDiagnostic::new(
@ -1361,20 +1559,282 @@ fn read_package(
safe_toml_parse_message(&error), safe_toml_parse_message(&error),
) )
.with_source(source) .with_source(source)
.with_package(label) .with_package(label.clone())
})?; })?;
validate_manifest(&manifest, &archive, label, source)?; validate_manifest(&manifest, &archive, &label, source)?;
let digest = deterministic_digest(&archive.files); let digest = deterministic_digest(&archive.files);
let identity = SourceQualifiedPluginId::new(source, manifest.id.clone()); let identity = SourceQualifiedPluginId::new(source, manifest.id.clone());
let package = DiscoveredPluginPackage {
Ok(DiscoveredPluginPackage {
identity, identity,
package_path: path.to_path_buf(), package_path: path.to_path_buf(),
package_label: label.to_string(), package_label: label,
digest, digest,
manifest, manifest,
entries: archive.files.keys().cloned().collect(), entries: archive.files.keys().cloned().collect(),
}) };
Ok(MaterializedPluginPackage { package, files })
}
fn collect_directory_files(
root: &Path,
dir: &Path,
label: &str,
source: PluginSourceKind,
limits: &PluginDiscoveryLimits,
files: &mut BTreeMap<String, Vec<u8>>,
) -> Result<(), PluginDiagnostic> {
let mut entries = fs::read_dir(dir)
.map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory could not be listed: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
entries.sort();
for path in entries {
let metadata = fs::symlink_metadata(&path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory entry metadata could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?;
if metadata.file_type().is_symlink() {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory contains a symlink entry",
)
.with_source(source)
.with_package(label.to_string()));
}
if metadata.is_dir() {
collect_directory_files(root, &path, label, source, limits, files)?;
continue;
}
if !metadata.is_file() {
continue;
}
if metadata.len() > limits.max_file_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin directory file exceeds the configured per-file bound",
)
.with_source(source)
.with_package(label.to_string()));
}
let canonical = fs::canonicalize(&path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory file could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?;
if !canonical.starts_with(root) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory file escapes the package root",
)
.with_source(source)
.with_package(label.to_string()));
}
let relative = canonical.strip_prefix(root).map_err(|_| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory file escapes the package root",
)
.with_source(source)
.with_package(label.to_string())
})?;
let normalized = relative
.components()
.map(|component| component.as_os_str().to_str())
.collect::<Option<Vec<_>>>()
.and_then(|parts| normalize_archive_path(&parts.join("/")))
.ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin directory contains an unsafe relative path",
)
.with_source(source)
.with_package(label.to_string())
})?;
let content = fs::read(&path).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin directory file could not be read: {}",
safe_io_error(&error)
),
)
.with_source(source)
.with_package(label.to_string())
})?;
files.insert(normalized, content);
if files.len() > limits.max_entries_per_package {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin directory contains more files than the configured bound",
)
.with_source(source)
.with_package(label.to_string()));
}
let expanded_size = files
.values()
.map(|content| content.len() as u64)
.sum::<u64>();
if expanded_size > limits.max_expanded_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin directory expanded size exceeds the configured bound",
)
.with_source(source)
.with_package(label.to_string()));
}
}
Ok(())
}
fn write_stored_zip_file(
output_path: &Path,
files: &BTreeMap<String, Vec<u8>>,
limits: &PluginDiscoveryLimits,
) -> Result<(), PluginDiagnostic> {
if files.len() > limits.max_entries_per_package {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package contains more entries than the configured bound",
));
}
let mut bytes = Vec::new();
let mut central = Vec::new();
for (name, content) in files {
let name = normalize_archive_path(name).ok_or_else(|| {
PluginDiagnostic::new(
PluginDiagnosticKind::Traversal,
PluginDiagnosticPhase::Discovery,
"plugin package entry path escapes the archive root",
)
})?;
if content.len() as u64 > limits.max_file_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package entry exceeds the configured per-file bound",
));
}
let local_offset = bytes.len() as u32;
write_u32_vec(&mut bytes, ZIP_LOCAL_FILE);
write_u16_vec(&mut bytes, 20);
write_u16_vec(&mut bytes, 0x0800);
write_u16_vec(&mut bytes, ZIP_COMPRESSION_STORED);
write_u16_vec(&mut bytes, 0);
write_u16_vec(&mut bytes, 0);
write_u32_vec(&mut bytes, 0);
write_u32_vec(&mut bytes, content.len() as u32);
write_u32_vec(&mut bytes, content.len() as u32);
write_u16_vec(&mut bytes, name.len() as u16);
write_u16_vec(&mut bytes, 0);
bytes.extend_from_slice(name.as_bytes());
bytes.extend_from_slice(content);
write_u32_vec(&mut central, ZIP_CENTRAL_DIRECTORY);
write_u16_vec(&mut central, 20);
write_u16_vec(&mut central, 20);
write_u16_vec(&mut central, 0x0800);
write_u16_vec(&mut central, ZIP_COMPRESSION_STORED);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u32_vec(&mut central, 0);
write_u32_vec(&mut central, content.len() as u32);
write_u32_vec(&mut central, content.len() as u32);
write_u16_vec(&mut central, name.len() as u16);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u16_vec(&mut central, 0);
write_u32_vec(&mut central, 0);
write_u32_vec(&mut central, local_offset);
central.extend_from_slice(name.as_bytes());
}
let central_offset = bytes.len() as u32;
bytes.extend_from_slice(&central);
write_u32_vec(&mut bytes, ZIP_EOCD);
write_u16_vec(&mut bytes, 0);
write_u16_vec(&mut bytes, 0);
write_u16_vec(&mut bytes, files.len() as u16);
write_u16_vec(&mut bytes, files.len() as u16);
write_u32_vec(&mut bytes, central.len() as u32);
write_u32_vec(&mut bytes, central_offset);
write_u16_vec(&mut bytes, 0);
if bytes.len() as u64 > limits.max_package_size_bytes {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Bounds,
PluginDiagnosticPhase::Discovery,
"plugin package exceeds the configured package size bound",
));
}
if let Some(parent) = output_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package output directory could not be created: {}",
safe_io_error(&error)
),
)
})?;
}
fs::write(output_path, bytes).map_err(|error| {
PluginDiagnostic::new(
PluginDiagnosticKind::Io,
PluginDiagnosticPhase::Discovery,
format!(
"plugin package output could not be written: {}",
safe_io_error(&error)
),
)
})?;
Ok(())
}
fn write_u16_vec(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn write_u32_vec(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
} }
fn validate_manifest( fn validate_manifest(
@ -1957,6 +2417,40 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
#[test]
fn embedded_rust_component_tool_template_is_valid_package_shape() {
let paths: BTreeSet<_> = RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.map(|file| file.path)
.collect();
assert_eq!(
paths,
BTreeSet::from(["Cargo.toml", "src/lib.rs", "plugin.toml", "README.md"])
);
assert!(
RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.all(|file| !file.path.starts_with('/') && !file.path.contains(".."))
);
let manifest_text = RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.find(|file| file.path == "plugin.toml")
.unwrap()
.contents;
let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap();
assert_eq!(manifest.schema_version, SUPPORTED_PLUGIN_API_VERSION);
assert_eq!(
manifest.runtime.as_ref().unwrap().kind,
PLUGIN_RUNTIME_COMPONENT_KIND
);
assert_eq!(
manifest.runtime.as_ref().unwrap().world.as_deref(),
Some(PLUGIN_COMPONENT_TOOL_WORLD)
);
assert_eq!(manifest.tools.len(), 1);
}
#[test] #[test]
fn discovers_valid_user_and_workspace_packages() { fn discovers_valid_user_and_workspace_packages() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -19,8 +19,9 @@ use crate::config::{
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths, PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig,
WorkerManifestConfig, paths,
}; };
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
@ -628,6 +629,7 @@ fn resolve_lua_profile_value(
permissions: profile.permissions, permissions: profile.permissions,
feature: profile.feature, feature: profile.feature,
plugins: profile.plugins, plugins: profile.plugins,
mcp: profile.mcp,
compaction, compaction,
web: profile.web, web: profile.web,
memory: profile.memory, memory: profile.memory,
@ -691,6 +693,8 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
plugins: PluginConfig, plugins: PluginConfig,
#[serde(default)] #[serde(default)]
mcp: McpConfig,
#[serde(default)]
compaction: Option<serde_json::Value>, compaction: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
web: Option<WebConfig>, web: Option<WebConfig>,
@ -1247,6 +1251,16 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
} }
} }
} }
for server in &profile.mcp.stdio_servers {
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd
&& path.is_absolute()
{
return Err(ProfileError::InvalidProfile(
"field `mcp.stdio_server.cwd.path` must be profile-relative in reusable Profiles"
.into(),
));
}
}
Ok(()) Ok(())
} }
fn reject_absolute_auth_file( fn reject_absolute_auth_file(
@ -1693,6 +1707,66 @@ return profile {
Some("coder") Some("coder")
); );
} }
#[test]
fn lua_profile_resolves_named_mcp_stdio_config_without_starting_command() {
let tmp = TempDir::new().unwrap();
let profile = write_profile(
tmp.path(),
"mcp.lua",
r#"
local profile = require("yoi.profile")
return profile {
slug = "mcp",
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
mcp = {
stdio_server = {
{
name = "filesystem",
command = "definitely-not-spawned-during-profile-resolution",
args = { "--root", "." },
cwd = { kind = "path", path = "servers" },
env = {
inherit = { "PATH" },
set = {
SAFE_MODE = { kind = "literal", value = "1" },
API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" },
FROM_ENV = { kind = "env_ref", name = "MCP_TOKEN" },
},
},
},
},
},
}
"#,
);
std::fs::create_dir(tmp.path().join("servers")).unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"),
)
.unwrap();
let server = &resolved.manifest.mcp.stdio_servers[0];
assert_eq!(server.name, "filesystem");
assert_eq!(
server.command,
"definitely-not-spawned-during-profile-resolution"
);
assert!(matches!(
server.cwd,
Some(McpStdioCwdPolicy::Path { ref path }) if path == &tmp.path().join("servers")
));
assert!(matches!(
server.env.set["API_TOKEN"],
crate::McpEnvValue::SecretRef { .. }
));
}
#[test] #[test]
fn resolves_lua_profile_feature_flags_without_runtime_state() { fn resolves_lua_profile_feature_flags_without_runtime_state() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();

23
crates/mcp/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "mcp"
version = "0.1.0"
edition.workspace = true
[dependencies]
libc = "0.2"
manifest = { workspace = true }
secrets = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["io-util", "process", "sync", "time"] }
[dev-dependencies]
tokio = { workspace = true, features = ["io-util", "macros", "process", "rt-multi-thread", "sync", "time"] }
[[bin]]
name = "mcp-stdio-mock-server"
path = "tests/fixtures/mock_server.rs"
test = false
bench = false
doc = false

7
crates/mcp/src/lib.rs Normal file
View File

@ -0,0 +1,7 @@
//! Model Context Protocol client foundations.
//!
//! This crate intentionally only owns protocol/lifecycle plumbing. It does not
//! register MCP tools, resources, or prompts into Yoi's model-visible tool
//! surface.
pub mod stdio;

1130
crates/mcp/src/stdio.rs Normal file

File diff suppressed because it is too large Load Diff

116
crates/mcp/tests/fixtures/mock_server.rs vendored Normal file
View File

@ -0,0 +1,116 @@
use std::env;
use std::io::{self, BufRead, Write};
use std::thread;
use std::time::Duration;
use serde_json::{Value, json};
fn main() {
let mode = env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string());
match mode.as_str() {
"success" => success(),
"fail-init" => fail_init(),
"sampling" => sampling_request(),
"shutdown-hang" => shutdown_hang(),
other => panic!("unknown mock mode: {other}"),
}
}
fn success() {
let init = read_json();
assert_eq!(init["method"], "initialize");
assert!(init["params"]["capabilities"].get("sampling").is_none());
assert!(init["params"]["capabilities"].get("elicitation").is_none());
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"result": initialize_result(),
}));
let initialized = read_json();
assert_eq!(initialized["method"], "notifications/initialized");
drain_stdin();
}
fn fail_init() {
let secret = env::var("MCP_TEST_SECRET").unwrap_or_default();
for idx in 0..5 {
eprintln!("diagnostic {idx}: secret={secret}");
}
let init = read_json();
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"error": {
"code": -32000,
"message": format!("init rejected with {secret}"),
}
}));
}
fn sampling_request() {
let init = read_json();
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"result": initialize_result(),
}));
let initialized = read_json();
assert_eq!(initialized["method"], "notifications/initialized");
write_json(json!({
"jsonrpc": "2.0",
"id": 99,
"method": "sampling/createMessage",
"params": {},
}));
let response = read_json();
assert_eq!(response["id"], 99);
assert_eq!(response["error"]["code"], -32601);
}
fn shutdown_hang() {
let init = read_json();
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"result": initialize_result(),
}));
let initialized = read_json();
assert_eq!(initialized["method"], "notifications/initialized");
loop {
thread::sleep(Duration::from_secs(60));
}
}
fn initialize_result() -> Value {
json!({
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": true }
},
"serverInfo": {
"name": "mock-mcp",
"version": "0.1.0"
}
})
}
fn read_json() -> Value {
let mut line = String::new();
let read = io::stdin().lock().read_line(&mut line).expect("read stdin");
assert_ne!(read, 0, "stdin closed before JSON-RPC message");
serde_json::from_str(&line).expect("valid JSON-RPC line")
}
fn write_json(value: Value) {
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &value).expect("write JSON");
stdout.write_all(b"\n").expect("write newline");
stdout.flush().expect("flush stdout");
}
fn drain_stdin() {
let mut line = String::new();
while io::stdin().lock().read_line(&mut line).unwrap_or(0) != 0 {
line.clear();
}
}

View File

@ -0,0 +1,122 @@
use std::time::Duration;
use mcp::stdio::{McpErrorKind, McpPhase, McpStdioClient, McpStdioLimits, McpStdioServerSpec};
fn mock_server(mode: &str) -> McpStdioServerSpec {
McpStdioServerSpec::new("mock", env!("CARGO_BIN_EXE_mcp-stdio-mock-server"))
.env("YOI_MCP_MOCK_MODE", mode)
}
fn tight_limits() -> McpStdioLimits {
McpStdioLimits {
startup_timeout: Duration::from_secs(2),
request_timeout: Duration::from_secs(2),
shutdown_timeout: Duration::from_millis(100),
kill_timeout: Duration::from_millis(100),
max_diagnostic_lines: 2,
max_stderr_line_bytes: 256,
..Default::default()
}
}
#[test]
fn stdio_server_spec_debug_redacts_resolved_env_values() {
let spec = McpStdioServerSpec::new("debug-mock", "/bin/mock-mcp")
.arg("--stdio")
.cwd("/tmp/mock-mcp")
.env("LITERAL_VALUE", "literal-plaintext")
.env("INHERITED_VALUE", "inherited-plaintext")
.env("ENV_REF_VALUE", "env-ref-plaintext")
.env("SECRET_REF_VALUE", "secret-ref-plaintext");
let debug = format!("{spec:?}");
assert!(debug.contains("debug-mock"));
assert!(debug.contains("/bin/mock-mcp"));
assert!(debug.contains("--stdio"));
assert!(debug.contains("/tmp/mock-mcp"));
assert!(debug.contains("LITERAL_VALUE"));
assert!(debug.contains("INHERITED_VALUE"));
assert!(debug.contains("ENV_REF_VALUE"));
assert!(debug.contains("SECRET_REF_VALUE"));
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("literal-plaintext"));
assert!(!debug.contains("inherited-plaintext"));
assert!(!debug.contains("env-ref-plaintext"));
assert!(!debug.contains("secret-ref-plaintext"));
}
#[tokio::test]
async fn initializes_mock_stdio_server() {
let mut client = McpStdioClient::connect(mock_server("success"), tight_limits())
.await
.expect("initialize succeeds");
let result = client.initialize_result().expect("initialize result");
assert_eq!(result.protocol_version, "2025-11-25");
assert_eq!(result.server_info.name, "mock-mcp");
let shutdown = client.shutdown().await.expect("shutdown succeeds");
assert!(!shutdown.terminated);
assert!(!shutdown.killed);
assert!(shutdown.exit_status.is_some_and(|status| status.success()));
}
#[tokio::test]
async fn initialize_failure_reports_server_phase_and_redacted_bounded_stderr() {
let spec = mock_server("fail-init").env("MCP_TEST_SECRET", "super-secret-token");
let err = match McpStdioClient::connect(spec, tight_limits()).await {
Ok(mut client) => {
let _ = client.shutdown().await;
panic!("initialize unexpectedly succeeded");
}
Err(err) => err,
};
assert_eq!(err.server_name, "mock");
assert_eq!(err.phase, McpPhase::Initialize);
match &err.kind {
McpErrorKind::JsonRpcError { code, message } => {
assert_eq!(*code, -32000);
assert!(!message.contains("super-secret-token"));
assert!(message.contains("[redacted]"));
}
other => panic!("unexpected error kind: {other:?}"),
}
let rendered = err.to_string();
assert!(rendered.contains("mock"));
assert!(rendered.contains("initialize"));
let diagnostics = err.diagnostics().expect("diagnostics");
assert_eq!(diagnostics.server_name, "mock");
assert_eq!(diagnostics.stderr.len(), 2);
assert!(diagnostics.dropped_stderr_lines >= 3);
assert!(
diagnostics
.stderr
.iter()
.all(|line| !line.contains("super-secret-token"))
);
assert!(
diagnostics
.stderr
.iter()
.any(|line| line.contains("[redacted]"))
);
}
#[tokio::test]
async fn shutdown_terminates_or_kills_uncooperative_server() {
let mut client = McpStdioClient::connect(mock_server("shutdown-hang"), tight_limits())
.await
.expect("initialize succeeds");
let shutdown = client.shutdown().await.expect("shutdown succeeds");
assert!(shutdown.terminated || shutdown.killed);
}
#[tokio::test]
async fn sampling_requests_fail_closed_and_are_not_advertised() {
let mut client = McpStdioClient::connect(mock_server("sampling"), tight_limits())
.await
.expect("initialize succeeds");
tokio::time::sleep(Duration::from_millis(50)).await;
let shutdown = client.shutdown().await.expect("shutdown succeeds");
assert!(shutdown.exit_status.is_some_and(|status| status.success()));
}

View File

@ -0,0 +1,17 @@
[package]
name = "yoi-plugin-pdk"
version = "0.1.0"
edition.workspace = true
license.workspace = true
description = "Guest-side Rust PDK helpers for Yoi Component Model Tool plugins"
publish = false
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
wit-bindgen = "0.51.0"
[dev-dependencies]
tempfile.workspace = true
toml.workspace = true

View File

@ -0,0 +1,476 @@
//! Guest-side helpers for Yoi Component Model Tool plugins.
//!
//! This crate is intentionally small and guest-only: it depends on JSON/WIT
//! binding support, but not on Yoi host/runtime crates. It grants no authority;
//! package manifests and host-side Plugin grants decide whether a Tool or host
//! API can run.
//!
//! Component authors still generate the WIT bindings in their guest crate, then
//! delegate the exported `call` function to [`run_json_tool`] or to the
//! [`export_component_tool!`] macro:
//!
//! ```ignore
//! use yoi_plugin_pdk::wit_bindgen;
//!
//! wit_bindgen::generate!({
//! world: "tool",
//! path: "../../../../resources/plugin/wit",
//! generate_all,
//! runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
//! });
//!
//! fn echo(
//! ctx: yoi_plugin_pdk::ToolContext,
//! input: EchoInput,
//! ) -> Result<yoi_plugin_pdk::ToolOutput, yoi_plugin_pdk::ToolError> {
//! yoi_plugin_pdk::ToolOutput::json(
//! format!("{} ok", ctx.tool_name()),
//! EchoOutput { text: input.text },
//! )
//! }
//!
//! yoi_plugin_pdk::export_component_tool!(Plugin, echo);
//! ```
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
pub use wit_bindgen;
/// Current Yoi Component Model Tool world targeted by this PDK.
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
/// Repository WIT for the current Tool world, exposed for authoring tools and
/// tests. Runtime components should still generate bindings at compile time.
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit");
/// Repository WIT for the grant-bound host APIs importable by Tool components.
pub const HOST_WIT: &str =
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit");
/// Maximum serialized ToolOutput JSON accepted by Yoi's current Plugin runtime.
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
/// Maximum summary bytes accepted by Yoi's current Plugin runtime.
pub const MAX_SUMMARY_BYTES: usize = 1024;
/// Conservative content cap that leaves room for JSON framing and escaping.
pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096;
/// Maximum structured error message bytes retained by the PDK.
pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096;
/// Per-call context passed to a typed JSON handler.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolContext {
tool_name: String,
}
impl ToolContext {
/// Create context for the manifest-declared Tool name selected by the host.
pub fn new(tool_name: impl Into<String>) -> Self {
Self {
tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES),
}
}
/// Manifest-declared Tool name supplied by the host runtime.
pub fn tool_name(&self) -> &str {
&self.tool_name
}
}
/// ToolOutput JSON shape accepted by the current Yoi Plugin runtime.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ToolOutput {
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
impl ToolOutput {
/// Create an ordinary Tool output.
pub fn new(summary: impl Into<String>, content: Option<String>) -> Self {
let mut output = Self {
summary: normalize_summary(summary.into()),
content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)),
};
output.bound_serialized_json();
output
}
/// Create a Tool output whose content is typed JSON.
pub fn json(summary: impl Into<String>, value: impl Serialize) -> Result<Self, ToolError> {
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
let output = Self {
summary: normalize_summary(summary.into()),
content: Some(content),
};
let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?;
if serialized.len() > MAX_TOOL_OUTPUT_BYTES {
return Err(ToolError::invalid_output(format!(
"serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes"
)));
}
Ok(output)
}
/// Create a summary-only Tool output.
pub fn summary(summary: impl Into<String>) -> Self {
Self::new(summary, None)
}
/// Return the bounded summary.
pub fn summary_text(&self) -> &str {
&self.summary
}
/// Return optional detailed content.
pub fn content(&self) -> Option<&str> {
self.content.as_deref()
}
/// Serialize to the ToolOutput JSON string returned by the WIT `call` export.
pub fn to_json_string(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string()
})
}
fn bound_serialized_json(&mut self) {
loop {
let Ok(serialized) = serde_json::to_string(self) else {
return;
};
if serialized.len() <= MAX_TOOL_OUTPUT_BYTES {
return;
}
let Some(content) = self.content.take() else {
return;
};
if content.is_empty() {
return;
}
let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES;
let target = content.len().saturating_sub(overflow + "".len());
self.content = Some(bounded_text(content, target));
}
}
}
/// Stable, low-cardinality error codes for PDK-produced Tool errors.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolErrorCode {
InvalidInput,
InvalidOutput,
Serialization,
Denied,
UnsupportedTool,
Failed,
}
impl ToolErrorCode {
pub fn as_str(self) -> &'static str {
match self {
Self::InvalidInput => "invalid_input",
Self::InvalidOutput => "invalid_output",
Self::Serialization => "serialization",
Self::Denied => "denied",
Self::UnsupportedTool => "unsupported_tool",
Self::Failed => "failed",
}
}
}
/// Structured, bounded error that is rendered as ordinary ToolOutput JSON.
#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)]
#[error("{code:?}: {message}")]
pub struct ToolError {
code: ToolErrorCode,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
}
impl ToolError {
pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES),
details: None,
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::InvalidInput, message)
}
pub fn invalid_output(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::InvalidOutput, message)
}
pub fn serialization(error: impl std::fmt::Display) -> Self {
Self::new(ToolErrorCode::Serialization, error.to_string())
}
pub fn denied(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::Denied, message)
}
pub fn unsupported_tool(tool_name: impl Into<String>) -> Self {
Self::new(
ToolErrorCode::UnsupportedTool,
format!("unsupported tool `{}`", tool_name.into()),
)
}
pub fn failed(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::Failed, message)
}
pub fn with_details(mut self, details: impl Serialize) -> Self {
self.details = serde_json::to_value(details).ok();
self
}
pub fn code(&self) -> ToolErrorCode {
self.code
}
pub fn code_str(&self) -> &'static str {
self.code.as_str()
}
pub fn message(&self) -> &str {
&self.message
}
/// Render this error as ordinary ToolOutput JSON content.
pub fn into_tool_output(self) -> ToolOutput {
#[derive(Serialize)]
struct ErrorBody<'a> {
error: ErrorPayload<'a>,
}
#[derive(Serialize)]
struct ErrorPayload<'a> {
code: &'static str,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<&'a Value>,
}
let summary = format!("tool error: {}", self.code.as_str());
let payload = ErrorBody {
error: ErrorPayload {
code: self.code.as_str(),
message: &self.message,
details: self.details.as_ref(),
},
};
let mut content = serde_json::to_string(&payload).unwrap_or_else(|_| {
format!(
r#"{{"error":{{"code":"{}","message":"failed to serialize error details"}}}}"#,
ToolErrorCode::Serialization.as_str()
)
});
if content.len() > MAX_CONTENT_BYTES {
let payload = ErrorBody {
error: ErrorPayload {
code: self.code.as_str(),
message: &self.message,
details: None,
},
};
content = serde_json::to_string(&payload).unwrap_or_else(|_| {
r#"{"error":{"code":"serialization","message":"failed to serialize error"}}"#
.to_string()
});
}
ToolOutput::new(summary, Some(content))
}
}
/// Parse the WIT `input-json` string into a typed input value.
pub fn parse_json_input<T>(input_json: &str) -> Result<T, ToolError>
where
T: DeserializeOwned,
{
serde_json::from_str(input_json).map_err(|error| {
ToolError::invalid_input(format!(
"tool input is not valid JSON for this schema: {error}"
))
})
}
/// Execute a typed JSON Tool handler and return ToolOutput JSON for the runtime.
///
/// Handler errors and parse/serialization failures are not panics or host-side
/// authority decisions. They are rendered into ordinary ToolOutput JSON so the
/// runtime can route them through the normal Tool result path.
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
where
I: DeserializeOwned,
F: FnOnce(ToolContext, I) -> Result<ToolOutput, ToolError>,
{
let result = parse_json_input::<I>(input_json).and_then(|input| {
let context = ToolContext::new(tool_name);
handler(context, input)
});
match result {
Ok(output) => output.to_json_string(),
Err(error) => error.into_tool_output().to_json_string(),
}
}
/// Implement the generated Component Model `Guest` trait for a typed JSON
/// handler and export it with the `wit-bindgen` generated `export!` macro.
///
/// The caller must import the PDK's `wit_bindgen` re-export and invoke
/// `wit_bindgen::generate!` for the `tool` world first, with
/// `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines the
/// `Guest` trait and `export!` macro in the current module. The generated
/// component still imports only WIT-declared
/// host APIs; this macro does not grant filesystem, network, or environment
/// authority.
#[macro_export]
macro_rules! export_component_tool {
($adapter:ident, $handler:path) => {
struct $adapter;
impl Guest for $adapter {
fn call(
tool_name: ::std::string::String,
input_json: ::std::string::String,
) -> ::std::string::String {
$crate::run_json_tool(&tool_name, &input_json, $handler)
}
}
export!($adapter);
};
}
fn normalize_summary(summary: String) -> String {
let summary = bounded_text(summary, MAX_SUMMARY_BYTES);
if summary.trim().is_empty() {
"tool completed".to_string()
} else {
summary
}
}
fn bounded_text(mut value: String, max_bytes: usize) -> String {
if max_bytes == 0 {
return String::new();
}
value = value
.chars()
.map(|ch| {
if ch.is_control() && ch != '\n' && ch != '\t' {
' '
} else {
ch
}
})
.collect();
if value.len() <= max_bytes {
return value;
}
let suffix = "";
let budget = max_bytes.saturating_sub(suffix.len());
let mut cut = budget.min(value.len());
while cut > 0 && !value.is_char_boundary(cut) {
cut -= 1;
}
value.truncate(cut);
value.push_str(suffix);
value
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Debug, Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
}
#[test]
fn run_json_tool_parses_input_and_serializes_output() {
let output = run_json_tool(
"example_echo",
r#"{"text":"hello"}"#,
|ctx, input: EchoInput| {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
},
);
let value: Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "example_echo ok");
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
assert_eq!(content, json!({"tool":"example_echo","text":"hello"}));
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
}
#[test]
fn run_json_tool_renders_error_output() {
let output =
run_json_tool::<EchoInput, _>("example_echo", r#"{"text":1}"#, |_ctx, _input| {
Ok(ToolOutput::summary("unreachable"))
});
let value: Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "tool error: invalid_input");
let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap();
assert_eq!(content["error"]["code"], "invalid_input");
assert!(content["error"]["message"].as_str().unwrap().len() <= MAX_ERROR_MESSAGE_BYTES);
assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES);
}
#[test]
fn json_output_rejects_oversized_serialized_tool_output() {
let too_large = vec!["quoted \" text"; MAX_TOOL_OUTPUT_BYTES / 4];
let error = ToolOutput::json("too large", too_large).unwrap_err();
assert_eq!(error.code(), ToolErrorCode::InvalidOutput);
}
#[test]
fn tool_error_bounds_message_and_control_characters() {
let message = format!("bad\u{0007}{}", "x".repeat(MAX_ERROR_MESSAGE_BYTES * 2));
let error = ToolError::failed(message);
assert_eq!(error.code_str(), "failed");
assert!(!error.message().contains('\u{0007}'));
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "".len());
}
#[test]
fn wit_constants_match_current_world() {
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0"));
assert!(TOOL_WIT.contains("world tool"));
assert!(TOOL_WIT.contains("export call"));
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0");
assert!(HOST_WIT.contains("interface https"));
assert!(HOST_WIT.contains("interface fs"));
assert!(HOST_WIT.contains("%list: func"));
}
}

View File

@ -0,0 +1,117 @@
use std::fs;
use std::path::Path;
use std::process::Command;
use toml::Value;
const TEMPLATE_CARGO: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml");
const TEMPLATE_LIB: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs");
const TEMPLATE_PLUGIN: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
const TEMPLATE_README: &str =
include_str!("../../../resources/plugin/templates/rust-component-tool/README.md");
const SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs");
const PDK_CARGO: &str = include_str!("../Cargo.toml");
#[test]
fn rust_component_tool_template_has_expected_files() {
let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses");
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
assert!(cargo["workspace"].is_table());
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
assert_eq!(
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
Some("../../../../crates/plugin-pdk")
);
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-revision>\""));
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
assert_eq!(
plugin["runtime"]["world"].as_str(),
Some("yoi:plugin/tool@1.0.0")
);
assert!(plugin["tools"].as_array().expect("tools array").len() == 1);
assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!"));
assert!(TEMPLATE_LIB.contains("generate_all"));
assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(TEMPLATE_LIB.contains("ToolOutput::json"));
assert!(TEMPLATE_README.contains("Component Model Tool Plugin"));
}
#[test]
fn documented_sample_uses_pdk_component_path() {
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!"));
assert!(SAMPLE_LIB.contains("generate_all"));
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(!SAMPLE_LIB.contains("export_name"));
}
#[test]
fn embedded_template_cargo_checks_for_wasm_target() {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let template_dir = crate_dir.join("../../resources/plugin/templates/rust-component-tool");
let manifest_path = template_dir.join("Cargo.toml");
let lock_path = template_dir.join("Cargo.lock");
let _ = fs::remove_file(&lock_path);
let target_dir = tempfile::tempdir().expect("temporary cargo target dir");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let output = Command::new(cargo)
.arg("check")
.arg("--manifest-path")
.arg(&manifest_path)
.arg("--target")
.arg("wasm32-unknown-unknown")
.arg("--offline")
.arg("--target-dir")
.arg(target_dir.path())
.env("CARGO_TERM_COLOR", "never")
.output()
.expect("spawn cargo check for embedded template");
let _ = fs::remove_file(&lock_path);
assert!(
output.status.success(),
"template cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn pdk_runtime_dependencies_are_guest_side_only() {
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
let dependencies = cargo["dependencies"]
.as_table()
.expect("dependencies table");
let forbidden = [
"pod",
"yoi-pod",
"llm-worker",
"tui",
"yoi-tui",
"client",
"yoi-client",
"manifest",
"yoi-manifest",
"ticket",
"yoi-ticket",
];
for name in forbidden {
assert!(
!dependencies.contains_key(name),
"PDK must not depend on host/runtime crate `{name}`"
);
}
}

View File

@ -0,0 +1,25 @@
use yoi_plugin_pdk::wit_bindgen;
wit_bindgen::generate!({
world: "tool",
path: "../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
struct Probe;
impl Guest for Probe {
fn call(tool_name: String, input_json: String) -> String {
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| {
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
})
}
}
#[test]
fn wit_bindgen_generates_current_tool_world() {
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string());
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "probe ok");
}

View File

@ -44,6 +44,7 @@ dotenv = "0.15.0"
futures = { workspace = true } futures = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
wat = "1.241.2" wat = "1.241.2"
yoi-plugin-pdk = { workspace = true }
[build-dependencies] [build-dependencies]
toml = { workspace = true } toml = { workspace = true }

View File

@ -3868,6 +3868,18 @@ mod tests {
); );
} }
#[test]
fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() {
let pdk_output =
yoi_plugin_pdk::ToolOutput::json("pdk ok", serde_json::json!({"answer": 42}))
.unwrap()
.to_json_string();
let output = decode_plugin_wasm_output(pdk_output.as_bytes()).unwrap();
assert_eq!(output.summary, "pdk ok");
assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#));
}
#[tokio::test] #[tokio::test]
async fn malformed_input_json_fails_before_wasm_execution() { async fn malformed_input_json_fails_before_wasm_execution() {
let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module()); let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module());

View File

@ -776,6 +776,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
}), }),
feature: manifest.feature.clone().into(), feature: manifest.feature.clone().into(),
plugins: manifest.plugins.clone(), plugins: manifest.plugins.clone(),
mcp: manifest.mcp.clone(),
compaction: manifest compaction: manifest
.compaction .compaction
.as_ref() .as_ref()

View File

@ -1650,11 +1650,15 @@ impl TicketBackend for LocalTicketBackend {
let item = dir.join("item.md"); let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
let blockers = self.relation_blockers_for_meta(&meta)?; let blockers = self.relation_blockers_for_meta(&meta)?;
if !blockers.is_empty() { let active_blockers = blockers
.into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
return Err(TicketError::Conflict(format!( return Err(TicketError::Conflict(format!(
"ticket {} has unresolved blocking relation(s): {}", "ticket {} has unresolved blocking relation(s): {}",
meta.id, meta.id,
format_relation_blockers(&blockers) format_relation_blockers(&active_blockers)
))); )));
} }
let at = now_utc(); let at = now_utc();
@ -2550,6 +2554,13 @@ fn relation_view_from_records(
view view
} }
fn relation_blocker_allows_queue(blocker: &TicketRelationBlocker) -> bool {
matches!(
blocker.blocking_state,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
)
}
fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String { fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String {
blockers blockers
.iter() .iter()
@ -4408,6 +4419,65 @@ state: planning
assert_eq!(backend.doctor().unwrap().error_count(), 0); assert_eq!(backend.doctor().unwrap().error_count(), 0);
} }
#[test]
fn queue_gate_allows_ready_ticket_when_blocking_relation_is_already_queued_or_inprogress() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut waiter_input = NewTicket::new("Ready After Queued Dependency");
waiter_input.workflow_state = Some(TicketWorkflowState::Ready);
let waiter = backend.create(waiter_input).unwrap();
let mut dependency_input = NewTicket::new("Queued Dependency");
dependency_input.workflow_state = Some(TicketWorkflowState::Queued);
let dependency = backend.create(dependency_input).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(waiter.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: Some("test".to_string()),
},
)
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(waiter.id.clone()), "test")
.unwrap();
let queued = backend.show(TicketIdOrSlug::Id(waiter.id.clone())).unwrap();
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
assert_eq!(queued.meta.queued_by.as_deref(), Some("test"));
let mut incoming_input = NewTicket::new("Ready After Inprogress Blocker");
incoming_input.workflow_state = Some(TicketWorkflowState::Ready);
let incoming = backend.create(incoming_input).unwrap();
let mut blocker_input = NewTicket::new("Inprogress Blocker");
blocker_input.workflow_state = Some(TicketWorkflowState::InProgress);
let blocker = backend.create(blocker_input).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(blocker.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::Blocks,
target: incoming.id.clone(),
note: None,
author: Some("test".to_string()),
},
)
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(incoming.id.clone()), "test")
.unwrap();
let queued_incoming = backend
.show(TicketIdOrSlug::Id(incoming.id.clone()))
.unwrap();
assert_eq!(
queued_incoming.meta.workflow_state,
TicketWorkflowState::Queued
);
}
#[test] #[test]
fn queue_gate_rejects_unresolved_dependency_and_incoming_blocker() { fn queue_gate_rejects_unresolved_dependency_and_incoming_blocker() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();

View File

@ -429,10 +429,58 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> { fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(
"yoi plugin requires `list` or `show <ref>`".to_string(), "yoi plugin requires `new`, `check`, `pack`, `list`, or `show <ref>`".to_string(),
)); ));
}; };
match subcommand.as_str() { match subcommand.as_str() {
"new" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
match positional.as_slice() {
[template, destination] => Ok(plugin_cli::PluginCliCommand::New {
template: template.clone(),
destination: PathBuf::from(destination),
args: plugin_args,
}),
[] | [_] => Err(ParseError(
"yoi plugin new requires a template and destination".to_string(),
)),
_ => Err(ParseError(
"yoi plugin new accepts exactly a template and destination".to_string(),
)),
}
}
"check" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
match positional.as_slice() {
[input] => Ok(plugin_cli::PluginCliCommand::Check {
input: PathBuf::from(input),
args: plugin_args,
}),
[] => Err(ParseError(
"yoi plugin check requires a plugin directory or .yoi-plugin path".to_string(),
)),
_ => Err(ParseError(
"yoi plugin check accepts exactly one plugin directory or .yoi-plugin path"
.to_string(),
)),
}
}
"pack" => {
let (plugin_args, positional, output) = parse_plugin_pack_args(rest)?;
match positional.as_slice() {
[input] => Ok(plugin_cli::PluginCliCommand::Pack {
input: PathBuf::from(input),
output,
args: plugin_args,
}),
[] => Err(ParseError(
"yoi plugin pack requires a plugin directory".to_string(),
)),
_ => Err(ParseError(
"yoi plugin pack accepts exactly one plugin directory".to_string(),
)),
}
}
"list" => { "list" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?; let (plugin_args, positional) = parse_plugin_common_args(rest)?;
if !positional.is_empty() { if !positional.is_empty() {
@ -513,8 +561,36 @@ fn parse_plugin_common_args(
Ok((parsed, positional)) Ok((parsed, positional))
} }
fn parse_plugin_pack_args(
args: &[String],
) -> Result<(plugin_cli::PluginCliArgs, Vec<String>, Option<PathBuf>), ParseError> {
let mut normalized = Vec::new();
let mut output = None;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if arg == "--output" {
index += 1;
let Some(value) = args.get(index) else {
return Err(ParseError("--output requires a value".to_string()));
};
output = Some(PathBuf::from(value));
} else if let Some(value) = arg.strip_prefix("--output=") {
if value.is_empty() {
return Err(ParseError("--output requires a value".to_string()));
}
output = Some(PathBuf::from(value));
} else {
normalized.push(arg.clone());
}
index += 1;
}
let (plugin_args, positional) = parse_plugin_common_args(&normalized)?;
Ok((plugin_args, positional, output))
}
fn plugin_usage() -> &'static str { fn plugin_usage() -> &'static str {
"usage: yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]" "usage: yoi plugin new rust-component-tool <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
} }
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> { fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
@ -547,7 +623,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n" "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
); );
} }

View File

@ -2,13 +2,17 @@ use std::collections::BTreeMap;
use std::error::Error; use std::error::Error;
use std::fmt::Write as _; use std::fmt::Write as _;
use std::fs; use std::fs;
use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use manifest::plugin::{ use manifest::plugin::{
PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiscoveryLimits, MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind,
PluginDiscoveryOptions, PluginDiscoveryReport, PluginPackageManifest, PluginPermission, PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport,
PluginResolution, PluginSourceKind, PluginSurface, ResolvedPlugin, ResolvedPluginRecord, PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission,
SourceQualifiedPluginId, discover_plugins, resolve_enabled_plugins, PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE,
ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins,
read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins,
write_plugin_package_file,
}; };
use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths}; use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths};
use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static}; use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static};
@ -35,17 +39,607 @@ pub(crate) enum PluginCliCommand {
reference: String, reference: String,
args: PluginCliArgs, args: PluginCliArgs,
}, },
New {
template: String,
destination: PathBuf,
args: PluginCliArgs,
},
Check {
input: PathBuf,
args: PluginCliArgs,
},
Pack {
input: PathBuf,
output: Option<PathBuf>,
args: PluginCliArgs,
},
} }
pub(crate) fn run(command: PluginCliCommand) -> Result<()> { pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
if let PluginCliCommand::Check { input, args } = command {
let report = build_check_report(&input);
let rendered = render_check_report(&report, &args)?;
print!("{rendered}");
if report.status == "rejected" {
return Err("plugin check failed; see diagnostics above".into());
}
return Ok(());
}
let rendered = match command { let rendered = match command {
PluginCliCommand::List(args) => render_list(&args)?, PluginCliCommand::List(args) => render_list(&args)?,
PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?, PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?,
PluginCliCommand::New {
template,
destination,
args,
} => render_new(&template, &destination, &args)?,
PluginCliCommand::Check { .. } => unreachable!("handled above"),
PluginCliCommand::Pack {
input,
output,
args,
} => render_pack(&input, output.as_deref(), &args)?,
}; };
print!("{rendered}"); print!("{rendered}");
Ok(()) Ok(())
} }
fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
if template != "rust-component-tool" {
return Err(format!(
"unsupported plugin template `{template}` (supported: rust-component-tool)"
)
.into());
}
materialize_template(destination)?;
let report = NewReport {
command: "new",
template: "rust-component-tool",
destination: destination.display().to_string(),
files: RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.map(|resource| resource.path.to_string())
.collect(),
safety: AuthoringSafetyReport::default(),
next_steps: vec![
"Review plugin.toml and generated Rust source.".to_string(),
"Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(),
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
],
};
if args.json {
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
}
render_new_human(&report)
}
fn materialize_template(destination: &Path) -> Result<()> {
match fs::symlink_metadata(destination) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(format!(
"plugin template destination `{}` is a symlink; refusing to follow it",
destination.display()
)
.into());
}
if !metadata.is_dir() {
return Err(format!(
"plugin template destination `{}` already exists and is not a directory",
destination.display()
)
.into());
}
if fs::read_dir(destination)?.next().is_some() {
return Err(format!(
"plugin template destination `{}` is not empty",
destination.display()
)
.into());
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir_all(destination)?;
}
Err(error) => return Err(error.into()),
}
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
let relative = safe_template_relative_path(resource.path)?;
let path = destination.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, resource.contents)?;
}
Ok(())
}
fn safe_template_relative_path(path: &str) -> Result<&Path> {
let relative = Path::new(path);
if relative.is_absolute()
|| relative
.components()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
return Err(format!("embedded plugin template path `{path}` is unsafe").into());
}
Ok(relative)
}
#[cfg(test)]
fn render_check(input: &Path, args: &PluginCliArgs) -> Result<String> {
let report = build_check_report(input);
render_check_report(&report, args)
}
fn render_check_report(report: &CheckReport, args: &PluginCliArgs) -> Result<String> {
if args.json {
return Ok(format!("{}\n", serde_json::to_string_pretty(report)?));
}
render_check_human(report)
}
fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Result<String> {
let limits = PluginDiscoveryLimits::default();
let materialized = read_plugin_directory(input, PluginSourceKind::Project, &limits)
.map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?;
let output_path = output
.map(Path::to_path_buf)
.unwrap_or_else(|| default_package_output_path(input));
let packed = write_plugin_package_file(&materialized, &output_path, &limits)
.map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?;
let report = PackReport {
command: "pack",
status: "packed",
input_path: input.display().to_string(),
output_path: packed.output_path.display().to_string(),
package: PackageReport::from_materialized(&MaterializedPluginPackage {
package: packed.package,
files: materialized.files,
}),
safety: AuthoringSafetyReport::default(),
};
if args.json {
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
}
render_pack_human(&report)
}
fn default_package_output_path(input: &Path) -> PathBuf {
let name = input
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("plugin");
input.with_file_name(format!("{name}.yoi-plugin"))
}
fn build_check_report(input: &Path) -> CheckReport {
let limits = PluginDiscoveryLimits::default();
let input_kind = if input.is_dir() {
"directory"
} else {
"package"
};
let result = if input.is_dir() {
read_plugin_directory(input, PluginSourceKind::Project, &limits)
} else {
read_plugin_package_file(input, PluginSourceKind::Project, &limits)
};
match result {
Ok(materialized) => {
let static_inspection = inspect_materialized_package(&materialized);
let static_diagnostics = static_inspection_diagnostics(&static_inspection);
let placeholder_diagnostic = placeholder_component_diagnostic(&materialized);
let status = if !static_diagnostics.is_empty() {
"rejected"
} else if placeholder_diagnostic.is_some() {
"partial"
} else {
"active"
};
let mut diagnostics = static_diagnostics;
diagnostics.extend(placeholder_diagnostic);
let reference = package_reference(&materialized.package.identity);
CheckReport {
command: "check",
status,
input_path: input.display().to_string(),
input_kind,
package: Some(PackageReport::from_materialized(&materialized)),
diagnostics,
static_inspection: Some(StaticInspectionReport::from_inspection(
&static_inspection,
)),
safety: AuthoringSafetyReport::default(),
next_steps: check_next_steps(status, &reference),
}
}
Err(diagnostic) => CheckReport {
command: "check",
status: "rejected",
input_path: input.display().to_string(),
input_kind,
package: None,
diagnostics: vec![PluginDiagnosticReport::from_diagnostic(&diagnostic)],
static_inspection: None,
safety: AuthoringSafetyReport::default(),
next_steps: vec![
"Fix the reported package diagnostic and run `yoi plugin check` again.".to_string(),
],
},
}
}
fn inspect_materialized_package(
materialized: &MaterializedPluginPackage,
) -> PluginStaticInspection {
let requested_permissions = materialized.package.manifest.permissions.clone();
let record = ResolvedPluginRecord {
identity: materialized.package.identity.clone(),
source: materialized.package.identity.source,
package_path: materialized.package.package_path.clone(),
package_label: materialized.package.package_label.clone(),
digest: materialized.package.digest.clone(),
version: materialized.package.manifest.version.clone(),
manifest: materialized.package.manifest.clone(),
enabled_surfaces: materialized.package.manifest.surfaces.clone(),
grants: PluginGrantConfig {
id: Some(materialized.package.identity.to_string()),
version: Some(PluginExactVersion(
materialized.package.manifest.version.clone(),
)),
digest: Some(materialized.package.digest.clone()),
permissions: requested_permissions,
https: Vec::new(),
fs: Vec::new(),
},
config: None,
};
inspect_resolved_plugin_static(&record)
}
fn static_inspection_diagnostics(
inspection: &PluginStaticInspection,
) -> Vec<PluginDiagnosticReport> {
let mut diagnostics = Vec::new();
if let Some(message) = &inspection.runtime.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "malformed".to_string(),
phase: "resolution".to_string(),
message: bound_text(message.clone()),
});
}
for api in &inspection.host_apis {
if let Some(message) = &api.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "grant".to_string(),
phase: "resolution".to_string(),
message: bound_text(message.clone()),
});
}
}
for tool in &inspection.tools {
if let Some(message) = &tool.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "malformed".to_string(),
phase: "resolution".to_string(),
message: bound_text(message.clone()),
});
}
}
diagnostics
}
fn placeholder_component_diagnostic(
materialized: &MaterializedPluginPackage,
) -> Option<PluginDiagnosticReport> {
let runtime = materialized.package.manifest.runtime.as_ref()?;
let component = runtime.component.as_deref()?;
let component_bytes = materialized.files.get(component)?;
let placeholder_bytes = RUST_COMPONENT_TOOL_TEMPLATE
.iter()
.find(|resource| resource.path == "plugin.component.wasm")?
.contents
.as_bytes();
if component_bytes != placeholder_bytes {
return None;
}
Some(PluginDiagnosticReport {
kind: "placeholder".to_string(),
phase: "runtime".to_string(),
message: format!(
"plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before enabling or execution"
),
})
}
fn check_next_steps(status: &str, reference: &str) -> Vec<String> {
match status {
"active" => vec![
"Package metadata is valid without executing Plugin code.".to_string(),
format!(
"To enable after review, add an explicit plugin enablement entry for `{reference}` with matching digest and grants."
),
"Run `yoi plugin pack <path>` to create a deterministic .yoi-plugin archive."
.to_string(),
],
"partial" => vec![
"Replace the generated placeholder component artifact with a real built component."
.to_string(),
"Run `yoi plugin check <path>` again before enabling or execution.".to_string(),
"Do not enable this Plugin while check status is partial.".to_string(),
],
_ => {
vec!["Fix the reported diagnostics before enabling or packing this Plugin.".to_string()]
}
}
}
fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String {
format!("{context} failed: {}", diagnostic.message)
}
fn render_new_human(report: &NewReport) -> Result<String> {
let mut out = String::new();
writeln!(
out,
"created plugin template `{}` at {}",
report.template, report.destination
)?;
writeln!(out, "files:")?;
for file in &report.files {
writeln!(out, " - {file}")?;
}
writeln!(
out,
"safety: no network; embedded template only; no secrets generated"
)?;
writeln!(out, "next steps:")?;
for step in &report.next_steps {
writeln!(out, " - {step}")?;
}
Ok(out)
}
fn render_check_human(report: &CheckReport) -> Result<String> {
let mut out = String::new();
writeln!(
out,
"plugin check: {} [{}] input_kind={}",
report.input_path, report.status, report.input_kind
)?;
if let Some(package) = &report.package {
writeln!(
out,
"package: {} version={} digest={} entries={} source={} surfaces={} tools={}",
package.reference,
package.version,
package.digest,
package.entries.len(),
package.source,
join_or_none(&package.surfaces),
package.tools.len()
)?;
match report.status {
"active" => writeln!(
out,
"enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config",
package.reference, package.digest
)?,
"partial" => writeln!(
out,
"enablement guidance: not ready to enable; replace the generated placeholder component and rerun check; this command does not mutate config"
)?,
_ => writeln!(
out,
"enablement guidance: not ready to enable; fix diagnostics first; this command does not mutate config"
)?,
}
}
if report.diagnostics.is_empty() {
writeln!(out, "diagnostics: none")?;
} else {
writeln!(out, "diagnostics:")?;
for diagnostic in &report.diagnostics {
writeln!(
out,
" - kind={} phase={} message={}",
diagnostic.kind, diagnostic.phase, diagnostic.message
)?;
}
}
writeln!(
out,
"safety: no Plugin execution; no enablement config mutation; no secrets generated"
)?;
writeln!(out, "next steps:")?;
for step in &report.next_steps {
writeln!(out, " - {step}")?;
}
Ok(out)
}
fn render_pack_human(report: &PackReport) -> Result<String> {
let mut out = String::new();
writeln!(
out,
"plugin pack: {} [{}]",
report.output_path, report.status
)?;
writeln!(
out,
"package: {} version={} digest={} entries={}",
report.package.reference,
report.package.version,
report.package.digest,
report.package.entries.len()
)?;
writeln!(
out,
"safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation"
)?;
Ok(out)
}
#[derive(Serialize)]
struct AuthoringSafetyReport {
no_network: bool,
no_plugin_execution: bool,
no_enablement_config_mutation: bool,
no_secrets_generated: bool,
}
impl Default for AuthoringSafetyReport {
fn default() -> Self {
Self {
no_network: true,
no_plugin_execution: true,
no_enablement_config_mutation: true,
no_secrets_generated: true,
}
}
}
#[derive(Serialize)]
struct NewReport {
command: &'static str,
template: &'static str,
destination: String,
files: Vec<String>,
safety: AuthoringSafetyReport,
next_steps: Vec<String>,
}
#[derive(Serialize)]
struct CheckReport {
command: &'static str,
status: &'static str,
input_path: String,
input_kind: &'static str,
package: Option<PackageReport>,
diagnostics: Vec<PluginDiagnosticReport>,
static_inspection: Option<StaticInspectionReport>,
safety: AuthoringSafetyReport,
next_steps: Vec<String>,
}
#[derive(Serialize)]
struct PackReport {
command: &'static str,
status: &'static str,
input_path: String,
output_path: String,
package: PackageReport,
safety: AuthoringSafetyReport,
}
#[derive(Serialize)]
struct PackageReport {
reference: String,
package: String,
source: String,
version: String,
schema_version: u32,
digest: String,
package_path: String,
entries: Vec<String>,
surfaces: Vec<String>,
tools: Vec<String>,
permissions: Vec<String>,
}
impl PackageReport {
fn from_materialized(materialized: &MaterializedPluginPackage) -> Self {
Self {
reference: package_reference(&materialized.package.identity),
package: materialized.package.manifest.id.clone(),
source: materialized.package.identity.source.to_string(),
version: materialized.package.manifest.version.clone(),
schema_version: materialized.package.manifest.schema_version,
digest: materialized.package.digest.clone(),
package_path: materialized.package.package_path.display().to_string(),
entries: materialized.package.entries.iter().cloned().collect(),
surfaces: materialized
.package
.manifest
.surfaces
.iter()
.map(ToString::to_string)
.collect(),
tools: materialized
.package
.manifest
.tools
.iter()
.map(|tool| tool.name.clone())
.collect(),
permissions: materialized
.package
.manifest
.permissions
.iter()
.map(|permission| permission_name(permission.clone()).to_string())
.collect(),
}
}
}
#[derive(Serialize)]
struct PluginDiagnosticReport {
kind: String,
phase: String,
message: String,
}
impl PluginDiagnosticReport {
fn from_diagnostic(diagnostic: &PluginDiagnostic) -> Self {
Self {
kind: diagnostic_kind(&diagnostic.kind).to_string(),
phase: diagnostic_phase(&diagnostic.phase).to_string(),
message: bound_text(diagnostic.message.clone()),
}
}
}
fn diagnostic_phase(phase: &PluginDiagnosticPhase) -> &'static str {
match phase {
PluginDiagnosticPhase::Discovery => "discovery",
PluginDiagnosticPhase::Manifest => "manifest",
PluginDiagnosticPhase::Resolution => "resolution",
}
}
fn package_reference(identity: &SourceQualifiedPluginId) -> String {
identity.to_string()
}
fn permission_name(permission: PluginPermission) -> String {
permission.label()
}
#[derive(Serialize)]
struct StaticInspectionReport {
status: String,
diagnostics: usize,
}
impl StaticInspectionReport {
fn from_inspection(inspection: &PluginStaticInspection) -> Self {
let diagnostics = static_inspection_diagnostics(inspection).len();
let status = if diagnostics == 0 {
"active"
} else {
"rejected"
};
Self {
status: status.to_string(),
diagnostics,
}
}
}
fn render_list(args: &PluginCliArgs) -> Result<String> { fn render_list(args: &PluginCliArgs) -> Result<String> {
let snapshot = build_snapshot(args)?; let snapshot = build_snapshot(args)?;
if args.json { if args.json {
@ -1254,6 +1848,222 @@ mod tests {
assert!(show_output.contains("eligible=false")); assert!(show_output.contains("eligible=false"));
} }
#[test]
fn plugin_new_creates_template_files_and_refuses_non_empty_destination() {
let dir = tempdir().unwrap();
let destination = dir.path().join("my-plugin");
let output = render_new(
"rust-component-tool",
&destination,
&PluginCliArgs::default(),
)
.unwrap();
assert!(output.contains("created plugin template"));
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
assert!(
destination.join(resource.path).is_file(),
"missing {}",
resource.path
);
}
let check_json = render_check(
&destination,
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let check_value: serde_json::Value = serde_json::from_str(&check_json).unwrap();
assert_eq!(check_value["status"], "partial");
assert_eq!(check_value["diagnostics"][0]["kind"], "placeholder");
assert!(
check_value["diagnostics"][0]["message"]
.as_str()
.unwrap()
.contains("generated placeholder")
);
assert!(
check_value["next_steps"]
.as_array()
.unwrap()
.iter()
.any(|step| step.as_str().unwrap_or_default().contains("Do not enable"))
);
let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap();
assert!(human_check.contains("[partial]"));
assert!(human_check.contains("not ready to enable"));
let error = render_new(
"rust-component-tool",
&destination,
&PluginCliArgs::default(),
)
.unwrap_err()
.to_string();
assert!(error.contains("not empty"));
}
#[cfg(unix)]
#[test]
fn plugin_new_refuses_symlink_destination_without_following_it() {
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::create_dir_all(&target).unwrap();
let link = dir.path().join("linkdest");
std::os::unix::fs::symlink(&target, &link).unwrap();
let error = render_new("rust-component-tool", &link, &PluginCliArgs::default())
.unwrap_err()
.to_string();
assert!(error.contains("symlink"));
assert!(!target.join("plugin.toml").exists());
}
#[test]
fn plugin_check_accepts_valid_directory_and_reports_json_shape() {
let dir = tempdir().unwrap();
let plugin = dir.path().join("plugin");
fs::create_dir_all(&plugin).unwrap();
fs::write(
plugin.join("plugin.toml"),
plugin_manifest("echo", "echo", "object", &["echo"]),
)
.unwrap();
fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap();
let human = render_check(&plugin, &PluginCliArgs::default()).unwrap();
assert!(human.contains("[active]"));
assert!(human.contains("digest="));
assert!(human.contains("does not mutate config"));
let json = render_check(
&plugin,
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["command"], "check");
assert_eq!(value["status"], "active");
assert_eq!(value["input_kind"], "directory");
assert_eq!(value["package"]["reference"], "project:echo");
assert_eq!(value["safety"]["no_plugin_execution"], true);
}
#[test]
fn plugin_check_rejects_invalid_manifest_and_missing_runtime_artifact() {
let dir = tempdir().unwrap();
let invalid = dir.path().join("invalid");
fs::create_dir_all(&invalid).unwrap();
fs::write(
invalid.join("plugin.toml"),
"schema_version = 1\nid = [\"bad\"]\n",
)
.unwrap();
let invalid_json = render_check(
&invalid,
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let invalid_value: serde_json::Value = serde_json::from_str(&invalid_json).unwrap();
assert_eq!(invalid_value["status"], "rejected");
assert_eq!(invalid_value["diagnostics"][0]["phase"], "manifest");
let missing = dir.path().join("missing-runtime");
fs::create_dir_all(&missing).unwrap();
fs::write(
missing.join("plugin.toml"),
plugin_manifest_missing_runtime_entry("missing_runtime"),
)
.unwrap();
let missing_output = render_check(&missing, &PluginCliArgs::default()).unwrap();
assert!(missing_output.contains("rejected"));
assert!(missing_output.contains("path not present"));
}
#[test]
fn plugin_check_rejects_unsafe_package_archive() {
let dir = tempdir().unwrap();
let package = dir.path().join("unsafe.yoi-plugin");
write_stored_zip(
&package,
&[
(
"plugin.toml",
plugin_manifest("unsafe", "Echo", "object", &["Echo"]).as_bytes(),
),
("../escape.wasm", b"not wasm"),
],
);
let output = render_check(&package, &PluginCliArgs::default()).unwrap();
assert!(output.contains("rejected"));
assert!(output.contains("escapes"));
}
#[test]
fn plugin_pack_is_deterministic_and_discoverable() {
let dir = tempdir().unwrap();
let plugin = dir.path().join("plugin");
fs::create_dir_all(&plugin).unwrap();
fs::write(
plugin.join("plugin.toml"),
plugin_manifest("echo", "echo", "object", &["echo"]),
)
.unwrap();
fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap();
let first = dir.path().join("first.yoi-plugin");
let second = dir.path().join("second.yoi-plugin");
let first_json = render_pack(
&plugin,
Some(&first),
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
let second_json = render_pack(
&plugin,
Some(&second),
&PluginCliArgs {
json: true,
..PluginCliArgs::default()
},
)
.unwrap();
assert_eq!(fs::read(&first).unwrap(), fs::read(&second).unwrap());
let first_value: serde_json::Value = serde_json::from_str(&first_json).unwrap();
let second_value: serde_json::Value = serde_json::from_str(&second_json).unwrap();
assert_eq!(first_value["command"], "pack");
assert_eq!(first_value["status"], "packed");
assert_eq!(
first_value["package"]["digest"],
second_value["package"]["digest"]
);
let workspace = dir.path().join("workspace");
fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap();
fs::copy(&first, workspace.join(".yoi/plugins/echo.yoi-plugin")).unwrap();
let discovery = discover_plugins(&PluginDiscoveryOptions {
workspace_root: workspace,
user_data_home: None,
limits: PluginDiscoveryLimits::default(),
});
assert_eq!(discovery.packages.len(), 1);
assert_eq!(discovery.packages[0].identity.to_string(), "project:echo");
}
#[test] #[test]
fn ambiguous_ref_is_bounded_error() { fn ambiguous_ref_is_bounded_error() {
let snapshot = PluginInspectionSnapshot { let snapshot = PluginInspectionSnapshot {

View File

@ -12,9 +12,10 @@ It is not a dumping ground for external research, old plans, API inventories, or
4. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. 4. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. 5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. 6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
7. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records. 7. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
8. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 8. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records.
9. [`development/validation.md`](development/validation.md) — how to check changes. 9. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
10. [`development/validation.md`](development/validation.md) — how to check changes.
## What belongs here ## What belongs here

View File

@ -100,7 +100,7 @@ The migration should be phased:
2. Add manifest/schema support for `runtime.kind = "wasm-component"` without executing it during discovery. 2. Add manifest/schema support for `runtime.kind = "wasm-component"` without executing it during discovery.
3. Add a component runtime backend and typed host import/export binding. 3. Add a component runtime backend and typed host import/export binding.
4. Port `https` and `fs` host API designs to WIT-compatible interfaces. 4. Port `https` and `fs` host API designs to WIT-compatible interfaces.
5. Add a Rust authoring SDK/template around the component world. 5. Add a Rust PDK/template around the component world.
6. Decide whether the raw ABI remains supported, becomes legacy-only, or is deprecated after examples and tests move. 6. Decide whether the raw ABI remains supported, becomes legacy-only, or is deprecated after examples and tests move.
## Runtime/backend caution ## Runtime/backend caution
@ -161,10 +161,14 @@ Wrong `world`, missing artifact metadata, missing `call` export, unsupported
imports, or core-Wasm bytes in a component package all fail closed with bounded imports, or core-Wasm bytes in a component package all fail closed with bounded
Plugin diagnostics or ordinary Tool errors. Plugin diagnostics or ordinary Tool errors.
See `docs/examples/plugin-component-tool/lib.rs` for a minimal See `docs/examples/plugin-component-tool/lib.rs` and the embedded
`wit-bindgen`/SDK-style authoring sketch. Package authors should generate `resources/plugin/templates/rust-component-tool/` starter for the preferred
bindings from `resources/plugin/wit`, build a component artifact, and set the Rust PDK authoring path. `yoi-plugin-pdk` is guest-side only: it re-exports
component runtime metadata above. `wit-bindgen`, provides typed JSON input/output helpers, renders bounded
`ToolError` values as ordinary ToolOutput JSON, and does not depend on host
runtime crates or grant authority. Package authors should generate bindings from
`resources/plugin/wit`, build a component artifact, and set the component
runtime metadata above.
### v1 request/response shape ### v1 request/response shape

View File

@ -52,7 +52,7 @@ entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1" abi = "yoi-plugin-wasm-1"
``` ```
The preferred future WASM authoring/runtime shape is the WebAssembly Component Model, recorded in [Plugin Component Model migration](plugin-component-model.md). Component packages should be explicit and source-compatible rather than silently changing the existing raw core-Wasm runtime: The preferred WASM authoring/runtime shape is the WebAssembly Component Model, recorded in [Plugin Component Model migration](plugin-component-model.md). Component packages should be explicit and source-compatible rather than silently changing the existing raw core-Wasm runtime:
```toml ```toml
[runtime] [runtime]
@ -216,6 +216,13 @@ component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0" world = "yoi:plugin/tool@1.0.0"
``` ```
For new Rust Tool packages, the preferred authoring path is the first-party
`yoi-plugin-pdk` plus the embedded `resources/plugin/templates/rust-component-tool/`
starter. The template uses a checkout-local path dependency for development and
documents a future out-of-tree pinned git `rev` dependency pattern. Crates.io
publication, remote template fetching, and package authoring commands are not
part of the current package/runtime contract.
This is separate from the legacy raw core-Wasm runtime: This is separate from the legacy raw core-Wasm runtime:
```toml ```toml

View File

@ -29,6 +29,37 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `pod.name` because Pod identity remains a runtime input. For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `pod.name` because Pod identity remains a runtime input.
## Local stdio MCP server declarations
Profiles and manifest layers may declare named local stdio MCP servers under `mcp.stdio_server`. This is a typed configuration surface only. Declaring a server does not start a subprocess, discover packages, negotiate MCP capabilities, or register tools/resources/prompts.
Example Lua Profile fragment:
```lua
mcp = {
stdio_server = {
{
name = "filesystem",
command = "node",
args = { "server.js", "--root", "." },
cwd = { kind = "path", path = "./mcp" },
env = {
inherit = { "PATH" },
set = {
SAFE_MODE = { kind = "literal", value = "1" },
API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" },
UPSTREAM_TOKEN = { kind = "env_ref", name = "MCP_UPSTREAM_TOKEN" },
},
},
},
},
}
```
`command` is a direct executable name/path, not a shell string. `args` are passed as argv entries by future lifecycle code. `cwd.kind = "path"` is resolved relative to the Profile or manifest layer; omit `cwd` or use `{ kind = "inherit" }` when the lifecycle caller should choose. Environment handling is explicit: future spawn code should inherit only names listed in `env.inherit` and set only variables in `env.set`. `literal` values are for non-secret data; credentials should use `secret_ref` or explicit `env_ref`. Diagnostics and Debug output must redact env literal values and must not print secret plaintext.
Local stdio MCP servers are ordinary local executables running with the user's OS permissions. Yoi's feature flags, Plugin permissions, and MCP config validation are not an operating-system sandbox and cannot prevent filesystem/network/process side effects once a later lifecycle implementation chooses to spawn a configured server.
## Spawned Pods ## Spawned Pods
`SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope. `SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope.

View File

@ -0,0 +1,341 @@
# Plugin development
This guide is for building a Yoi Plugin outside the Yoi runtime codebase. It describes the current Plugin package shape, how to author a Tool Plugin, how to enable it in a workspace, and how to inspect/debug it.
Yoi Plugins are intentionally explicit:
- putting a package in `.yoi/plugins` only makes it discoverable;
- a Profile/config entry must explicitly enable it;
- Plugin grants must allow its surfaces and host APIs;
- Plugin code runs only through the configured sandbox runtime;
- Tool calls and Tool results use the ordinary Yoi Tool/Worker history path.
## Current status
Implemented foundation:
- package discovery from project/user Plugin stores;
- explicit enablement resolution;
- Tool surface registration;
- Plugin permission grants;
- raw core-Wasm Tool runtime;
- Component Model Tool runtime;
- first-party Rust PDK helpers for Component Model Tool guests;
- embedded Rust Component Tool starter template;
- `https` and `fs` host APIs for Tool runtime;
- read-only `yoi plugin list/show` inspection;
- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`.
Still intentionally separate/future work:
- multi-language SDK/PDK crates;
- Service / Ingress surfaces;
- WebSocket or inbound HTTP for bidirectional bridges;
- public registry/install/update/signature tooling.
## Package locations
Yoi discovers `.yoi-plugin` packages from:
```text
<workspace>/.yoi/plugins/*.yoi-plugin
${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/*.yoi-plugin
```
Use project packages for workspace-specific Plugins and user packages for personal reusable Plugins. Project packages should normally be committed only when the package content is safe and intended to be part of the project.
## Package archive format
A `.yoi-plugin` package is currently a bounded ZIP archive. For now, create it with stored entries, not compressed entries:
```bash
(cd my-plugin && zip -0 -r ../example.echo.yoi-plugin plugin.toml plugin.component.wasm)
```
The archive root must contain `plugin.toml`. Runtime files referenced by the manifest must also be inside the archive. Yoi rejects path traversal, root escapes, malformed manifests, unsupported API/runtime versions, and other unsafe archive shapes.
## Authoring CLI
Use the local authoring commands for first-party deterministic authoring. These commands never fetch remote templates, never run Plugin code, never mutate enablement configuration, and never generate or embed secrets.
Create a Rust Component Tool starter from embedded resources:
```bash
yoi plugin new rust-component-tool ./my-plugin
```
`new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin.
Validate a source directory or an existing `.yoi-plugin` archive:
```bash
yoi plugin check ./my-plugin
yoi plugin check ./my-plugin --json
yoi plugin check ./my-plugin.yoi-plugin --json
```
`check` performs bounded static validation of the directory/archive shape, manifest, runtime declaration, referenced artifact presence, Tool schemas, permission declarations, host API declarations, archive safety, and deterministic digest when a package can be materialized. Component-world validation is metadata-only: it verifies the declared world string and runtime manifest shape, but it does not instantiate or execute the component. A generated placeholder component produces `status = "partial"` plus a diagnostic and is not enablement-ready until replaced. Invalid checks print the same structured report and exit non-zero.
Pack a source directory into a deterministic stored `.yoi-plugin` archive:
```bash
yoi plugin pack ./my-plugin
yoi plugin pack ./my-plugin --output ./my-plugin.yoi-plugin --json
```
`pack` rejects malformed manifests, missing runtime artifacts, symlinks/root escapes, and unsupported package shapes. The JSON output contains the stable package reference, output path, digest, entries, and safety flags. After review, copy the package to `.yoi/plugins/` (or the user Plugin store) and add explicit Profile/config enablement with pinned digest and grants; packing and checking do not do this for you.
## Manifest: `plugin.toml`
A minimal Component Model Tool Plugin manifest looks like this:
```toml
schema_version = 1
id = "example.echo"
name = "Example Echo"
version = "0.1.0"
surfaces = ["tool"]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_echo" },
]
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"
[[tools]]
name = "example_echo"
description = "Echo input text."
input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false }
external_write = false
```
The preferred new runtime is `wasm-component`. The older raw core-Wasm runtime remains explicit for compatibility:
```toml
[runtime]
kind = "wasm"
entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1"
```
Do not rely on package presence to activate anything. Discovery only records inventory.
## Component Model + Rust PDK authoring
Component Model authoring with `yoi-plugin-pdk` is the preferred path for new Tool Plugins. The raw core-Wasm ABI remains available only as compatibility/transitional runtime support.
Yoi's Component Model Tool world is stored in `resources/plugin/wit/`. The embedded Rust starter template is available as data in the Yoi source tree at:
```text
resources/plugin/templates/rust-component-tool/
```
It contains:
- `Cargo.toml` with a checkout-local `yoi-plugin-pdk` path dependency;
- `src/lib.rs` with WIT binding generation and typed JSON Tool handling;
- `plugin.toml` targeting `kind = "wasm-component"` and `world = "yoi:plugin/tool@1.0.0"`;
- README next steps and the future out-of-tree pinned git `rev` dependency pattern.
A minimal PDK-backed Rust sketch is also available at:
```text
docs/examples/plugin-component-tool/lib.rs
```
The important authoring shape is:
```rust
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({
world: "tool",
path: "../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);
```
`run_json_tool` parses the WIT `input-json` string into a typed input, passes a `ToolContext` containing the selected Tool name, and serializes `ToolOutput` JSON accepted by the current component runtime. `ToolError` values are structured and bounded, then rendered through the ordinary Tool result path; the component cannot inject hidden context.
The PDK is guest-side only. It does not depend on Yoi host/runtime crates and does not grant filesystem, network, or environment authority. Host-side Plugin manifests and explicit enablement grants remain the authority boundary for Tool execution and for WIT host APIs such as `yoi:host/https` and `yoi:host/fs`.
The exact component build pipeline depends on the authoring toolchain (`wit-bindgen`, component adapter tooling, etc.). Crates.io publication, remote template fetching, and `yoi plugin new/check/pack` are intentionally deferred. Until they exist, Plugin authors should treat the template/example as the ABI contract sketch and use `yoi plugin list/show` plus focused runtime tests to verify packages.
## Enabling a Plugin in a workspace
Enablement belongs in the resolved Profile/config path for the workspace. For local dogfooding or private experiments, use the ignored local overlay rather than committing secrets or local paths:
```toml
# .yoi/override.local.toml
[features]
plugins = true
[[plugins.enabled]]
id = "project:example.echo"
version = "0.1.0"
digest = "sha256:<digest from yoi plugin show/list>"
surfaces = ["tool"]
[plugins.enabled.grants]
id = "project:example.echo"
version = "0.1.0"
digest = "sha256:<same digest>"
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_echo" },
]
```
A source-qualified id is preferred:
```text
project:example.echo
user:example.echo
builtin:example.echo
```
Unqualified ids can be ambiguous and should fail closed when more than one source matches.
## Inspecting Plugins
Use the read-only CLI inspection commands first:
```bash
yoi plugin list
yoi plugin list --json
yoi plugin show project:example.echo
yoi plugin show project:example.echo --json
```
`list/show` must not execute Plugin code. They are intended to explain static state:
- discovered packages;
- enabled vs disabled packages;
- missing packages referenced by enablement;
- invalid manifests;
- digest/version/source mismatches;
- granted/denied permissions;
- Tool registration eligibility;
- runtime metadata.
Typical statuses:
```text
active enabled and statically valid for at least one surface/tool
disabled discovered but not explicitly enabled
missing enablement references a package that is not discovered
rejected invalid manifest, incompatible API, digest mismatch, grant denial, etc.
partial usable package with some rejected surfaces/tools
```
## `https` host API
The `https` host API is outbound-only and grant-gated. It is meant for Tool calls such as webhook posting or REST requests. It is not a WebSocket/Gateway or inbound HTTP bridge.
Manifest permissions should request `host_api.https` in addition to the Tool permissions. Enablement grants must then allow the API and constrain hosts/methods.
Example grant shape:
```toml
[plugins.enabled.grants]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "discord_post" },
{ kind = "host_api", api = "https" },
]
[[plugins.enabled.grants.https]]
host = "discord.com"
methods = ["POST"]
path_prefixes = ["/api/webhooks/"]
```
Yoi rejects `http://`, localhost/private/link-local targets, disallowed hosts/methods, oversize requests/responses, and missing grants. Credentials must come from explicit config/secret references, not ambient environment variables.
## `fs` host API
The `fs` host API is Plugin-scoped and grant-gated. Plugins do not inherit the Pod/workspace filesystem authority automatically.
Example grant shape:
```toml
[plugins.enabled.grants]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "read_notes" },
{ kind = "host_api", api = "fs" },
]
[[plugins.enabled.grants.fs]]
root = "/absolute/path/to/plugin-data"
operations = ["read", "list"]
```
Yoi normalizes paths, rejects `..` traversal, rejects symlink/root escapes, and applies read/write/list bounds. Diagnostics must not include file contents.
## Outbound vs bridge integrations
After `https`, an outbound Discord webhook Tool is feasible:
```text
Yoi Tool call -> Plugin Tool -> yoi:host/https -> Discord REST/webhook
```
A bidirectional Discord bridge is different. It needs a Service surface plus Ingress and either WebSocket/Gateway support or inbound HTTP interactions:
```text
Discord Gateway/Webhook -> Plugin Service/Ingress -> host routing policy -> notify/run/drop/diagnostic
```
Do not model bidirectional bridge work as an `https` Tool alone.
## Development checklist
1. Create a package directory with `plugin.toml` and the runtime artifact.
2. Build the Wasm/component artifact.
3. Package with stored ZIP entries as `.yoi-plugin`.
4. Put it under `.yoi/plugins/` or the user Plugin store.
5. Run `yoi plugin list` and `yoi plugin show <ref>`.
6. Add explicit enablement and grants.
7. Re-run `yoi plugin show <ref>` until status/diagnostics are correct.
8. Start Yoi with `features.plugins = true` in the resolved config/Profile.
9. Call the Tool and verify ordinary Tool result/history behavior.
## Safety rules for Plugin authors
- Do not assume ambient filesystem, network, or environment access.
- Do not put secrets in `plugin.toml` or package files.
- Request only the minimal host APIs and grants needed.
- Keep Tool output bounded and structured.
- Prefer Component Model authoring for new Plugins.
- Treat raw core-Wasm ABI support as transitional compatibility.

View File

@ -1,23 +1,38 @@
//! Minimal Component Model Tool plugin authoring sketch. //! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`.
//! //!
//! Build this as a `wasm32-unknown-unknown` cdylib with `wit-bindgen`-generated //! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling
//! exports and package the adapted component as `plugin.component.wasm`. //! and package the adapted component as `plugin.component.wasm`.
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({ wit_bindgen::generate!({
world: "tool", world: "tool",
path: "../../../resources/plugin/wit", path: "../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
}); });
struct Plugin; #[derive(Debug, Deserialize)]
struct EchoInput {
impl Guest for Plugin { text: String,
fn call(tool_name: String, input_json: String) -> String {
// Ordinary ToolOutput JSON. The runtime routes this through the normal
// Worker/Tool result path; no context is injected by the component.
format!(
r#"{{"summary":"component tool {tool_name}","content":"input was {input_json}"}}"#
)
}
} }
export!(Plugin); #[derive(Debug, Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-i4U7wXPoWIHA4EAJZva2HQXNN8P5+RhGVGNBAOZVGk0="; cargoHash = "sha256-EH4zdakrFxqVrgaNBx3dICN6KoLqskTEGYnU73XMVsU=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -0,0 +1,21 @@
[package]
name = "yoi-rust-component-tool-template"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
# Keep the embedded template checkable in-place without making it a member of
# Yoi's root workspace. A copied starter remains a normal standalone package.
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
# Future out-of-tree Plugin packages should pin the Yoi revision instead of
# relying on crates.io publication or remote template fetching, for example:
# yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-revision>" }

View File

@ -0,0 +1,37 @@
# Rust Component Tool Template
This is the embedded starter template for a Yoi Component Model Tool Plugin written with the first-party Rust PDK.
## What this template demonstrates
- `wasm-component` runtime targeting `yoi:plugin/tool@1.0.0`.
- Guest-side WIT binding generation through the PDK's `wit_bindgen` re-export.
- Typed JSON input parsing through `run_json_tool` via `export_component_tool!`.
- Typed JSON output serialization with `ToolOutput::json`.
- Structured, bounded `ToolError` output for user-visible Tool failures.
The PDK is guest-side only. It does not grant filesystem, network, or environment authority. Host-side Plugin manifests and grants remain the authority boundary for Tool execution and host APIs.
## Checkout/development dependency
Inside the Yoi checkout this template uses a local path dependency and declares an empty `[workspace]` so it can be checked in place without becoming a member of Yoi's root workspace:
```toml
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
```
If this template is copied elsewhere before crates.io publication exists, pin a Yoi source revision instead of fetching an unpinned remote template:
```toml
yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-revision>" }
```
`plugin.component.wasm` in the template is a text placeholder so `yoi plugin check` and `yoi plugin pack` can exercise deterministic local package validation immediately. Replace it with a real built component before enabling or executing the Plugin.
## Next steps
1. Replace package/plugin ids, names, descriptions, and Tool schema.
2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic.
3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment, replacing the placeholder `plugin.component.wasm`.
4. Run `yoi plugin check .` and `yoi plugin pack . --output ./my-plugin.yoi-plugin`.
5. Copy the package to a Plugin store and add explicit enablement with pinned digest/grants after review.

View File

@ -0,0 +1 @@
placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.

View File

@ -0,0 +1,20 @@
schema_version = 1
id = "example.rust_component_tool"
name = "Rust Component Tool Template"
version = "0.1.0"
surfaces = ["tool"]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_echo" },
]
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"
[[tools]]
name = "example_echo"
description = "Echo input text using the Rust PDK."
input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false }
external_write = false

View File

@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({
world: "tool",
path: "../../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[derive(Debug, Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Debug, Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
if input.text.trim().is_empty() {
return Err(ToolError::invalid_input("`text` must not be empty"));
}
ToolOutput::json(
format!("{} echoed text", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);

View File

@ -10,6 +10,6 @@ interface https {
/// Grant-bound filesystem host API. No ambient WASI filesystem is exposed. /// Grant-bound filesystem host API. No ambient WASI filesystem is exposed.
interface fs { interface fs {
read: func(request-json: string) -> string; read: func(request-json: string) -> string;
list: func(request-json: string) -> string; %list: func(request-json: string) -> string;
write: func(request-json: string) -> string; write: func(request-json: string) -> string;
} }