merge: integrate orchestration branch
This commit is contained in:
commit
5df7580a1e
|
|
@ -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']
|
||||||
|
|
|
||||||
41
.yoi/tickets/00001KVHKWNQS/resolution.md
Normal file
41
.yoi/tickets/00001KVHKWNQS/resolution.md
Normal 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`
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
37
.yoi/tickets/00001KVHR3WRF/resolution.md
Normal file
37
.yoi/tickets/00001KVHR3WRF/resolution.md
Normal 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`
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
37
.yoi/tickets/00001KVHR3WRY/resolution.md
Normal file
37
.yoi/tickets/00001KVHR3WRY/resolution.md
Normal 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`
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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 する。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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 する。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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 する。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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
14
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(¢ral);
|
||||||
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
23
crates/mcp/Cargo.toml
Normal 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
7
crates/mcp/src/lib.rs
Normal 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
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
116
crates/mcp/tests/fixtures/mock_server.rs
vendored
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
crates/mcp/tests/stdio_lifecycle.rs
Normal file
122
crates/mcp/tests/stdio_lifecycle.rs
Normal 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()));
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.
|
||||||
Loading…
Reference in New Issue
Block a user