From e37a360d079b9a35306cb2270a13c4cfd1333e43 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:00:52 +0900 Subject: [PATCH 01/29] ticket: hold queued mcp chain for dependencies --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WRF/item.md | 2 +- .yoi/tickets/00001KVHR3WRF/thread.md | 19 +++++++++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WRY/item.md | 2 +- .yoi/tickets/00001KVHR3WRY/thread.md | 13 +++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WS6/item.md | 2 +- .yoi/tickets/00001KVHR3WS6/thread.md | 13 +++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WSD/item.md | 2 +- .yoi/tickets/00001KVHR3WSD/thread.md | 13 +++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WSN/item.md | 2 +- .yoi/tickets/00001KVHR3WSN/thread.md | 13 +++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WSW/item.md | 2 +- .yoi/tickets/00001KVHR3WSW/thread.md | 13 +++++++++++++ 18 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 .yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVHR3WS6/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVHR3WSD/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVHR3WSN/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KVHR3WSW/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..9d601353 --- /dev/null +++ b/.yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"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"} diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index 9aab83a7..b4d8d486 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -2,7 +2,7 @@ title: 'MCP: add local stdio server config and trust policy' state: 'queued' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T05:58:46Z' +updated_at: '2026-06-20T06:00:44Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index a9e48f3b..c1ad93f7 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -13,4 +13,23 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 する。 + --- diff --git a/.yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..da6a936c --- /dev/null +++ b/.yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"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"} diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index 7b1b803f..cf6c6091 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -2,7 +2,7 @@ title: 'MCP: implement stdio JSON-RPC lifecycle client' state: 'queued' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T05:58:54Z' +updated_at: '2026-06-20T06:00:44Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index 49ccf7ea..49b5e38c 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: blocked_pending_dependency + +Panel Queue により routing 対象として確認したが、`00001KVHR3WRY` は `00001KVHR3WRF` に `depends_on` している。`00001KVHR3WRF` はまだ queued で未実装のため、この Ticket は実装開始せず queued のまま保持する。 + +Next: +- `00001KVHR3WRF` が closed になった後、改めて reroute する。 + --- diff --git a/.yoi/tickets/00001KVHR3WS6/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WS6/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..d7f603ba --- /dev/null +++ b/.yoi/tickets/00001KVHR3WS6/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KVHR3WS6/item.md b/.yoi/tickets/00001KVHR3WS6/item.md index 7cb6d4b5..46d709eb 100644 --- a/.yoi/tickets/00001KVHR3WS6/item.md +++ b/.yoi/tickets/00001KVHR3WS6/item.md @@ -2,7 +2,7 @@ title: 'MCP: register server tools into ToolRegistry' state: 'queued' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T05:58:58Z' +updated_at: '2026-06-20T06:00:44Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'tools-list', 'tool-registry', 'schema', 'untrusted-metadata'] diff --git a/.yoi/tickets/00001KVHR3WS6/thread.md b/.yoi/tickets/00001KVHR3WS6/thread.md index 19c20f8b..e93479b5 100644 --- a/.yoi/tickets/00001KVHR3WS6/thread.md +++ b/.yoi/tickets/00001KVHR3WS6/thread.md @@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 する。 + --- diff --git a/.yoi/tickets/00001KVHR3WSD/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WSD/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..4fa63ffe --- /dev/null +++ b/.yoi/tickets/00001KVHR3WSD/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KVHR3WSD/item.md b/.yoi/tickets/00001KVHR3WSD/item.md index 098c7884..6e78768e 100644 --- a/.yoi/tickets/00001KVHR3WSD/item.md +++ b/.yoi/tickets/00001KVHR3WSD/item.md @@ -2,7 +2,7 @@ title: 'MCP: execute tools/call through ordinary Tool path' state: 'queued' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T05:59:04Z' +updated_at: '2026-06-20T06:00:44Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'tools-call', 'permission', 'history', 'bounded-output'] diff --git a/.yoi/tickets/00001KVHR3WSD/thread.md b/.yoi/tickets/00001KVHR3WSD/thread.md index 05b1edca..58f6808c 100644 --- a/.yoi/tickets/00001KVHR3WSD/thread.md +++ b/.yoi/tickets/00001KVHR3WSD/thread.md @@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 する。 + --- diff --git a/.yoi/tickets/00001KVHR3WSN/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WSN/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..2dbf5ef0 --- /dev/null +++ b/.yoi/tickets/00001KVHR3WSN/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KVHR3WSN/item.md b/.yoi/tickets/00001KVHR3WSN/item.md index dbe7eb62..68596323 100644 --- a/.yoi/tickets/00001KVHR3WSN/item.md +++ b/.yoi/tickets/00001KVHR3WSN/item.md @@ -2,7 +2,7 @@ title: 'MCP: expose resources and prompts as explicit tool operations' state: 'queued' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T05:58:57Z' +updated_at: '2026-06-20T06:00:44Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'resources', 'prompts', 'prompt-context', 'history', 'untrusted-content'] diff --git a/.yoi/tickets/00001KVHR3WSN/thread.md b/.yoi/tickets/00001KVHR3WSN/thread.md index b102fcb7..83927274 100644 --- a/.yoi/tickets/00001KVHR3WSN/thread.md +++ b/.yoi/tickets/00001KVHR3WSN/thread.md @@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 する。 + --- diff --git a/.yoi/tickets/00001KVHR3WSW/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WSW/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..554d1821 --- /dev/null +++ b/.yoi/tickets/00001KVHR3WSW/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KVHR3WSW/item.md b/.yoi/tickets/00001KVHR3WSW/item.md index 8cfd58f0..9432596e 100644 --- a/.yoi/tickets/00001KVHR3WSW/item.md +++ b/.yoi/tickets/00001KVHR3WSW/item.md @@ -2,7 +2,7 @@ title: 'MCP: handle list_changed notifications safely' state: 'queued' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T05:59:05Z' +updated_at: '2026-06-20T06:00:44Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'notifications', 'tool-schema', 'prompt-cache', 'refresh'] diff --git a/.yoi/tickets/00001KVHR3WSW/thread.md b/.yoi/tickets/00001KVHR3WSW/thread.md index 098639ac..a4a31e93 100644 --- a/.yoi/tickets/00001KVHR3WSW/thread.md +++ b/.yoi/tickets/00001KVHR3WSW/thread.md @@ -13,4 +13,17 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 する。 + --- From 945ecdf64da2e90b357865deb963512a0fd7e8ea Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:24:35 +0900 Subject: [PATCH 02/29] plugin: add authoring cli --- crates/manifest/src/plugin.rs | 442 ++++++++++- crates/yoi/src/main.rs | 82 +- crates/yoi/src/plugin_cli.rs | 728 +++++++++++++++++- docs/development/plugin-development.md | 35 +- .../templates/rust-component-tool/README.md | 10 +- .../rust-component-tool/plugin.component.wasm | 1 + 6 files changed, 1274 insertions(+), 24 deletions(-) create mode 100644 resources/plugin/templates/rust-component-tool/plugin.component.wasm diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index b005752b..1ca3f2e7 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -45,6 +45,12 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ "../../../resources/plugin/templates/rust-component-tool/plugin.toml" ), }, + PluginTemplateResource { + path: "plugin.component.wasm", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm" + ), + }, PluginTemplateResource { path: "README.md", contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"), @@ -518,6 +524,23 @@ pub struct DiscoveredPluginPackage { pub entries: BTreeSet, } +/// 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>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackedPluginPackage { + pub output_path: PathBuf, + pub package: DiscoveredPluginPackage, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PluginDiscoveryReport { pub packages: Vec, @@ -1362,7 +1385,146 @@ fn read_package( .with_source(source) .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 { + 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 { + 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 { + 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 { + 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>, + limits: &PluginDiscoveryLimits, +) -> Result { + let archive = StoredArchive { + files: files.clone(), + }; let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| { PluginDiagnostic::new( PluginDiagnosticKind::Missing, @@ -1370,7 +1532,7 @@ fn read_package( "plugin package is missing root plugin.toml", ) .with_source(source) - .with_package(label) + .with_package(label.clone()) })?; if manifest_bytes.len() > limits.max_manifest_size_bytes { return Err(PluginDiagnostic::new( @@ -1388,7 +1550,7 @@ fn read_package( "plugin.toml is not valid UTF-8", ) .with_source(source) - .with_package(label) + .with_package(label.clone()) })?; let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| { PluginDiagnostic::new( @@ -1397,20 +1559,282 @@ fn read_package( safe_toml_parse_message(&error), ) .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 identity = SourceQualifiedPluginId::new(source, manifest.id.clone()); - - Ok(DiscoveredPluginPackage { + let package = DiscoveredPluginPackage { identity, package_path: path.to_path_buf(), - package_label: label.to_string(), + package_label: label, digest, manifest, 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>, +) -> 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::>(); + 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::>>() + .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::(); + 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>, + 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, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +fn write_u32_vec(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); } fn validate_manifest( diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index e6c5b400..b3efec3d 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -429,10 +429,58 @@ fn parse_args_slice(args: &[String]) -> Result { fn parse_plugin_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( - "yoi plugin requires `list` or `show `".to_string(), + "yoi plugin requires `new`, `check`, `pack`, `list`, or `show `".to_string(), )); }; 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" => { let (plugin_args, positional) = parse_plugin_common_args(rest)?; if !positional.is_empty() { @@ -513,8 +561,36 @@ fn parse_plugin_common_args( Ok((parsed, positional)) } +fn parse_plugin_pack_args( + args: &[String], +) -> Result<(plugin_cli::PluginCliArgs, Vec, Option), 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 { - "usage: yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" + "usage: yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" } fn parse_panel_workspace(args: &[String]) -> Result { @@ -547,7 +623,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 474c6a8e..89a48cd7 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -5,10 +5,13 @@ use std::fs; use std::path::{Path, PathBuf}; use manifest::plugin::{ - PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiscoveryLimits, - PluginDiscoveryOptions, PluginDiscoveryReport, PluginPackageManifest, PluginPermission, - PluginResolution, PluginSourceKind, PluginSurface, ResolvedPlugin, ResolvedPluginRecord, - SourceQualifiedPluginId, discover_plugins, resolve_enabled_plugins, + MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind, + PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport, + PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission, + 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 pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static}; @@ -35,17 +38,552 @@ pub(crate) enum PluginCliCommand { reference: String, args: PluginCliArgs, }, + New { + template: String, + destination: PathBuf, + args: PluginCliArgs, + }, + Check { + input: PathBuf, + args: PluginCliArgs, + }, + Pack { + input: PathBuf, + output: Option, + args: PluginCliArgs, + }, } 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 != "active" { + return Err("plugin check failed; see diagnostics above".into()); + } + return Ok(()); + } let rendered = match command { PluginCliCommand::List(args) => render_list(&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}"); Ok(()) } +fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result { + 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 ` and then `yoi plugin pack `.".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<()> { + if destination.exists() { + let metadata = fs::metadata(destination)?; + 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()); + } + } else { + fs::create_dir_all(destination)?; + } + + 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 { + let report = build_check_report(input); + render_check_report(&report, args) +} + +fn render_check_report(report: &CheckReport, args: &PluginCliArgs) -> Result { + 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 { + 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 diagnostics = static_inspection_diagnostics(&static_inspection); + let status = if diagnostics.is_empty() { + "active" + } else { + "rejected" + }; + 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 { + 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 check_next_steps(status: &str, reference: &str) -> Vec { + if 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 ` to create a deterministic .yoi-plugin archive." + .to_string(), + ] + } else { + 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 { + 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 { + 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() + )?; + writeln!( + out, + "enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config", + package.reference, package.digest + )?; + } + 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 { + 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, + safety: AuthoringSafetyReport, + next_steps: Vec, +} + +#[derive(Serialize)] +struct CheckReport { + command: &'static str, + status: &'static str, + input_path: String, + input_kind: &'static str, + package: Option, + diagnostics: Vec, + static_inspection: Option, + safety: AuthoringSafetyReport, + next_steps: Vec, +} + +#[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, + surfaces: Vec, + tools: Vec, + permissions: Vec, +} + +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 { let snapshot = build_snapshot(args)?; if args.json { @@ -1254,6 +1792,188 @@ mod tests { 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"], "active"); + let error = render_new( + "rust-component-tool", + &destination, + &PluginCliArgs::default(), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("not empty")); + } + + #[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] fn ambiguous_ref_is_bounded_error() { let snapshot = PluginInspectionSnapshot { diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 25edef62..930e6fbb 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -23,11 +23,11 @@ Implemented foundation: - first-party Rust PDK helpers for Component Model Tool guests; - embedded Rust Component Tool starter template; - `https` and `fs` host APIs for Tool runtime; -- read-only `yoi plugin list/show` inspection. +- 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: -- `yoi plugin new/check/pack` authoring commands; - multi-language SDK/PDK crates; - Service / Ingress surfaces; - 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. +## 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. 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. Invalid checks print the same structured report and exit non-zero. + +Pack a source directory into a deterministic stored `.yoi-plugin` archive: + +```bash +yoi plugin pack ./my-plugin +yoi plugin pack ./my-plugin --output ./my-plugin.yoi-plugin --json +``` + +`pack` rejects malformed manifests, missing runtime artifacts, symlinks/root escapes, and unsupported package shapes. The JSON output contains the stable package reference, output path, digest, entries, and safety flags. After review, copy the package to `.yoi/plugins/` (or the user Plugin store) and add explicit Profile/config enablement with pinned digest and grants; packing and checking do not do this for you. + ## Manifest: `plugin.toml` A minimal Component Model Tool Plugin manifest looks like this: diff --git a/resources/plugin/templates/rust-component-tool/README.md b/resources/plugin/templates/rust-component-tool/README.md index 9208c75f..0fecba15 100644 --- a/resources/plugin/templates/rust-component-tool/README.md +++ b/resources/plugin/templates/rust-component-tool/README.md @@ -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 = "" } ``` -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 1. Replace package/plugin ids, names, descriptions, and Tool schema. 2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic. -3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment. -4. Package `plugin.toml` and `plugin.component.wasm` into a `.yoi-plugin` archive. -5. Use `yoi plugin list` / `yoi plugin show` plus focused runtime tests to inspect and validate the package. - -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. +3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment, replacing the placeholder `plugin.component.wasm`. +4. Run `yoi plugin check .` and `yoi plugin pack . --output ./my-plugin.yoi-plugin`. +5. Copy the package to a Plugin store and add explicit enablement with pinned digest/grants after review. diff --git a/resources/plugin/templates/rust-component-tool/plugin.component.wasm b/resources/plugin/templates/rust-component-tool/plugin.component.wasm new file mode 100644 index 00000000..6193fb38 --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/plugin.component.wasm @@ -0,0 +1 @@ +placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling. From 59d0a58e3ae4e5de5f5a2de47807be428e10f76b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:25:47 +0900 Subject: [PATCH 03/29] ticket: record plugin authoring cli implementation report --- .yoi/tickets/00001KVHKWNQS/item.md | 2 +- .yoi/tickets/00001KVHKWNQS/thread.md | 49 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHKWNQS/item.md b/.yoi/tickets/00001KVHKWNQS/item.md index 1d3c4f6b..98439fa0 100644 --- a/.yoi/tickets/00001KVHKWNQS/item.md +++ b/.yoi/tickets/00001KVHKWNQS/item.md @@ -2,7 +2,7 @@ title: 'Plugin: add authoring CLI new/check/pack' state: 'inprogress' created_at: '2026-06-20T04:16:14Z' -updated_at: '2026-06-20T05:55:31Z' +updated_at: '2026-06-20T06:25:39Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] diff --git a/.yoi/tickets/00001KVHKWNQS/thread.md b/.yoi/tickets/00001KVHKWNQS/thread.md index 2b5a1650..132b811f 100644 --- a/.yoi/tickets/00001KVHKWNQS/thread.md +++ b/.yoi/tickets/00001KVHKWNQS/thread.md @@ -124,3 +124,52 @@ Next action: - Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。 --- + + + +## Implementation report + +Coder implementation report received from `yoi-coder-00001KVHKWNQS`. + +Implementation commit: +- `945ecdf6 plugin: add authoring cli` + +Changed areas reported: +- Added `yoi plugin new rust-component-tool `。 +- Added `yoi plugin check [--json]`。 +- Added `yoi plugin pack [--output ] [--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。 + +--- From 8206b5912d3f7cb0bb34ea1a7539f52b36b5971c Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:26:33 +0900 Subject: [PATCH 04/29] ticket: dispatch plugin authoring cli review --- .yoi/tickets/00001KVHKWNQS/item.md | 2 +- .yoi/tickets/00001KVHKWNQS/thread.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHKWNQS/item.md b/.yoi/tickets/00001KVHKWNQS/item.md index 98439fa0..cc0108c2 100644 --- a/.yoi/tickets/00001KVHKWNQS/item.md +++ b/.yoi/tickets/00001KVHKWNQS/item.md @@ -2,7 +2,7 @@ title: 'Plugin: add authoring CLI new/check/pack' state: 'inprogress' created_at: '2026-06-20T04:16:14Z' -updated_at: '2026-06-20T06:25:39Z' +updated_at: '2026-06-20T06:26:24Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] diff --git a/.yoi/tickets/00001KVHKWNQS/thread.md b/.yoi/tickets/00001KVHKWNQS/thread.md index 132b811f..f40099ab 100644 --- a/.yoi/tickets/00001KVHKWNQS/thread.md +++ b/.yoi/tickets/00001KVHKWNQS/thread.md @@ -173,3 +173,16 @@ 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。 --- + + + +## 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。 + +--- From 9ac540f7cf7d9eb92333543e738319a4da4da9d0 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:36:56 +0900 Subject: [PATCH 05/29] ticket: request changes on plugin authoring cli --- .yoi/tickets/00001KVHKWNQS/item.md | 2 +- .yoi/tickets/00001KVHKWNQS/thread.md | 45 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHKWNQS/item.md b/.yoi/tickets/00001KVHKWNQS/item.md index cc0108c2..a136b751 100644 --- a/.yoi/tickets/00001KVHKWNQS/item.md +++ b/.yoi/tickets/00001KVHKWNQS/item.md @@ -2,7 +2,7 @@ title: 'Plugin: add authoring CLI new/check/pack' state: 'inprogress' created_at: '2026-06-20T04:16:14Z' -updated_at: '2026-06-20T06:26:24Z' +updated_at: '2026-06-20T06:36:52Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] diff --git a/.yoi/tickets/00001KVHKWNQS/thread.md b/.yoi/tickets/00001KVHKWNQS/thread.md index f40099ab..8308c013 100644 --- a/.yoi/tickets/00001KVHKWNQS/thread.md +++ b/.yoi/tickets/00001KVHKWNQS/thread.md @@ -186,3 +186,48 @@ Review dispatch: - Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。 --- + + + +## Review: request changes + +Verdict: `request_changes` + +確認範囲: +- Ticket 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。 + +--- From 699db538b64222c6df0f1ea3aa667ae8cefef8fe Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:45:12 +0900 Subject: [PATCH 06/29] plugin: harden authoring checks --- crates/yoi/src/plugin_cli.rs | 154 ++++++++++++++++++++----- docs/development/plugin-development.md | 4 +- 2 files changed, 124 insertions(+), 34 deletions(-) diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 89a48cd7..c680126b 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::error::Error; use std::fmt::Write as _; use std::fs; +use std::io; use std::path::{Path, PathBuf}; use manifest::plugin::{ @@ -59,7 +60,7 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> { let report = build_check_report(&input); let rendered = render_check_report(&report, &args)?; print!("{rendered}"); - if report.status != "active" { + if report.status == "rejected" { return Err("plugin check failed; see diagnostics above".into()); } return Ok(()); @@ -113,24 +114,34 @@ fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Resul } fn materialize_template(destination: &Path) -> Result<()> { - if destination.exists() { - let metadata = fs::metadata(destination)?; - if !metadata.is_dir() { - return Err(format!( - "plugin template destination `{}` already exists and is not a directory", - destination.display() - ) - .into()); + 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()); + } } - 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)?; } - } else { - fs::create_dir_all(destination)?; + Err(error) => return Err(error.into()), } for resource in RUST_COMPONENT_TOOL_TEMPLATE { @@ -219,12 +230,17 @@ fn build_check_report(input: &Path) -> CheckReport { match result { Ok(materialized) => { let static_inspection = inspect_materialized_package(&materialized); - let diagnostics = static_inspection_diagnostics(&static_inspection); - let status = if diagnostics.is_empty() { - "active" - } else { + 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", @@ -316,18 +332,48 @@ fn static_inspection_diagnostics( diagnostics } +fn placeholder_component_diagnostic( + materialized: &MaterializedPluginPackage, +) -> Option { + 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 { - if status == "active" { - vec![ + 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 ` to create a deterministic .yoi-plugin archive." .to_string(), - ] - } else { - vec!["Fix the reported diagnostics before enabling or packing this Plugin.".to_string()] + ], + "partial" => vec![ + "Replace the generated placeholder component artifact with a real built component." + .to_string(), + "Run `yoi plugin check ` 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()] + } } } @@ -376,11 +422,21 @@ fn render_check_human(report: &CheckReport) -> Result { join_or_none(&package.surfaces), package.tools.len() )?; - writeln!( - out, - "enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config", - package.reference, package.digest - )?; + 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")?; @@ -1821,7 +1877,24 @@ mod tests { ) .unwrap(); let check_value: serde_json::Value = serde_json::from_str(&check_json).unwrap(); - assert_eq!(check_value["status"], "active"); + 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, @@ -1832,6 +1905,23 @@ mod tests { 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(); diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 930e6fbb..43cfcf03 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -64,7 +64,7 @@ Create a Rust Component Tool starter from embedded resources: yoi plugin new rust-component-tool ./my-plugin ``` -`new` writes only inside the requested destination and refuses an existing non-empty destination. 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. +`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: @@ -74,7 +74,7 @@ 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. Invalid checks print the same structured report and exit non-zero. +`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: From 93265ae65cbe9b8ffead3fe78064b14e1e52b1fe Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:46:18 +0900 Subject: [PATCH 07/29] ticket: record plugin authoring cli hardening fix --- .yoi/tickets/00001KVHKWNQS/item.md | 2 +- .yoi/tickets/00001KVHKWNQS/thread.md | 49 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHKWNQS/item.md b/.yoi/tickets/00001KVHKWNQS/item.md index a136b751..4b76fa6e 100644 --- a/.yoi/tickets/00001KVHKWNQS/item.md +++ b/.yoi/tickets/00001KVHKWNQS/item.md @@ -2,7 +2,7 @@ title: 'Plugin: add authoring CLI new/check/pack' state: 'inprogress' created_at: '2026-06-20T04:16:14Z' -updated_at: '2026-06-20T06:36:52Z' +updated_at: '2026-06-20T06:46:09Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] diff --git a/.yoi/tickets/00001KVHKWNQS/thread.md b/.yoi/tickets/00001KVHKWNQS/thread.md index 8308c013..96846a8d 100644 --- a/.yoi/tickets/00001KVHKWNQS/thread.md +++ b/.yoi/tickets/00001KVHKWNQS/thread.md @@ -231,3 +231,52 @@ Reviewer validation: Worktree status at review end: clean。 --- + + + +## 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 --json` => `partial` with placeholder diagnostic。 + - `plugin pack ... --json` => `packed`。 + - `plugin check --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。 + +--- From 52e40b2fdc366d6bd1964baa811cbc54c717a461 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:50:48 +0900 Subject: [PATCH 08/29] ticket: approve plugin authoring cli --- .yoi/tickets/00001KVHKWNQS/item.md | 2 +- .yoi/tickets/00001KVHKWNQS/thread.md | 42 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHKWNQS/item.md b/.yoi/tickets/00001KVHKWNQS/item.md index 4b76fa6e..ef8f369f 100644 --- a/.yoi/tickets/00001KVHKWNQS/item.md +++ b/.yoi/tickets/00001KVHKWNQS/item.md @@ -2,7 +2,7 @@ title: 'Plugin: add authoring CLI new/check/pack' state: 'inprogress' created_at: '2026-06-20T04:16:14Z' -updated_at: '2026-06-20T06:46:09Z' +updated_at: '2026-06-20T06:50:40Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] diff --git a/.yoi/tickets/00001KVHKWNQS/thread.md b/.yoi/tickets/00001KVHKWNQS/thread.md index 96846a8d..ffe6e7dd 100644 --- a/.yoi/tickets/00001KVHKWNQS/thread.md +++ b/.yoi/tickets/00001KVHKWNQS/thread.md @@ -280,3 +280,45 @@ Next action: - Route back to Reviewer for r2 verification of symlink destination refusal, placeholder partial/check behavior, and final acceptance criteria。 --- + + + +## 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。 + +--- From ead96654becad0a0a9bf37a3c084ce695e6d4a1e Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:54:51 +0900 Subject: [PATCH 09/29] ticket: complete plugin authoring cli --- .yoi/tickets/00001KVHKWNQS/item.md | 4 +-- .yoi/tickets/00001KVHKWNQS/thread.md | 46 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVHKWNQS/item.md b/.yoi/tickets/00001KVHKWNQS/item.md index ef8f369f..fd1463f5 100644 --- a/.yoi/tickets/00001KVHKWNQS/item.md +++ b/.yoi/tickets/00001KVHKWNQS/item.md @@ -1,8 +1,8 @@ --- title: 'Plugin: add authoring CLI new/check/pack' -state: 'inprogress' +state: 'done' created_at: '2026-06-20T04:16:14Z' -updated_at: '2026-06-20T06:50:40Z' +updated_at: '2026-06-20T06:54:45Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] diff --git a/.yoi/tickets/00001KVHKWNQS/thread.md b/.yoi/tickets/00001KVHKWNQS/thread.md index ffe6e7dd..3a3be75f 100644 --- a/.yoi/tickets/00001KVHKWNQS/thread.md +++ b/.yoi/tickets/00001KVHKWNQS/thread.md @@ -322,3 +322,49 @@ Reviewer validation: Worktree status at review end: source tree clean。 --- + + + +## 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。 + +--- + + + +## 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. + +--- From a5df9e37284ce26a7f2b172ea772ce42272f8704 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:55:14 +0900 Subject: [PATCH 10/29] ticket: close plugin authoring cli --- .yoi/tickets/00001KVHKWNQS/item.md | 4 +- .yoi/tickets/00001KVHKWNQS/resolution.md | 41 +++++++++++++++++ .yoi/tickets/00001KVHKWNQS/thread.md | 57 ++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KVHKWNQS/resolution.md diff --git a/.yoi/tickets/00001KVHKWNQS/item.md b/.yoi/tickets/00001KVHKWNQS/item.md index fd1463f5..b8fa7fcd 100644 --- a/.yoi/tickets/00001KVHKWNQS/item.md +++ b/.yoi/tickets/00001KVHKWNQS/item.md @@ -1,8 +1,8 @@ --- title: 'Plugin: add authoring CLI new/check/pack' -state: 'done' +state: 'closed' created_at: '2026-06-20T04:16:14Z' -updated_at: '2026-06-20T06:54:45Z' +updated_at: '2026-06-20T06:55:06Z' assignee: null readiness: 'implementation_ready' risk_flags: ['plugin', 'cli', 'authoring', 'templates', 'package-validation', 'packaging', 'read-only-check'] diff --git a/.yoi/tickets/00001KVHKWNQS/resolution.md b/.yoi/tickets/00001KVHKWNQS/resolution.md new file mode 100644 index 00000000..163c1a97 --- /dev/null +++ b/.yoi/tickets/00001KVHKWNQS/resolution.md @@ -0,0 +1,41 @@ +## Resolution + +`00001KVHKWNQS` を完了しました。 + +実装内容: +- `yoi plugin new rust-component-tool ` を追加しました。 +- `yoi plugin check [--json]` を追加しました。 +- `yoi plugin pack [--output ] [--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` \ No newline at end of file diff --git a/.yoi/tickets/00001KVHKWNQS/thread.md b/.yoi/tickets/00001KVHKWNQS/thread.md index 3a3be75f..fd5ab0e3 100644 --- a/.yoi/tickets/00001KVHKWNQS/thread.md +++ b/.yoi/tickets/00001KVHKWNQS/thread.md @@ -368,3 +368,60 @@ Final state: 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. --- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +## Resolution + +`00001KVHKWNQS` を完了しました。 + +実装内容: +- `yoi plugin new rust-component-tool ` を追加しました。 +- `yoi plugin check [--json]` を追加しました。 +- `yoi plugin pack [--output ] [--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` + +--- From b0225e48b8b9ac6ac38eb9f03289946231ef352a Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:56:29 +0900 Subject: [PATCH 11/29] ticket: accept mcp stdio config trust --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WRF/item.md | 4 +- .yoi/tickets/00001KVHR3WRF/thread.md | 66 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl index 9d601353..b32ba35f 100644 --- a/.yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVHR3WRF/artifacts/orchestration-plan.jsonl @@ -1 +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"} diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index b4d8d486..985051d0 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -1,8 +1,8 @@ --- title: 'MCP: add local stdio server config and trust policy' -state: 'queued' +state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T06:00:44Z' +updated_at: '2026-06-20T06:56:23Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index c1ad93f7..84e975a2 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -33,3 +33,69 @@ Next: - `00001KVHKWNQS` が完了して実装 capacity が空いた後、改めて reroute する。 --- + + + +## 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。 + +--- + + + +## 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. + +--- From 6cc8551a6c89b0a3484d6e4705b5b5e51defe82c Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 15:57:19 +0900 Subject: [PATCH 12/29] ticket: start mcp stdio config worktree --- .yoi/tickets/00001KVHR3WRF/item.md | 2 +- .yoi/tickets/00001KVHR3WRF/thread.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index 985051d0..8b32594e 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -2,7 +2,7 @@ title: 'MCP: add local stdio server config and trust policy' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T06:56:23Z' +updated_at: '2026-06-20T06:57:08Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index 84e975a2..0594a481 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -99,3 +99,18 @@ Next action: 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. --- + + + +## 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。 + +--- From e0680ccee0210f937d5df7eb7db6307df84504c4 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:17:38 +0900 Subject: [PATCH 13/29] mcp: add stdio server config --- Cargo.lock | 1 + crates/manifest/Cargo.toml | 1 + crates/manifest/src/config.rs | 310 +++++++++++++++++++++- crates/manifest/src/lib.rs | 101 ++++++- crates/manifest/src/profile.rs | 78 +++++- crates/pod/src/spawn/tool.rs | 1 + docs/design/profiles-manifests-prompts.md | 31 +++ package.nix | 2 +- 8 files changed, 515 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba35ed1c..ef3de4e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2032,6 +2032,7 @@ dependencies = [ "llm-worker", "mlua", "protocol", + "secrets", "serde", "serde_ignored", "serde_json", diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index fbd3667c..ceae4a9c 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -13,6 +13,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_ignored = "0.1.14" sha2 = "0.10" +secrets = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 1137e71c..72b93061 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -6,7 +6,7 @@ //! via [`PodManifestConfig::merge`] and the final config is converted to //! a validated [`PodManifest`] via `TryFrom`. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::num::NonZeroU32; use std::path::{Path, PathBuf}; @@ -17,10 +17,10 @@ use crate::defaults; use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::plugin::PluginConfig; use crate::{ - CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig, - PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig, - TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, - WorkerManifest, + CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue, + McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig, + SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits, + ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, }; /// 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. #[serde(default)] pub plugins: PluginConfig, + /// Explicit Model Context Protocol provider declarations. Config parsing + /// never starts a local MCP subprocess. + #[serde(default)] + pub mcp: McpConfig, #[serde(default)] pub compaction: Option, /// First-class web tool opt-in. See [`WebConfig`]. @@ -322,6 +326,11 @@ pub enum ResolveError { MissingField(&'static str), #[error("path must be absolute ({field}): {}", .path.display())] 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 @@ -436,6 +445,11 @@ impl PodManifestConfig { *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 } @@ -458,6 +472,7 @@ impl PodManifestConfig { ), feature: self.feature.merge(upper.feature), plugins: merge_plugin_config(self.plugins, upper.plugins), + mcp: merge_mcp_config(self.mcp, upper.mcp), compaction: merge_option( self.compaction, upper.compaction, @@ -487,6 +502,11 @@ fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginCon base } +fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig { + base.stdio_servers.extend(upper.stdio_servers); + base +} + impl WebConfig { fn merge(self, upper: Self) -> Self { Self { @@ -708,6 +728,149 @@ fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<() 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) -> 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 for PodManifest { type Error = ResolveError; @@ -842,6 +1005,8 @@ impl TryFrom for PodManifest { } } + validate_mcp_config(&cfg.mcp)?; + Ok(PodManifest { pod: PodMeta { name, prompt_pack }, model: cfg.model, @@ -852,6 +1017,7 @@ impl TryFrom for PodManifest { permissions, feature: FeatureConfig::from(cfg.feature), plugins: cfg.plugins, + mcp: cfg.mcp, compaction, web: cfg.web, memory: cfg.memory, @@ -899,6 +1065,7 @@ mod tests { permissions: None, feature: FeatureConfigPartial::default(), plugins: PluginConfig::default(), + mcp: McpConfig::default(), session: None, compaction: None, web: None, @@ -915,6 +1082,139 @@ mod tests { 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] fn resolve_session_record_event_trace() { let mut cfg = minimal_valid(); diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 19319f95..40d6cc88 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -24,10 +24,12 @@ pub use profile::{ pub use protocol::{Permission, ScopeRule}; 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::path::PathBuf; +use serde::de::Error as _; use serde::{Deserialize, Serialize}; /// Declarative configuration for a Pod. @@ -62,6 +64,12 @@ pub struct PodManifest { /// source-qualified entries listed here may resolve to active plugin metadata. #[serde(default)] 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)] pub compaction: Option, /// Memory subsystem configuration. Presence of `[memory]` configures memory @@ -194,6 +202,92 @@ pub struct SkillsConfig { pub directories: Vec, } +/// 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, +} + +#[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, + /// 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, + /// 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, + /// Environment variables to set explicitly. + #[serde(default)] + pub set: BTreeMap, +} + +#[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. /// /// Network tools are fail-closed: absent config or `enabled = false` disables @@ -712,7 +806,10 @@ impl PodManifest { /// Parse a manifest from a TOML string. pub fn from_toml(s: &str) -> Result { 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) } } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 2170a977..03a8fac6 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -19,8 +19,9 @@ use crate::config::{ use crate::model::{AuthRef, ModelManifest}; use crate::plugin::PluginConfig; use crate::{ - MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError, - ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths, + McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig, + PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig, + WorkerManifestConfig, paths, }; const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1"; @@ -628,6 +629,7 @@ fn resolve_lua_profile_value( permissions: profile.permissions, feature: profile.feature, plugins: profile.plugins, + mcp: profile.mcp, compaction, web: profile.web, memory: profile.memory, @@ -691,6 +693,8 @@ struct ProfileConfig { #[serde(default)] plugins: PluginConfig, #[serde(default)] + mcp: McpConfig, + #[serde(default)] compaction: Option, #[serde(default)] web: Option, @@ -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(()) } fn reject_absolute_auth_file( @@ -1693,6 +1707,66 @@ return profile { 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] fn resolves_lua_profile_feature_flags_without_runtime_state() { let tmp = TempDir::new().unwrap(); diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 3a5612a6..21693d50 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -776,6 +776,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig { }), feature: manifest.feature.clone().into(), plugins: manifest.plugins.clone(), + mcp: manifest.mcp.clone(), compaction: manifest .compaction .as_ref() diff --git a/docs/design/profiles-manifests-prompts.md b/docs/design/profiles-manifests-prompts.md index 76966842..292bdfc3 100644 --- a/docs/design/profiles-manifests-prompts.md +++ b/docs/design/profiles-manifests-prompts.md @@ -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 ` 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 `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. diff --git a/package.nix b/package.nix index fdf427fe..d027b05a 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-ci9h0U83YQQBeT3xlsGuKULnl1Aphgpg3pR4n0se16I="; + cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From e578d888e34c2814ec0f3329275e46943580f9ae Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:18:46 +0900 Subject: [PATCH 14/29] ticket: record mcp stdio config implementation report --- .yoi/tickets/00001KVHR3WRF/item.md | 2 +- .yoi/tickets/00001KVHR3WRF/thread.md | 44 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index 8b32594e..877ddd75 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -2,7 +2,7 @@ title: 'MCP: add local stdio server config and trust policy' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T06:57:08Z' +updated_at: '2026-06-20T07:18:35Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index 0594a481..abc3eff6 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -114,3 +114,47 @@ Next action: - Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。 --- + + + +## Implementation report + +Coder implementation report received from `yoi-coder-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。 + +--- From a0cd3dd0e993c46de05e1238cd181335b6429c8e Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:19:31 +0900 Subject: [PATCH 15/29] ticket: dispatch mcp stdio config review --- .yoi/tickets/00001KVHR3WRF/item.md | 2 +- .yoi/tickets/00001KVHR3WRF/thread.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index 877ddd75..1e84c0c6 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -2,7 +2,7 @@ title: 'MCP: add local stdio server config and trust policy' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:18:35Z' +updated_at: '2026-06-20T07:19:20Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index abc3eff6..b3fbb3d4 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -158,3 +158,16 @@ 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。 --- + + + +## 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。 + +--- From c6fa0b2d95fe3655e54d385f8fee33c54baa99ea Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:27:07 +0900 Subject: [PATCH 16/29] ticket: approve mcp stdio config trust --- .yoi/tickets/00001KVHR3WRF/item.md | 2 +- .yoi/tickets/00001KVHR3WRF/thread.md | 38 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index 1e84c0c6..cb1d7338 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -2,7 +2,7 @@ title: 'MCP: add local stdio server config and trust policy' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:19:20Z' +updated_at: '2026-06-20T07:27:01Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index b3fbb3d4..8daa1dde 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -171,3 +171,41 @@ Review dispatch: - Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。 --- + + + +## Review: approve + +Verdict: `approve` + +確認範囲: +- Ticket contract / Orchestrator IntentPacket。 +- 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。 + +--- From c4a7eb7a2ef546b534768432995f83ab57400afc Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:28:39 +0900 Subject: [PATCH 17/29] ticket: complete mcp stdio config trust --- .yoi/tickets/00001KVHR3WRF/item.md | 4 +-- .yoi/tickets/00001KVHR3WRF/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index cb1d7338..5bc32885 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -1,8 +1,8 @@ --- title: 'MCP: add local stdio server config and trust policy' -state: 'inprogress' +state: 'done' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:27:01Z' +updated_at: '2026-06-20T07:28:26Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index 8daa1dde..6a529601 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -209,3 +209,46 @@ Reviewer validation: Worktree status at review end: clean。 --- + + + +## 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。 + +--- + + + +## 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`. + +--- From 8f5eef94e4a297fa079fe5f76de5480b73de4072 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:29:03 +0900 Subject: [PATCH 18/29] ticket: close mcp stdio config trust --- .yoi/tickets/00001KVHR3WRF/item.md | 4 +- .yoi/tickets/00001KVHR3WRF/resolution.md | 37 +++++++++++++++++ .yoi/tickets/00001KVHR3WRF/thread.md | 53 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KVHR3WRF/resolution.md diff --git a/.yoi/tickets/00001KVHR3WRF/item.md b/.yoi/tickets/00001KVHR3WRF/item.md index 5bc32885..f4a806a9 100644 --- a/.yoi/tickets/00001KVHR3WRF/item.md +++ b/.yoi/tickets/00001KVHR3WRF/item.md @@ -1,8 +1,8 @@ --- title: 'MCP: add local stdio server config and trust policy' -state: 'done' +state: 'closed' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:28:26Z' +updated_at: '2026-06-20T07:28:55Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'config', 'trust-boundary', 'secrets', 'process-exec'] diff --git a/.yoi/tickets/00001KVHR3WRF/resolution.md b/.yoi/tickets/00001KVHR3WRF/resolution.md new file mode 100644 index 00000000..499a854d --- /dev/null +++ b/.yoi/tickets/00001KVHR3WRF/resolution.md @@ -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` \ No newline at end of file diff --git a/.yoi/tickets/00001KVHR3WRF/thread.md b/.yoi/tickets/00001KVHR3WRF/thread.md index 6a529601..43eae513 100644 --- a/.yoi/tickets/00001KVHR3WRF/thread.md +++ b/.yoi/tickets/00001KVHR3WRF/thread.md @@ -252,3 +252,56 @@ Final state: 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`. --- + + + +## State changed + +Ticket を 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` + +--- From c0e760d73ecda353323a3aaab1bcbd39adbf51fd Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:30:12 +0900 Subject: [PATCH 19/29] ticket: accept mcp stdio lifecycle client --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KVHR3WRY/item.md | 4 +- .yoi/tickets/00001KVHR3WRY/thread.md | 68 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl index da6a936c..8e653097 100644 --- a/.yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KVHR3WRY/artifacts/orchestration-plan.jsonl @@ -1 +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"} diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index cf6c6091..a154bd00 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -1,8 +1,8 @@ --- title: 'MCP: implement stdio JSON-RPC lifecycle client' -state: 'queued' +state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T06:00:44Z' +updated_at: '2026-06-20T07:30:03Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index 49b5e38c..2816ade8 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -27,3 +27,71 @@ Next: - `00001KVHR3WRF` が closed になった後、改めて reroute する。 --- + + + +## 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。 + +--- + + + +## 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. + +--- From 017c4471edbaaf6ad4594dde256b6ae387c2ccce Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:31:10 +0900 Subject: [PATCH 20/29] ticket: start mcp stdio lifecycle worktree --- .yoi/tickets/00001KVHR3WRY/item.md | 2 +- .yoi/tickets/00001KVHR3WRY/thread.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index a154bd00..2fccde95 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -2,7 +2,7 @@ title: 'MCP: implement stdio JSON-RPC lifecycle client' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:30:03Z' +updated_at: '2026-06-20T07:30:59Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index 2816ade8..70c3e0b9 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -95,3 +95,18 @@ Next action: 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. --- + + + +## 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。 + +--- From a114fa9d0a5bafa056328137e5c317097ff2e9cd Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:45:05 +0900 Subject: [PATCH 21/29] mcp: implement stdio lifecycle client --- Cargo.lock | 13 + Cargo.toml | 3 + crates/mcp/Cargo.toml | 23 + crates/mcp/src/lib.rs | 7 + crates/mcp/src/stdio.rs | 1112 ++++++++++++++++++++++ crates/mcp/tests/fixtures/mock_server.rs | 116 +++ crates/mcp/tests/stdio_lifecycle.rs | 94 ++ package.nix | 2 +- 8 files changed, 1369 insertions(+), 1 deletion(-) create mode 100644 crates/mcp/Cargo.toml create mode 100644 crates/mcp/src/lib.rs create mode 100644 crates/mcp/src/stdio.rs create mode 100644 crates/mcp/tests/fixtures/mock_server.rs create mode 100644 crates/mcp/tests/stdio_lifecycle.rs diff --git a/Cargo.lock b/Cargo.lock index ef3de4e9..0274ebac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2078,6 +2078,19 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "mcp" +version = "0.1.0" +dependencies = [ + "libc", + "manifest", + "secrets", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "memchr" version = "2.8.0" diff --git a/Cargo.toml b/Cargo.toml index 2dadc297..8ce48d23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/session-store", "crates/secrets", "crates/manifest", + "crates/mcp", "crates/pod", "crates/plugin-pdk", "crates/yoi", @@ -34,6 +35,7 @@ default-members = [ "crates/session-store", "crates/secrets", "crates/manifest", + "crates/mcp", "crates/pod", "crates/plugin-pdk", "crates/yoi", @@ -62,6 +64,7 @@ client = { path = "crates/client" } llm-worker = { path = "crates/llm-worker", version = "0.2" } llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" } manifest = { path = "crates/manifest" } +mcp = { path = "crates/mcp" } lint-common = { path = "crates/lint-common" } memory = { path = "crates/memory" } ticket = { path = "crates/ticket" } diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 00000000..6e82fd6b --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -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 diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 00000000..f382de19 --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -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; diff --git a/crates/mcp/src/stdio.rs b/crates/mcp/src/stdio.rs new file mode 100644 index 00000000..26171ed0 --- /dev/null +++ b/crates/mcp/src/stdio.rs @@ -0,0 +1,1112 @@ +use std::collections::{BTreeMap, VecDeque}; +use std::env; +use std::fmt; +use std::path::PathBuf; +use std::process::ExitStatus; +use std::sync::Arc; +use std::time::Duration; + +use manifest::{McpConfig, McpEnvValue, McpStdioCwdPolicy, McpStdioServerConfig}; +use secrets::SecretStore; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; +use tokio::sync::{Mutex, mpsc}; +use tokio::task::JoinHandle; +use tokio::time::{Instant, timeout}; + +const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; +const CLIENT_NAME: &str = "yoi"; +const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const JSONRPC_VERSION: &str = "2.0"; +const ERR_METHOD_NOT_FOUND: i64 = -32601; + +/// Resource limits for a local stdio MCP server lifecycle. +#[derive(Debug, Clone)] +pub struct McpStdioLimits { + pub max_stdout_line_bytes: usize, + pub max_stderr_line_bytes: usize, + pub max_diagnostic_lines: usize, + pub max_protocol_bytes: usize, + pub startup_timeout: Duration, + pub request_timeout: Duration, + pub shutdown_timeout: Duration, + pub kill_timeout: Duration, +} + +impl Default for McpStdioLimits { + fn default() -> Self { + Self { + max_stdout_line_bytes: 1024 * 1024, + max_stderr_line_bytes: 16 * 1024, + max_diagnostic_lines: 32, + max_protocol_bytes: 1024 * 1024, + startup_timeout: Duration::from_secs(10), + request_timeout: Duration::from_secs(10), + shutdown_timeout: Duration::from_secs(2), + kill_timeout: Duration::from_secs(2), + } + } +} + +/// A resolved, explicit local stdio MCP server process specification. +#[derive(Debug, Clone)] +pub struct McpStdioServerSpec { + pub name: String, + pub command: String, + pub args: Vec, + pub cwd: Option, + pub env: BTreeMap, + redactions: Vec, +} + +impl McpStdioServerSpec { + pub fn new(name: impl Into, command: impl Into) -> Self { + Self { + name: name.into(), + command: command.into(), + args: Vec::new(), + cwd: None, + env: BTreeMap::new(), + redactions: Vec::new(), + } + } + + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + pub fn args(mut self, args: impl IntoIterator>) -> Self { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + pub fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub fn env(mut self, name: impl Into, value: impl Into) -> Self { + let value = value.into(); + self.redact_value(&value); + self.env.insert(name.into(), value); + self + } + + pub fn redact_value(&mut self, value: &str) { + if !value.is_empty() && !self.redactions.iter().any(|existing| existing == value) { + self.redactions.push(value.to_owned()); + } + } + + fn redactor(&self) -> Redactor { + Redactor::new(self.redactions.clone()) + } +} + +/// Resolve one explicitly named stdio server from typed MCP config. +pub fn resolve_named_stdio_server( + config: &McpConfig, + name: &str, + workspace_root: impl Into, + secret_store: Option<&SecretStore>, +) -> Result { + let server = config + .stdio_servers + .iter() + .find(|server| server.name == name) + .ok_or_else(|| { + McpClientError::new( + name, + McpPhase::Spawn, + McpErrorKind::Config(format!("stdio server `{name}` is not configured")), + ) + })?; + resolve_stdio_server(server, workspace_root, secret_store) +} + +/// Resolve one typed stdio server into process IO settings without starting it. +pub fn resolve_stdio_server( + server: &McpStdioServerConfig, + workspace_root: impl Into, + secret_store: Option<&SecretStore>, +) -> Result { + let mut spec = McpStdioServerSpec::new(server.name.clone(), server.command.clone()) + .args(server.args.clone()); + let _workspace_root = workspace_root.into(); + spec.cwd = match &server.cwd { + Some(McpStdioCwdPolicy::Path { path }) => Some(path.clone()), + Some(McpStdioCwdPolicy::Inherit) | None => None, + }; + + for name in &server.env.inherit { + if let Ok(value) = env::var(name) { + spec.redact_value(&value); + spec.env.insert(name.clone(), value); + } + } + + for (name, value) in &server.env.set { + let resolved = match value { + McpEnvValue::Literal { value } => value.clone(), + McpEnvValue::EnvRef { name } => env::var(name).map_err(|err| { + McpClientError::new( + &server.name, + McpPhase::Spawn, + McpErrorKind::Config(format!( + "environment variable `{name}` is unavailable: {err}" + )), + ) + })?, + McpEnvValue::SecretRef { ref_ } => { + let store = secret_store.ok_or_else(|| { + McpClientError::new( + &server.name, + McpPhase::Spawn, + McpErrorKind::Config(format!( + "secret `{ref_}` requires a configured secret store" + )), + ) + })?; + store + .get(ref_) + .map_err(|err| { + McpClientError::new( + &server.name, + McpPhase::Spawn, + McpErrorKind::Config(format!( + "failed to resolve secret `{ref_}`: {err}" + )), + ) + })? + .into_string() + } + }; + spec.redact_value(&resolved); + spec.env.insert(name.clone(), resolved); + } + + Ok(spec) +} + +/// A running initialized stdio MCP client. +pub struct McpStdioClient { + server_name: String, + limits: McpStdioLimits, + redactor: Redactor, + diagnostics: Arc>, + stdin: Arc>>, + child: Option, + responses: mpsc::Receiver, + reader_task: JoinHandle<()>, + stderr_task: JoinHandle<()>, + next_id: u64, + initialized: Option, + shutdown_started: bool, +} + +impl McpStdioClient { + /// Spawn, initialize, negotiate capabilities, and send notifications/initialized. + pub async fn connect( + spec: McpStdioServerSpec, + limits: McpStdioLimits, + ) -> Result { + let started = Instant::now(); + let mut client = Self::spawn(spec, limits).await?; + match timeout(client.limits.startup_timeout, client.initialize()).await { + Ok(Ok(())) => Ok(client), + Ok(Err(err)) => { + let _ = client.shutdown().await; + Err(err.with_diagnostics(client.snapshot_diagnostics().await)) + } + Err(_) => { + let err = McpClientError::new( + &client.server_name, + McpPhase::Initialize, + McpErrorKind::Timeout { + operation: "startup".to_string(), + elapsed: started.elapsed(), + }, + ) + .with_diagnostics(client.snapshot_diagnostics().await); + let _ = client.shutdown().await; + Err(err) + } + } + } + + async fn spawn( + spec: McpStdioServerSpec, + limits: McpStdioLimits, + ) -> Result { + let redactor = spec.redactor(); + let mut command = Command::new(&spec.command); + command.args(&spec.args); + if let Some(cwd) = &spec.cwd { + command.current_dir(cwd); + } + command.env_clear(); + command.envs(&spec.env); + command.stdin(std::process::Stdio::piped()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + command.kill_on_drop(true); + + let mut child = command.spawn().map_err(|err| { + McpClientError::new( + &spec.name, + McpPhase::Spawn, + McpErrorKind::Io(redactor.redact(&err.to_string())), + ) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + McpClientError::new( + &spec.name, + McpPhase::Spawn, + McpErrorKind::Protocol("child stdin was not piped".into()), + ) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + McpClientError::new( + &spec.name, + McpPhase::Spawn, + McpErrorKind::Protocol("child stdout was not piped".into()), + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + McpClientError::new( + &spec.name, + McpPhase::Spawn, + McpErrorKind::Protocol("child stderr was not piped".into()), + ) + })?; + + let stdin = Arc::new(Mutex::new(Some(stdin))); + let diagnostics = Arc::new(Mutex::new(BoundedDiagnostics::new( + spec.name.clone(), + limits.max_diagnostic_lines, + redactor.clone(), + ))); + let (tx, rx) = mpsc::channel(16); + let reader_task = spawn_stdout_reader( + spec.name.clone(), + stdout, + stdin.clone(), + tx, + limits.clone(), + redactor.clone(), + ); + let stderr_task = spawn_stderr_reader(stderr, diagnostics.clone(), limits.clone()); + + Ok(Self { + server_name: spec.name, + limits, + redactor, + diagnostics, + stdin, + child: Some(child), + responses: rx, + reader_task, + stderr_task, + next_id: 1, + initialized: None, + shutdown_started: false, + }) + } + + async fn initialize(&mut self) -> Result<(), McpClientError> { + let result: InitializeResult = self + .request( + McpPhase::Initialize, + "initialize", + json!({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { + "name": CLIENT_NAME, + "version": CLIENT_VERSION, + } + }), + ) + .await?; + self.initialized = Some(result); + self.write_notification( + McpPhase::Initialized, + "notifications/initialized", + json!({}), + ) + .await?; + Ok(()) + } + + pub fn initialize_result(&self) -> Option<&InitializeResult> { + self.initialized.as_ref() + } + + pub async fn snapshot_diagnostics(&self) -> McpDiagnostics { + self.diagnostics.lock().await.snapshot() + } + + pub async fn request Deserialize<'de>>( + &mut self, + phase: McpPhase, + method: &str, + params: Value, + ) -> Result { + let id = self.next_id; + self.next_id += 1; + let request = ClientRequest { + jsonrpc: JSONRPC_VERSION, + id, + method, + params, + }; + self.write_protocol(phase, &request).await?; + let response = match timeout( + self.limits.request_timeout, + self.wait_for_response(id, phase), + ) + .await + { + Ok(result) => result?, + Err(_) => { + return Err(McpClientError::new( + &self.server_name, + phase, + McpErrorKind::Timeout { + operation: method.to_owned(), + elapsed: self.limits.request_timeout, + }, + ) + .with_diagnostics(self.snapshot_diagnostics().await)); + } + }; + if let Some(error) = response.error { + return Err(McpClientError::new( + &self.server_name, + phase, + McpErrorKind::JsonRpcError { + code: error.code, + message: self.redactor.redact(&error.message), + }, + ) + .with_diagnostics(self.snapshot_diagnostics().await)); + } + let result = response.result.ok_or_else(|| { + McpClientError::new( + &self.server_name, + phase, + McpErrorKind::Protocol(format!("response to `{method}` did not contain result")), + ) + })?; + serde_json::from_value(result).map_err(|err| { + McpClientError::new( + &self.server_name, + phase, + McpErrorKind::Protocol(format!("invalid `{method}` result: {err}")), + ) + }) + } + + async fn wait_for_response( + &mut self, + id: u64, + phase: McpPhase, + ) -> Result { + while let Some(event) = self.responses.recv().await { + match event { + ReaderEvent::Response(response) if response.id == id => return Ok(response), + ReaderEvent::Response(_) | ReaderEvent::Notification => continue, + ReaderEvent::Error(err) => return Err(err.with_phase(phase)), + ReaderEvent::Eof => { + return Err(McpClientError::new( + &self.server_name, + phase, + McpErrorKind::Protocol("server stdout closed before response".into()), + ) + .with_diagnostics(self.snapshot_diagnostics().await)); + } + } + } + Err(McpClientError::new( + &self.server_name, + phase, + McpErrorKind::Protocol("stdout reader stopped before response".into()), + ) + .with_diagnostics(self.snapshot_diagnostics().await)) + } + + async fn write_notification( + &mut self, + phase: McpPhase, + method: &str, + params: T, + ) -> Result<(), McpClientError> { + self.write_protocol( + phase, + &ClientNotification { + jsonrpc: JSONRPC_VERSION, + method, + params, + }, + ) + .await + } + + async fn write_protocol( + &mut self, + phase: McpPhase, + value: &T, + ) -> Result<(), McpClientError> { + write_json_line( + &self.server_name, + phase, + &self.stdin, + value, + self.limits.max_protocol_bytes, + &self.redactor, + ) + .await + } + + /// Close stdin and wait for process exit, falling back to terminate and kill. + pub async fn shutdown(&mut self) -> Result { + self.shutdown_started = true; + { + let mut stdin = self.stdin.lock().await; + stdin.take(); + } + + let mut child = match self.child.take() { + Some(child) => child, + None => return Ok(ShutdownReport::already_finished()), + }; + + if let Ok(Some(status)) = child.try_wait() { + self.reader_task.abort(); + self.stderr_task.abort(); + return Ok(ShutdownReport { + exit_status: Some(status), + terminated: false, + killed: false, + }); + } + + match timeout(self.limits.shutdown_timeout, child.wait()).await { + Ok(Ok(status)) => { + self.reader_task.abort(); + self.stderr_task.abort(); + Ok(ShutdownReport { + exit_status: Some(status), + terminated: false, + killed: false, + }) + } + Ok(Err(err)) => Err(McpClientError::new( + &self.server_name, + McpPhase::Shutdown, + McpErrorKind::Io(self.redactor.redact(&err.to_string())), + ) + .with_diagnostics(self.snapshot_diagnostics().await)), + Err(_) => self.terminate_then_kill(child).await, + } + } + + async fn terminate_then_kill( + &mut self, + mut child: Child, + ) -> Result { + let mut terminated = false; + let mut killed = false; + if send_terminate(&mut child).is_ok() { + terminated = true; + } + match timeout(self.limits.kill_timeout, child.wait()).await { + Ok(Ok(status)) => { + self.reader_task.abort(); + self.stderr_task.abort(); + Ok(ShutdownReport { + exit_status: Some(status), + terminated, + killed, + }) + } + Ok(Err(err)) => Err(McpClientError::new( + &self.server_name, + McpPhase::Shutdown, + McpErrorKind::Io(self.redactor.redact(&err.to_string())), + ) + .with_diagnostics(self.snapshot_diagnostics().await)), + Err(_) => { + child.start_kill().map_err(|err| { + McpClientError::new( + &self.server_name, + McpPhase::Shutdown, + McpErrorKind::Io(self.redactor.redact(&err.to_string())), + ) + })?; + killed = true; + let status = timeout(self.limits.kill_timeout, child.wait()) + .await + .map_err(|_| { + McpClientError::new( + &self.server_name, + McpPhase::Shutdown, + McpErrorKind::Timeout { + operation: "kill".to_string(), + elapsed: self.limits.kill_timeout, + }, + ) + })? + .map_err(|err| { + McpClientError::new( + &self.server_name, + McpPhase::Shutdown, + McpErrorKind::Io(self.redactor.redact(&err.to_string())), + ) + })?; + self.reader_task.abort(); + self.stderr_task.abort(); + Ok(ShutdownReport { + exit_status: Some(status), + terminated, + killed, + }) + } + } + } +} + +impl Drop for McpStdioClient { + fn drop(&mut self) { + if !self.shutdown_started { + if let Some(child) = &mut self.child { + let _ = child.start_kill(); + } + } + self.reader_task.abort(); + self.stderr_task.abort(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpPhase { + Spawn, + Initialize, + Initialized, + Running, + Shutdown, +} + +impl fmt::Display for McpPhase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Spawn => f.write_str("spawn"), + Self::Initialize => f.write_str("initialize"), + Self::Initialized => f.write_str("initialized"), + Self::Running => f.write_str("running"), + Self::Shutdown => f.write_str("shutdown"), + } + } +} + +#[derive(Debug, Clone)] +pub struct ShutdownReport { + pub exit_status: Option, + pub terminated: bool, + pub killed: bool, +} + +impl ShutdownReport { + fn already_finished() -> Self { + Self { + exit_status: None, + terminated: false, + killed: false, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: String, + #[serde(default)] + pub capabilities: Value, + pub server_info: ImplementationInfo, + #[serde(default)] + pub instructions: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImplementationInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone)] +pub struct McpDiagnostics { + pub server_name: String, + pub stderr: Vec, + pub dropped_stderr_lines: usize, + pub truncated_stderr_lines: usize, +} + +#[derive(Debug, Error, Clone)] +#[error("MCP stdio server `{server_name}` failed during phase `{phase}`: {kind}")] +pub struct McpClientError { + pub server_name: String, + pub phase: McpPhase, + pub kind: McpErrorKind, + diagnostics: Option, +} + +impl McpClientError { + fn new(server_name: impl Into, phase: McpPhase, kind: McpErrorKind) -> Self { + Self { + server_name: server_name.into(), + phase, + kind, + diagnostics: None, + } + } + + fn with_phase(mut self, phase: McpPhase) -> Self { + self.phase = phase; + self + } + + pub fn with_diagnostics(mut self, diagnostics: McpDiagnostics) -> Self { + self.diagnostics = Some(diagnostics); + self + } + + pub fn diagnostics(&self) -> Option<&McpDiagnostics> { + self.diagnostics.as_ref() + } +} + +#[derive(Debug, Error, Clone)] +pub enum McpErrorKind { + #[error("configuration error: {0}")] + Config(String), + #[error("I/O error: {0}")] + Io(String), + #[error("protocol error: {0}")] + Protocol(String), + #[error("JSON-RPC error {code}: {message}")] + JsonRpcError { code: i64, message: String }, + #[error("timed out during {operation} after {elapsed:?}")] + Timeout { + operation: String, + elapsed: Duration, + }, +} + +#[derive(Debug)] +enum ReaderEvent { + Response(ServerResponse), + Notification, + Error(McpClientError), + Eof, +} + +#[derive(Debug, Serialize)] +struct ClientRequest<'a> { + jsonrpc: &'static str, + id: u64, + method: &'a str, + params: Value, +} + +#[derive(Debug, Serialize)] +struct ClientNotification<'a, T> { + jsonrpc: &'static str, + method: &'a str, + params: T, +} + +#[derive(Debug, Deserialize)] +struct IncomingMessage { + #[allow(dead_code)] + jsonrpc: Option, + id: Option, + method: Option, + result: Option, + error: Option, + #[allow(dead_code)] + params: Option, +} + +#[derive(Debug, Deserialize)] +struct ServerResponse { + id: u64, + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct RpcError { + code: i64, + message: String, + #[allow(dead_code)] + data: Option, +} + +#[derive(Debug, Serialize)] +struct ErrorResponse<'a> { + jsonrpc: &'static str, + id: Value, + error: ErrorObject<'a>, +} + +#[derive(Debug, Serialize)] +struct ErrorObject<'a> { + code: i64, + message: &'a str, +} + +fn spawn_stdout_reader( + server_name: String, + stdout: ChildStdout, + stdin: Arc>>, + tx: mpsc::Sender, + limits: McpStdioLimits, + redactor: Redactor, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut stdout = BufReader::new(stdout); + loop { + match read_protocol_line(&mut stdout, limits.max_stdout_line_bytes).await { + Ok(Some(line)) => match serde_json::from_slice::(&line) { + Ok(message) => { + handle_incoming_message( + &server_name, + &stdin, + &tx, + &limits, + &redactor, + message, + ) + .await + } + Err(err) => { + let _ = tx + .send(ReaderEvent::Error(McpClientError::new( + &server_name, + McpPhase::Running, + McpErrorKind::Protocol(format!( + "invalid stdout JSON-RPC message: {err}" + )), + ))) + .await; + break; + } + }, + Ok(None) => { + let _ = tx.send(ReaderEvent::Eof).await; + break; + } + Err(err) => { + let _ = tx + .send(ReaderEvent::Error(McpClientError::new( + &server_name, + McpPhase::Running, + McpErrorKind::Protocol(err), + ))) + .await; + break; + } + } + } + }) +} + +async fn handle_incoming_message( + server_name: &str, + stdin: &Arc>>, + tx: &mpsc::Sender, + limits: &McpStdioLimits, + redactor: &Redactor, + message: IncomingMessage, +) { + if message.method.is_some() && message.id.is_some() { + if let Some(id) = message.id { + let response = ErrorResponse { + jsonrpc: JSONRPC_VERSION, + id, + error: ErrorObject { + code: ERR_METHOD_NOT_FOUND, + message: "server-to-client requests are not supported by this client", + }, + }; + let _ = write_json_line( + server_name, + McpPhase::Running, + stdin, + &response, + limits.max_protocol_bytes, + redactor, + ) + .await; + } + return; + } + + if message.method.is_some() { + let _ = tx.send(ReaderEvent::Notification).await; + return; + } + + if let Some(id) = message.id.as_ref().and_then(Value::as_u64) { + let _ = tx + .send(ReaderEvent::Response(ServerResponse { + id, + result: message.result, + error: message.error, + })) + .await; + return; + } + + let _ = tx + .send(ReaderEvent::Error(McpClientError::new( + server_name, + McpPhase::Running, + McpErrorKind::Protocol( + "JSON-RPC response id was missing or not an unsigned integer".into(), + ), + ))) + .await; +} + +fn spawn_stderr_reader( + stderr: ChildStderr, + diagnostics: Arc>, + limits: McpStdioLimits, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut stderr = BufReader::new(stderr); + loop { + match read_diagnostic_line(&mut stderr, limits.max_stderr_line_bytes).await { + Ok(Some((line, truncated))) => diagnostics.lock().await.push(line, truncated), + Ok(None) => break, + Err(err) => { + diagnostics + .lock() + .await + .push(format!("stderr read error: {err}"), false); + break; + } + } + } + }) +} + +async fn write_json_line( + server_name: &str, + phase: McpPhase, + stdin: &Arc>>, + value: &T, + max_protocol_bytes: usize, + redactor: &Redactor, +) -> Result<(), McpClientError> { + let mut bytes = serde_json::to_vec(value).map_err(|err| { + McpClientError::new( + server_name, + phase, + McpErrorKind::Protocol(format!("failed to encode JSON-RPC message: {err}")), + ) + })?; + if bytes.len() > max_protocol_bytes { + return Err(McpClientError::new( + server_name, + phase, + McpErrorKind::Protocol(format!( + "JSON-RPC payload exceeded {max_protocol_bytes} bytes" + )), + )); + } + bytes.push(b'\n'); + let mut guard = stdin.lock().await; + let Some(stdin) = guard.as_mut() else { + return Err(McpClientError::new( + server_name, + phase, + McpErrorKind::Io("child stdin is closed".into()), + )); + }; + stdin.write_all(&bytes).await.map_err(|err| { + McpClientError::new( + server_name, + phase, + McpErrorKind::Io(redactor.redact(&err.to_string())), + ) + })?; + stdin.flush().await.map_err(|err| { + McpClientError::new( + server_name, + phase, + McpErrorKind::Io(redactor.redact(&err.to_string())), + ) + }) +} + +async fn read_protocol_line( + reader: &mut R, + max_bytes: usize, +) -> Result>, String> { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let read = reader + .read(&mut byte) + .await + .map_err(|err| err.to_string())?; + if read == 0 { + return if buf.is_empty() { + Ok(None) + } else { + Ok(Some(trim_newline(buf))) + }; + } + if byte[0] == b'\n' { + return Ok(Some(trim_newline(buf))); + } + if buf.len() >= max_bytes { + return Err(format!("stdout line exceeded {max_bytes} bytes")); + } + buf.push(byte[0]); + } +} + +async fn read_diagnostic_line( + reader: &mut R, + max_bytes: usize, +) -> Result, String> { + let mut buf = Vec::new(); + let mut truncated = false; + let mut byte = [0u8; 1]; + loop { + let read = reader + .read(&mut byte) + .await + .map_err(|err| err.to_string())?; + if read == 0 { + if buf.is_empty() && !truncated { + return Ok(None); + } + return Ok(Some(( + String::from_utf8_lossy(&trim_newline(buf)).into_owned(), + truncated, + ))); + } + if byte[0] == b'\n' { + return Ok(Some(( + String::from_utf8_lossy(&trim_newline(buf)).into_owned(), + truncated, + ))); + } + if buf.len() < max_bytes { + buf.push(byte[0]); + } else { + truncated = true; + } + } +} + +fn trim_newline(mut buf: Vec) -> Vec { + if buf.last() == Some(&b'\r') { + buf.pop(); + } + buf +} + +#[derive(Debug)] +struct BoundedDiagnostics { + server_name: String, + max_lines: usize, + lines: VecDeque, + dropped_lines: usize, + truncated_lines: usize, + redactor: Redactor, +} + +impl BoundedDiagnostics { + fn new(server_name: String, max_lines: usize, redactor: Redactor) -> Self { + Self { + server_name, + max_lines, + lines: VecDeque::new(), + dropped_lines: 0, + truncated_lines: 0, + redactor, + } + } + + fn push(&mut self, line: String, truncated: bool) { + if truncated { + self.truncated_lines += 1; + } + if self.max_lines == 0 { + self.dropped_lines += 1; + return; + } + if self.lines.len() == self.max_lines { + self.lines.pop_front(); + self.dropped_lines += 1; + } + let suffix = if truncated { "… [truncated]" } else { "" }; + self.lines + .push_back(format!("{}{suffix}", self.redactor.redact(&line))); + } + + fn snapshot(&self) -> McpDiagnostics { + McpDiagnostics { + server_name: self.server_name.clone(), + stderr: self.lines.iter().cloned().collect(), + dropped_stderr_lines: self.dropped_lines, + truncated_stderr_lines: self.truncated_lines, + } + } +} + +#[derive(Debug, Clone)] +struct Redactor { + values: Vec, +} + +impl Redactor { + fn new(mut values: Vec) -> Self { + values.retain(|value| !value.is_empty()); + values.sort_by_key(|value| std::cmp::Reverse(value.len())); + values.dedup(); + Self { values } + } + + fn redact(&self, input: &str) -> String { + let mut output = input.to_owned(); + for value in &self.values { + output = output.replace(value, "[redacted]"); + } + output + } +} + +#[cfg(unix)] +fn send_terminate(child: &mut Child) -> Result<(), ()> { + let Some(pid) = child.id() else { + return Err(()); + }; + let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; + if result == 0 { Ok(()) } else { Err(()) } +} + +#[cfg(not(unix))] +fn send_terminate(child: &mut Child) -> Result<(), ()> { + child.start_kill().map_err(|_| ()) +} diff --git a/crates/mcp/tests/fixtures/mock_server.rs b/crates/mcp/tests/fixtures/mock_server.rs new file mode 100644 index 00000000..74dcea70 --- /dev/null +++ b/crates/mcp/tests/fixtures/mock_server.rs @@ -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(); + } +} diff --git a/crates/mcp/tests/stdio_lifecycle.rs b/crates/mcp/tests/stdio_lifecycle.rs new file mode 100644 index 00000000..d805c57e --- /dev/null +++ b/crates/mcp/tests/stdio_lifecycle.rs @@ -0,0 +1,94 @@ +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() + } +} + +#[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())); +} diff --git a/package.nix b/package.nix index d027b05a..ec9c2f6d 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM="; + cargoHash = "sha256-EH4zdakrFxqVrgaNBx3dICN6KoLqskTEGYnU73XMVsU="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From 35e65339866f029c04e3e552619f2a74e83934c6 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:46:05 +0900 Subject: [PATCH 22/29] ticket: record mcp stdio lifecycle implementation report --- .yoi/tickets/00001KVHR3WRY/item.md | 2 +- .yoi/tickets/00001KVHR3WRY/thread.md | 57 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index 2fccde95..bdb0c0ab 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -2,7 +2,7 @@ title: 'MCP: implement stdio JSON-RPC lifecycle client' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:30:59Z' +updated_at: '2026-06-20T07:45:58Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index 70c3e0b9..bbcc4b37 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -110,3 +110,60 @@ Next action: - Wait for Coder implementation report, then inspect branch diff/validation evidence and route to Reviewer。 --- + + + +## Implementation report + +Coder implementation report received from `yoi-coder-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。 + +--- From c91ed5600f47f38fd994390233b947a5e37485b6 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:46:44 +0900 Subject: [PATCH 23/29] ticket: dispatch mcp stdio lifecycle review --- .yoi/tickets/00001KVHR3WRY/item.md | 2 +- .yoi/tickets/00001KVHR3WRY/thread.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index bdb0c0ab..375e1d1d 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -2,7 +2,7 @@ title: 'MCP: implement stdio JSON-RPC lifecycle client' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:45:58Z' +updated_at: '2026-06-20T07:46:38Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index bbcc4b37..cc7fe649 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -167,3 +167,16 @@ 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。 --- + + + +## 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。 + +--- From 39b55fb6e86d682ac89aeee69e0f403ee02385f9 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:51:36 +0900 Subject: [PATCH 24/29] ticket: request changes on mcp stdio lifecycle --- .yoi/tickets/00001KVHR3WRY/item.md | 2 +- .yoi/tickets/00001KVHR3WRY/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index 375e1d1d..73abedf8 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -2,7 +2,7 @@ title: 'MCP: implement stdio JSON-RPC lifecycle client' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:46:38Z' +updated_at: '2026-06-20T07:51:30Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index cc7fe649..45e92b89 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -180,3 +180,46 @@ Review dispatch: - Reviewer is instructed not to edit source, commit, merge, close the Ticket, or use TicketReview directly; it will report verdict/evidence back to Orchestrator。 --- + + + +## Review: request changes + +Verdict: `request_changes` + +確認範囲: +- Ticket contract。 +- 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` に 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。 + +--- From f396e1a25328b71e73c67abbaae19b47c4990aa1 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:54:48 +0900 Subject: [PATCH 25/29] mcp: redact stdio server spec debug --- crates/mcp/src/stdio.rs | 20 +++++++++++++++++++- crates/mcp/tests/stdio_lifecycle.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/mcp/src/stdio.rs b/crates/mcp/src/stdio.rs index 26171ed0..2e953712 100644 --- a/crates/mcp/src/stdio.rs +++ b/crates/mcp/src/stdio.rs @@ -52,7 +52,7 @@ impl Default for McpStdioLimits { } /// A resolved, explicit local stdio MCP server process specification. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct McpStdioServerSpec { pub name: String, pub command: String, @@ -107,6 +107,24 @@ impl McpStdioServerSpec { } } +impl fmt::Debug for McpStdioServerSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let env: BTreeMap<&str, &str> = self + .env + .keys() + .map(|name| (name.as_str(), "[redacted]")) + .collect(); + f.debug_struct("McpStdioServerSpec") + .field("name", &self.name) + .field("command", &self.command) + .field("args", &self.args) + .field("cwd", &self.cwd) + .field("env", &env) + .field("redaction_count", &self.redactions.len()) + .finish() + } +} + /// Resolve one explicitly named stdio server from typed MCP config. pub fn resolve_named_stdio_server( config: &McpConfig, diff --git a/crates/mcp/tests/stdio_lifecycle.rs b/crates/mcp/tests/stdio_lifecycle.rs index d805c57e..f4a51a92 100644 --- a/crates/mcp/tests/stdio_lifecycle.rs +++ b/crates/mcp/tests/stdio_lifecycle.rs @@ -19,6 +19,34 @@ fn tight_limits() -> McpStdioLimits { } } +#[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()) From 8e6b440f9afec5e11d8e4a2ef7567ef2e0b574c4 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:56:06 +0900 Subject: [PATCH 26/29] ticket: record mcp stdio lifecycle redaction fix --- .yoi/tickets/00001KVHR3WRY/item.md | 2 +- .yoi/tickets/00001KVHR3WRY/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index 73abedf8..54ae69e5 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -2,7 +2,7 @@ title: 'MCP: implement stdio JSON-RPC lifecycle client' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:51:30Z' +updated_at: '2026-06-20T07:55:55Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index 45e92b89..f3135d91 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -223,3 +223,46 @@ Reviewer validation: Worktree status at review end: clean。 --- + + + +## 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。 + +--- From e5510620cc13043bb0097d698f5b220cb0402a64 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:58:21 +0900 Subject: [PATCH 27/29] ticket: approve mcp stdio lifecycle client --- .yoi/tickets/00001KVHR3WRY/item.md | 2 +- .yoi/tickets/00001KVHR3WRY/thread.md | 36 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index 54ae69e5..52674c28 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -2,7 +2,7 @@ title: 'MCP: implement stdio JSON-RPC lifecycle client' state: 'inprogress' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:55:55Z' +updated_at: '2026-06-20T07:58:02Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index f3135d91..c9cf1824 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -266,3 +266,39 @@ Next action: - Route back to Reviewer for r2 verification of the redacted `Debug` blocker and final acceptance criteria。 --- + + + +## 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。 + +--- From ca28c927b21f5422608423117a807e172cfe8dd7 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:59:17 +0900 Subject: [PATCH 28/29] ticket: complete mcp stdio lifecycle client --- .yoi/tickets/00001KVHR3WRY/item.md | 4 +-- .yoi/tickets/00001KVHR3WRY/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index 52674c28..13052108 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -1,8 +1,8 @@ --- title: 'MCP: implement stdio JSON-RPC lifecycle client' -state: 'inprogress' +state: 'done' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:58:02Z' +updated_at: '2026-06-20T07:59:10Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index c9cf1824..110315f2 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -302,3 +302,46 @@ Reviewer validation: Worktree status at review end: clean。 --- + + + +## 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。 + +--- + + + +## 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`. + +--- From 68a8fc97d21b59c8572cd7c52a59e907427be512 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 16:59:37 +0900 Subject: [PATCH 29/29] ticket: close mcp stdio lifecycle client --- .yoi/tickets/00001KVHR3WRY/item.md | 4 +- .yoi/tickets/00001KVHR3WRY/resolution.md | 37 +++++++++++++++++ .yoi/tickets/00001KVHR3WRY/thread.md | 53 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KVHR3WRY/resolution.md diff --git a/.yoi/tickets/00001KVHR3WRY/item.md b/.yoi/tickets/00001KVHR3WRY/item.md index 13052108..dd5167cd 100644 --- a/.yoi/tickets/00001KVHR3WRY/item.md +++ b/.yoi/tickets/00001KVHR3WRY/item.md @@ -1,8 +1,8 @@ --- title: 'MCP: implement stdio JSON-RPC lifecycle client' -state: 'done' +state: 'closed' created_at: '2026-06-20T05:30:04Z' -updated_at: '2026-06-20T07:59:10Z' +updated_at: '2026-06-20T07:59:30Z' assignee: null readiness: 'implementation_ready' risk_flags: ['mcp', 'stdio', 'json-rpc', 'process-lifecycle', 'diagnostics'] diff --git a/.yoi/tickets/00001KVHR3WRY/resolution.md b/.yoi/tickets/00001KVHR3WRY/resolution.md new file mode 100644 index 00000000..b413ca8d --- /dev/null +++ b/.yoi/tickets/00001KVHR3WRY/resolution.md @@ -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` \ No newline at end of file diff --git a/.yoi/tickets/00001KVHR3WRY/thread.md b/.yoi/tickets/00001KVHR3WRY/thread.md index 110315f2..efd125ea 100644 --- a/.yoi/tickets/00001KVHR3WRY/thread.md +++ b/.yoi/tickets/00001KVHR3WRY/thread.md @@ -345,3 +345,56 @@ Final state: 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`. --- + + + +## State changed + +Ticket を 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` + +---