merge: integrate orchestration branch

This commit is contained in:
Keisuke Hirata 2026-06-20 17:00:26 +09:00
commit 5df7580a1e
No known key found for this signature in database
43 changed files with 4454 additions and 44 deletions

View File

@ -1,8 +1,8 @@
--- ---
title: 'Plugin: add authoring CLI new/check/pack' title: 'Plugin: add authoring CLI new/check/pack'
state: 'inprogress' state: 'closed'
created_at: '2026-06-20T04:16:14Z' created_at: '2026-06-20T04:16:14Z'
updated_at: '2026-06-20T05:55:31Z' updated_at: '2026-06-20T06:55:06Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check']

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

@ -124,3 +124,304 @@ Next action:
- Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。 - 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,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

@ -1,8 +1,8 @@
--- ---
title: 'MCP: add local stdio server config and trust policy' title: 'MCP: add local stdio server config and trust policy'
state: 'queued' state: 'closed'
created_at: '2026-06-20T05:30:04Z' created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T05:58:46Z' updated_at: '2026-06-20T07:28:55Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec']

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

@ -13,4 +13,295 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 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

@ -1,8 +1,8 @@
--- ---
title: 'MCP: implement stdio JSON-RPC lifecycle client' title: 'MCP: implement stdio JSON-RPC lifecycle client'
state: 'queued' state: 'closed'
created_at: '2026-06-20T05:30:04Z' created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T05:58:54Z' updated_at: '2026-06-20T07:59:30Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics']

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

@ -13,4 +13,388 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 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

@ -2,7 +2,7 @@
title: 'MCP: register server tools into ToolRegistry' title: 'MCP: register server tools into ToolRegistry'
state: 'queued' state: 'queued'
created_at: '2026-06-20T05:30:04Z' created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T05:58:58Z' updated_at: '2026-06-20T06:00:44Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['mcp', 'tools-list', 'tool-registry', 'schema', 'untrusted-metadata'] risk_flags: ['mcp', 'tools-list', 'tool-registry', 'schema', 'untrusted-metadata']

View File

@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 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

@ -2,7 +2,7 @@
title: 'MCP: execute tools/call through ordinary Tool path' title: 'MCP: execute tools/call through ordinary Tool path'
state: 'queued' state: 'queued'
created_at: '2026-06-20T05:30:04Z' created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T05:59:04Z' updated_at: '2026-06-20T06:00:44Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['mcp', 'tools-call', 'permission', 'history', 'bounded-output'] risk_flags: ['mcp', 'tools-call', 'permission', 'history', 'bounded-output']

View File

@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 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

@ -2,7 +2,7 @@
title: 'MCP: expose resources and prompts as explicit tool operations' title: 'MCP: expose resources and prompts as explicit tool operations'
state: 'queued' state: 'queued'
created_at: '2026-06-20T05:30:04Z' created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T05:58:57Z' updated_at: '2026-06-20T06:00:44Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['mcp', 'resources', 'prompts', 'prompt-context', 'history', 'untrusted-content'] risk_flags: ['mcp', 'resources', 'prompts', 'prompt-context', 'history', 'untrusted-content']

View File

@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 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

@ -2,7 +2,7 @@
title: 'MCP: handle list_changed notifications safely' title: 'MCP: handle list_changed notifications safely'
state: 'queued' state: 'queued'
created_at: '2026-06-20T05:30:04Z' created_at: '2026-06-20T05:30:04Z'
updated_at: '2026-06-20T05:59:05Z' updated_at: '2026-06-20T06:00:44Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['mcp', 'notifications', 'tool-schema', 'prompt-cache', 'refresh'] risk_flags: ['mcp', 'notifications', 'tool-schema', 'prompt-cache', 'refresh']

View File

@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。 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 する。
--- ---

14
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"

View File

@ -8,6 +8,7 @@ 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/plugin-pdk",
"crates/yoi", "crates/yoi",
@ -34,6 +35,7 @@ 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/plugin-pdk",
"crates/yoi", "crates/yoi",
@ -62,6 +64,7 @@ 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" }

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

@ -45,6 +45,12 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
"../../../resources/plugin/templates/rust-component-tool/plugin.toml" "../../../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 { PluginTemplateResource {
path: "README.md", path: "README.md",
contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"), contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"),
@ -518,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>,
@ -1362,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,
@ -1370,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(
@ -1388,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(
@ -1397,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(

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

@ -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

@ -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

@ -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

@ -23,11 +23,11 @@ Implemented foundation:
- first-party Rust PDK helpers for Component Model Tool guests; - first-party Rust PDK helpers for Component Model Tool guests;
- embedded Rust Component Tool starter template; - embedded Rust Component Tool starter template;
- `https` and `fs` host APIs for Tool runtime; - `https` and `fs` host APIs for Tool runtime;
- read-only `yoi plugin list/show` inspection. - 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: Still intentionally separate/future work:
- `yoi plugin new/check/pack` authoring commands;
- multi-language SDK/PDK crates; - multi-language SDK/PDK crates;
- Service / Ingress surfaces; - Service / Ingress surfaces;
- WebSocket or inbound HTTP for bidirectional bridges; - WebSocket or inbound HTTP for bidirectional bridges;
@ -54,6 +54,37 @@ A `.yoi-plugin` package is currently a bounded ZIP archive. For now, create it w
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. 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` ## Manifest: `plugin.toml`
A minimal Component Model Tool Plugin manifest looks like this: A minimal Component Model Tool Plugin manifest looks like this:

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-ci9h0U83YQQBeT3xlsGuKULnl1Aphgpg3pR4n0se16I="; 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

@ -26,14 +26,12 @@ If this template is copied elsewhere before crates.io publication exists, pin a
yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-revision>" } yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-revision>" }
``` ```
Crates.io publication, remote template fetching, and `yoi plugin new/check/pack` are intentionally deferred to later authoring-tooling work. `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 ## Next steps
1. Replace package/plugin ids, names, descriptions, and Tool schema. 1. Replace package/plugin ids, names, descriptions, and Tool schema.
2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic. 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. 3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment, replacing the placeholder `plugin.component.wasm`.
4. Package `plugin.toml` and `plugin.component.wasm` into a `.yoi-plugin` archive. 4. Run `yoi plugin check .` and `yoi plugin pack . --output ./my-plugin.yoi-plugin`.
5. Use `yoi plugin list` / `yoi plugin show` plus focused runtime tests to inspect and validate the package. 5. Copy the package to a Plugin store and add explicit enablement with pinned digest/grants after review.
The exact component build/pack command is not part of this template yet because deterministic `yoi plugin new/check/pack` authoring commands are a separate planned Ticket.

View File

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