From 592f7bc3e9fb3eae1036918ba5cff927f9643a72 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 29 Sep 2026 01:56:43 -0700 Subject: [PATCH 1/8] chore: preserve interrupted extension-host regression Checkpoint the original uncommitted isolated-engine regression before recovering the 23-file phase-1 implementation from /private/tmp/cw-lanes/tsx-fixes-work.patch. Original agent ac38eaf89e9209cb2 saved the implementation and restored this test for a fail-without-fix run before quota interruption. The retained red-check log ends during compilation, so this checkpoint claims no completed validation. No new implementation in this commit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks Signed-off-by: Hunter B --- crates/tui/src/core/engine/tests.rs | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 3cb55cb962..2e2a6bb9fd 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -27244,6 +27244,99 @@ async fn extension_tool_is_deferred_gated_and_attributed_on_the_model_path() { manager.shutdown().await; } +/// Engines in one process share the extension host, but an engine with no +/// plugin snapshot of its own (an isolated chat falls back to an empty +/// registry) must not revoke the plugins another engine is using, neither when +/// it starts nor at its turn builds. +#[tokio::test] +async fn an_isolated_chat_engine_never_revokes_another_engines_extension() { + use crate::llm_client::mock::{MockLlmClient, canned}; + + let Some(node) = crate::extension_host::tests::node_for_tests( + "an_isolated_chat_engine_never_revokes_another_engines_extension", + ) else { + return; + }; + let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true); + let fixture = crate::extension_host::tests::FixturePlugins::new(&["slow-tool"]).await; + let plugin_id = fixture + .registry() + .get("slow-tool") + .expect("fixture plugin") + .id + .as_str() + .to_string(); + let manager = fixture.manager(node); + let _manager = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager)); + let config = Config::default(); + + // The workspace engine activates the plugin in the background. + let mut workspace_config = deterministic_engine_config(fixture.workspace()); + workspace_config.features.enable(Feature::ExtensionHost); + workspace_config.plugin_registry = Some(fixture.registry()); + let idle_client: crate::core::model_client::SharedModelClient = + std::sync::Arc::new(MockLlmClient::new(Vec::new())); + let (workspace_engine, _workspace_handle) = + Engine::new_with_model_client(workspace_config, &config, idle_client); + let deadline = Instant::now() + Duration::from_secs(20); + while manager.owner_state(&plugin_id) + != Some(crate::extension_host::registry::OwnerState::Active) + { + assert!( + Instant::now() < deadline, + "the workspace engine never activated its plugin: {:?}", + manager.diagnostics() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // An isolated chat starts and runs a turn in the same process. + let chat_dir = tempdir().expect("chat dir"); + let mut chat_config = deterministic_engine_config(chat_dir.path()); + chat_config.features.enable(Feature::ExtensionHost); + chat_config.plugin_registry = None; + let chat_client: crate::core::model_client::SharedModelClient = + std::sync::Arc::new(MockLlmClient::new(vec![canned::simple_text_turn("hello")])); + let (chat_engine, chat_handle) = + Engine::new_with_model_client(chat_config, &config, chat_client); + let task = tokio::spawn(chat_engine.run()); + chat_handle + .send(external_user_message_op("hi", AppMode::Agent, &config)) + .await + .expect("send turn"); + { + let mut rx = chat_handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for the chat turn") + .expect("engine event stream closed"); + if let Event::TurnComplete { .. } = event { + break; + } + } + } + // Give any reconcile the chat engine kicked time to finish. + tokio::time::sleep(Duration::from_millis(1500)).await; + + assert_eq!( + manager.owner_state(&plugin_id), + Some(crate::extension_host::registry::OwnerState::Active), + "the isolated chat revoked the workspace engine's plugin: {:?}", + manager.diagnostics() + ); + assert!( + !manager.diagnostics().iter().any(|d| d.contains("revoked")), + "{:?}", + manager.diagnostics() + ); + assert_eq!(manager.spawn_attempts(), 1); + chat_handle.send(Op::Shutdown).await.expect("shutdown chat"); + task.await.expect("chat engine task"); + drop(workspace_engine); + manager.shutdown().await; +} + /// Extension host phase 1, acceptance 7: with the flag off the engine never /// touches the extension host, even when a native plugin is installed. #[tokio::test] From 862f4d240f2f5d4ab51722e18db0a43680537407 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 29 Sep 2026 02:23:22 -0700 Subject: [PATCH 2/8] fix: isolate extension-host ownership and bind approval grants Recover the interrupted Phase 1 implementation from the preserved 23-file patch after checkpointing its isolated-engine regression. Give each engine its own attachment to the process-wide host, reconcile the reviewed union, and install only that engine's tools. Publish a completed scan only when the entire attachment set still matches, so a workspace change cannot activate or publish stale owners. Bind extension approval and session-grant keys to the plugin identity and reviewed content receipt. Cover approval-gated code-mode execution and preserve the ungated refusal. Share the regular .mjs/.js entry rule between discovery and activation, report invalid entries in review, and correct extension-host/skill authoring guidance while retaining the previous bundled skill generation. Validation on the final source tree: - Focused Rust host/engine/approval/plugin/skill tests: 532 passed, 0 failed, 1 ignored subprocess entry point (exercised by process tests), 13297 filtered. CODEWHALE_EXT_HOST_TESTS=1 required real Node fixtures; no provider calls. - npm test: 636 passed, 0 failed (68 wrapper, 16 SDK, 50 host, 502 web). - npm run check:web: exit 0; facts, docs, tokens, lint, types and production build passed. Existing canonical Git objects supplied the partial clone's missing history without changing generated dates. - cargo fmt --all -- --check and git diff --check passed. The earlier interrupted red-check never completed and is not claimed as evidence. Phase 2 supervision/restart work is not included; existing experimental platform sandbox limitations remain documented. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks Signed-off-by: Hunter B --- config.example.toml | 8 +- crates/tui/assets/skills-catalog-matrix.json | 2 +- .../plugin-creator/SKILL.generation-15.md | 62 +++ .../tui/assets/skills/plugin-creator/SKILL.md | 54 +-- crates/tui/src/core/engine.rs | 58 ++- crates/tui/src/core/engine/approval.rs | 92 +++++ crates/tui/src/core/engine/tests.rs | 9 +- crates/tui/src/core/engine/turn_loop.rs | 51 ++- crates/tui/src/extension_host/mod.rs | 333 ++++++++++++++-- crates/tui/src/extension_host/registry.rs | 37 +- crates/tui/src/extension_host/supervisor.rs | 6 + crates/tui/src/extension_host/tests.rs | 372 ++++++++++++++++-- crates/tui/src/extension_host/tool.rs | 17 +- crates/tui/src/plugins/discovery.rs | 20 + crates/tui/src/plugins/runtime.rs | 22 ++ crates/tui/src/skills/system.rs | 9 +- crates/tui/src/skills/system/tests.rs | 35 ++ crates/tui/src/tools/approval_cache.rs | 28 ++ crates/tui/src/tools/spec.rs | 11 + docs/PLUGIN_AUTHORING.md | 10 +- docs/PLUGIN_BUNDLES.md | 26 +- docs/design/TS_EXTENSION_HOST.md | 47 ++- docs/zh_hans/PLUGIN_AUTHORING.md | 7 +- 23 files changed, 1144 insertions(+), 172 deletions(-) create mode 100644 crates/tui/assets/skills/plugin-creator/SKILL.generation-15.md diff --git a/config.example.toml b/config.example.toml index b9c02079a2..2bd730af80 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1238,8 +1238,12 @@ exec_policy = true # Set false to remove it from the model's tool catalog. # extension_host = false # EXPERIMENTAL. Run reviewed plugins' `native` host code # (TypeScript/JavaScript, Cordis/DSH plugin model) in a - # Node.js sidecar. Tools only in this phase; every - # extension tool always asks for approval. On macOS + # Node.js sidecar. Tools only in this phase. Every + # extension tool is always Required and never + # read-only; like any Required tool it runs without + # a prompt under Full Access, under Bypass, or with a + # matching session grant, which is bound to the + # plugin's reviewed build (an update asks again). On macOS # the host runs under Seatbelt (no network, no reads of # ~/.codewhale secrets/credentials/config/sessions, # writes only to its data dir and temp dirs); on Linux diff --git a/crates/tui/assets/skills-catalog-matrix.json b/crates/tui/assets/skills-catalog-matrix.json index 0add4f8304..4502159113 100644 --- a/crates/tui/assets/skills-catalog-matrix.json +++ b/crates/tui/assets/skills-catalog-matrix.json @@ -15,7 +15,7 @@ "in_model_catalogue": "true when the skill renders as an ambient catalogue line", "shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution" }, - "generation": "15", + "generation": "16", "skills": [ { "name": "skill-creator", diff --git a/crates/tui/assets/skills/plugin-creator/SKILL.generation-15.md b/crates/tui/assets/skills/plugin-creator/SKILL.generation-15.md new file mode 100644 index 0000000000..967779687c --- /dev/null +++ b/crates/tui/assets/skills/plugin-creator/SKILL.generation-15.md @@ -0,0 +1,62 @@ +--- +name: plugin-creator +description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review. +--- + +# Plugin Creator + +Use this skill when a user wants a local Codewhale plugin bundle. Trusted and +enabled bundles may add declarative Skills, commands, agents, hooks, and MCP +servers (stdio and remote) through the existing engines. LSP, native +extensions, filesystem roots, and lifecycle mutation are inventory-only. + +## Workflow + +1. Pick a Codewhale-owned location: + - User bundle: `~/.codewhale/plugins//` + - Workspace bundle: `/.codewhale/plugins//` +2. Normalize the bundle name to lowercase hyphen-case. +3. Create `plugin.toml`: + +```toml +schema_version = 1 + +[plugin] +name = "my-plugin" +version = "0.1.0" +description = "What this bundle provides" + +[skills] +path = "skills" +``` + +4. Put each Skill under `skills//SKILL.md`. Codewhale exposes it + as `my-plugin:`, never as an unqualified command. +5. Add `[mcp_servers.]` only when the bundle needs an existing MCP + engine. Keep stdio commands and paths inside the bundle. Map local + environment values only as exact `${SOURCE_ENV}` references. For remote MCP, + use HTTPS (or loopback HTTP), forbid URL user information/query/fragment, + use only environment-backed headers or bearer tokens, and declare the exact + normalized endpoint host set in `[capabilities].network_hosts`. Never place + credentials in the manifest. +6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks + (`hooks/*.toml`) activate under the current policy — workspace bundles win + same-name collisions over user and built-in bundles. LSP, native + extensions, filesystem roots, and lifecycle mutation are inventory-only: + declare them only when inventorying future work. A bundle that declares + only unsupported surfaces cannot be enabled. +7. Validate and review without executing bundle content: + - `/plugin validate ` + - `/plugin show ` + - `/plugin enable ` to open the content/capability review + - run the exact `/plugin trust ...` confirmation shown, then enable again +8. Verify `/skills inspect` reports plugin provenance and `/plugin list` + reports the expected trust and activation state. Trust stages the reviewed + content but does not activate it. After enablement, follow the host's + reload notice: use `/reload` or a new session to apply changes to a live + session's pinned skills and tools. + +Every user and workspace bundle starts untrusted and disabled. Reuse the +existing `/plugin marketplace`, install, update, review and reload surfaces; +do not add a parallel installer, registry or automatic trust flow. Catalog +membership alone never installs, trusts or enables a plugin. diff --git a/crates/tui/assets/skills/plugin-creator/SKILL.md b/crates/tui/assets/skills/plugin-creator/SKILL.md index 967779687c..14bcbc06d1 100644 --- a/crates/tui/assets/skills/plugin-creator/SKILL.md +++ b/crates/tui/assets/skills/plugin-creator/SKILL.md @@ -7,8 +7,10 @@ description: Scaffold a local Codewhale plugin bundle with a versioned manifest, Use this skill when a user wants a local Codewhale plugin bundle. Trusted and enabled bundles may add declarative Skills, commands, agents, hooks, and MCP -servers (stdio and remote) through the existing engines. LSP, native -extensions, filesystem roots, and lifecycle mutation are inventory-only. +servers (stdio and remote) through the existing engines. LSP, filesystem +roots, and lifecycle mutation are inventory-only. Native extensions (host +code) are inventory-only unless the user has turned on the experimental +`[features] extension_host` flag. ## Workflow @@ -16,35 +18,39 @@ extensions, filesystem roots, and lifecycle mutation are inventory-only. - User bundle: `~/.codewhale/plugins//` - Workspace bundle: `/.codewhale/plugins//` 2. Normalize the bundle name to lowercase hyphen-case. -3. Create `plugin.toml`: +3. Create `plugin.json` (Agent Plugins v1.0.0; a legacy `plugin.toml` stays + readable, but new bundles use `plugin.json`): -```toml -schema_version = 1 - -[plugin] -name = "my-plugin" -version = "0.1.0" -description = "What this bundle provides" - -[skills] -path = "skills" +```json +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "my-plugin", + "version": "0.1.0", + "description": "What this bundle provides" +} ``` -4. Put each Skill under `skills//SKILL.md`. Codewhale exposes it - as `my-plugin:`, never as an unqualified command. -5. Add `[mcp_servers.]` only when the bundle needs an existing MCP - engine. Keep stdio commands and paths inside the bundle. Map local +4. Put each Skill under `skills//SKILL.md`; Codewhale finds + `skills/` automatically and exposes each as `my-plugin:`, + never as an unqualified command. +5. Add MCP servers in a sibling `mcp.json` only when the bundle needs an + existing MCP engine. Keep stdio commands and paths inside the bundle. Map local environment values only as exact `${SOURCE_ENV}` references. For remote MCP, use HTTPS (or loopback HTTP), forbid URL user information/query/fragment, use only environment-backed headers or bearer tokens, and declare the exact - normalized endpoint host set in `[capabilities].network_hosts`. Never place - credentials in the manifest. + normalized endpoint host set in `capabilities.network_hosts` under + `extensions["net.codewhale"]`. Never place credentials in the manifest. 6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks - (`hooks/*.toml`) activate under the current policy — workspace bundles win - same-name collisions over user and built-in bundles. LSP, native - extensions, filesystem roots, and lifecycle mutation are inventory-only: - declare them only when inventorying future work. A bundle that declares - only unsupported surfaces cannot be enabled. + (`hooks/*.toml`), declared under `extensions["net.codewhale"]`, activate + under the current policy — workspace bundles win same-name collisions over + user and built-in bundles. LSP, filesystem roots, and lifecycle mutation + are inventory-only: declare them only when inventorying future work. A + `native` entry runs only under the experimental extension host; there it + must be one `.mjs` or `.js` ES module file, `/plugin validate` rejects + anything else, and its tools always use `Required` approval, never a + plugin's read-only hint. Full Access, Bypass, or an exact session grant + for the reviewed build can satisfy that gate without a prompt. A bundle + that declares only unsupported surfaces cannot be enabled. 7. Validate and review without executing bundle content: - `/plugin validate ` - `/plugin show ` diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6d7d6da0b3..08420231a0 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -906,6 +906,11 @@ pub struct Engine { mcp_event_generation: u64, /// Workspace-scoped immutable plugin catalogue and authority receipts. plugin_registry: Arc, + /// This engine's hold on the process-wide extension host (`[features] + /// extension_host`), carrying `plugin_registry`. `None` with the flag off + /// and for engines without a plugin snapshot of their own (isolated + /// chats), which must never revoke another engine's plugins. + extension_host: Option, api_provider: ApiProvider, /// Exact configured route key. Named custom providers share the `Custom` /// enum, so the enum alone cannot prove that the active client is current. @@ -1687,19 +1692,27 @@ impl Engine { let compaction_cancellation = Arc::new(StdMutex::new(CompactionCancellationState::default())); let tool_exec_lock = Arc::new(RwLock::new(())); - let plugin_registry = config + let own_plugin_registry = config .plugin_registry .as_ref() .filter(|registry| registry.workspace() == config.workspace) - .cloned() - .unwrap_or_else(|| Arc::new(crate::plugins::PluginRegistry::empty(&config.workspace))); + .cloned(); // Experimental extension host: start in the background, never on the - // first-prompt path. Its tools join at the next turn's rebuild. - if config.features.enabled(Feature::ExtensionHost) { - let manager = crate::extension_host::manager(); - manager.begin_session(); - manager.sync_in_background(Arc::clone(&plugin_registry)); - } + // first-prompt path. Its tools join at the next turn's rebuild. Only + // an engine with its own plugin snapshot attaches; the empty fallback + // below would desire nothing and must not affect other engines. + let extension_host = own_plugin_registry + .as_ref() + .filter(|_| config.features.enabled(Feature::ExtensionHost)) + .map(|registry| { + let manager = crate::extension_host::manager(); + manager.begin_session(); + let attachment = manager.attach(Arc::clone(registry)); + attachment.sync_in_background(); + attachment + }); + let plugin_registry = own_plugin_registry + .unwrap_or_else(|| Arc::new(crate::plugins::PluginRegistry::empty(&config.workspace))); // Create clients for both providers let (codewhale_client, codewhale_client_error) = match CodewhaleClient::new(api_config) { @@ -1931,6 +1944,7 @@ impl Engine { mcp_boot_generation: None, mcp_event_generation: 0, plugin_registry, + extension_host, api_provider, api_provider_identity, api_provider_id, @@ -3506,6 +3520,10 @@ impl Engine { // A pool may contain plugin servers and authority // receipts from the previous workspace snapshot. self.mcp_pool = None; + if let Some(attachment) = &self.extension_host { + attachment.set_plugins(Arc::clone(&self.plugin_registry)); + attachment.sync_in_background(); + } } let ctx = crate::project_context::load_project_context_with_parents(&workspace); @@ -5111,21 +5129,23 @@ impl Engine { // config.toml overrides. Explicit overrides win over auto-discovered // scripts with the same tool name. let extension_host = self - .config - .features - .enabled(Feature::ExtensionHost) - .then(crate::extension_host::manager); - if let Some(manager) = &extension_host { + .extension_host + .as_ref() + .filter(|_| self.config.features.enabled(Feature::ExtensionHost)); + if let Some(attachment) = extension_host { // Natives only: scripts are added next and must not count as built-ins. - manager.note_native_names(tool_registry.names()); - manager.sync_in_background(Arc::clone(&self.plugin_registry)); + attachment + .manager() + .note_native_names(tool_registry.names()); + attachment.sync_in_background(); } let mut plugin_tool_names = configure_plugin_tools(&mut tool_registry, self.config.tools.as_ref()); // Extension tools go in last and never replace a name already present - // (`ToolRegistry::register` would overwrite it silently). - if let Some(manager) = &extension_host { - plugin_tool_names.extend(manager.install_tools(&mut tool_registry)); + // (`ToolRegistry::register` would overwrite it silently). Only this + // engine's own plugins' tools are installed. + if let Some(attachment) = extension_host { + plugin_tool_names.extend(attachment.install_tools(&mut tool_registry)); } let mcp_state = if self.config.features.enabled(Feature::Mcp) { diff --git a/crates/tui/src/core/engine/approval.rs b/crates/tui/src/core/engine/approval.rs index d8248c262b..be086e48fd 100644 --- a/crates/tui/src/core/engine/approval.rs +++ b/crates/tui/src/core/engine/approval.rs @@ -1153,6 +1153,98 @@ mod tests { ); } + /// Extension host acceptance 2 with code mode's gate (#6562 landed before + /// #6600): an `execute_tools` program calling an extension tool in a + /// main-session turn suspends for approval under a `.` id, + /// attributed to `extension:`, and no `tool/call` reaches the host + /// before a person allows it. Allow returns the host's result to the + /// program; deny fails only that nested call. + #[tokio::test] + async fn execute_tools_gates_an_extension_tool_before_any_host_call() { + let Some(node) = crate::extension_host::tests::node_for_tests( + "execute_tools_gates_an_extension_tool_before_any_host_call", + ) else { + return; + }; + let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true); + let fixture = crate::extension_host::tests::FixturePlugins::new(&["slow-tool"]).await; + let manager = fixture.manager(node); + let attachment = manager.attach(fixture.registry()); + attachment.sync().await.expect("host activation"); + let tool = + crate::extension_host::tests::host_tool(&attachment, fixture.workspace(), "slow_wait"); + let sent_before = manager.host_requests_started().expect("host running"); + + let code = "const first = await tools.call('slow_wait', { ms: 20 }); \ + let denied = null; \ + try { await tools.call('slow_wait', { ms: 20 }); } \ + catch (e) { denied = String(e.message || e); } \ + return { first: first.content, denied };"; + let mut turn = start_nested_program_turn_with( + code, + NestedTurnOptions { + tools: vec![tool], + ..NestedTurnOptions::default() + }, + ); + let events = turn.events.clone(); + let handle = turn.handle.clone(); + + let mut seen = Vec::new(); + let (id, tool_name, description) = next_approval(&events, &mut seen).await; + assert_eq!(id, "exec-1.1"); + assert_eq!(tool_name, "slow_wait"); + assert!( + description.contains("execute_tools program call") + && description.contains("extension:slow-tool"), + "{description}" + ); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut turn.task) + .await + .is_err(), + "the program is suspended on its nested call" + ); + assert_eq!( + manager.host_requests_started(), + Some(sent_before), + "no tool/call before approval" + ); + handle.approve_tool_call("exec-1.1").await.expect("allow"); + + let (id, _, _) = next_approval(&events, &mut seen).await; + assert_eq!(id, "exec-1.2"); + assert_eq!( + manager.host_requests_started(), + Some(sent_before + 1), + "allow sent exactly one tool/call" + ); + handle.deny_tool_call("exec-1.2").await.expect("deny"); + + let receipt = finish_nested_program_turn(&mut turn, &mut seen).await; + assert_eq!(receipt["success"], true, "{receipt}"); + assert_eq!( + receipt["body"]["return"]["first"]["waited"], 20, + "the host's result reached the program: {receipt}" + ); + assert!( + receipt["body"]["return"]["denied"] + .as_str() + .is_some_and(|message| message.contains("denied by user")), + "{receipt}" + ); + assert_eq!(receipt["calls"][0]["decision"], "approved"); + assert_eq!(receipt["calls"][0]["status"], "ok"); + assert_eq!(receipt["calls"][1]["decision"], "denied"); + assert_eq!(receipt["calls"][1]["status"], "refused"); + assert_eq!( + manager.host_requests_started(), + Some(sent_before + 1), + "the denied call never reached the host" + ); + manager.shutdown().await; + } + /// #6562: a nested call never runs on a posture the user has since /// narrowed. Narrowing while a nested approval card is open fails that /// call even though it was approved (same rule as a direct call), and diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 2e2a6bb9fd..85d65c8975 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -27140,10 +27140,8 @@ async fn extension_tool_is_deferred_gated_and_attributed_on_the_model_path() { let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true); let fixture = crate::extension_host::tests::FixturePlugins::new(&["dsh-workspace-deps"]).await; let manager = fixture.manager(node); - manager - .sync(fixture.registry()) - .await - .expect("host activation"); + let warm = manager.attach(fixture.registry()); + warm.sync().await.expect("host activation"); let _manager = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager)); let mock = std::sync::Arc::new(MockLlmClient::new(vec![ @@ -27161,6 +27159,9 @@ async fn extension_tool_is_deferred_gated_and_attributed_on_the_model_path() { engine_config.features.enable(Feature::ExtensionHost); engine_config.plugin_registry = Some(fixture.registry()); let (engine, handle) = Engine::new_with_model_client(engine_config, &config, client); + // The engine attached its own snapshot; let a reconcile publish what it + // desires before its first turn build installs tools. + manager.reconcile().await.expect("reconcile"); let task = tokio::spawn(engine.run()); handle .send(external_user_message_op( diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index c4f573ec62..b89166a275 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -528,19 +528,17 @@ impl Engine { return Some("a before-tool hook changed the code".to_string()); } let approved = if plan.approval_required { + let (approval_key, approval_grouping_key) = + crate::tools::approval_cache::approval_keys_for_call( + tool_registry, + tool_name, + &plan.input, + ); let event = Event::ApprovalRequired { id: approval_id.to_string(), tool_name: tool_name.to_string(), - approval_key: crate::tools::approval_cache::build_approval_key( - tool_name, - &plan.input, - ) - .0, - approval_grouping_key: crate::tools::approval_cache::build_approval_grouping_key( - tool_name, - &plan.input, - ) - .0, + approval_key: approval_key.0, + approval_grouping_key: approval_grouping_key.0, input: plan.input, description: format!( "Run the reply's ```repl block(s) in the session REPL kernel (a local \ @@ -4561,17 +4559,14 @@ impl Engine { "tool_id": tool_id.clone(), "tool_name": tool_name.clone(), })); - let approval_key = crate::tools::approval_cache::build_approval_key( - &tool_name, - &tool_input, - ) - .0; - let approval_grouping_key = - crate::tools::approval_cache::build_approval_grouping_key( + let (approval_key, approval_grouping_key) = + crate::tools::approval_cache::approval_keys_for_call( + tool_registry, &tool_name, &tool_input, - ) - .0; + ); + let (approval_key, approval_grouping_key) = + (approval_key.0, approval_grouping_key.0); let approval_event = Event::ApprovalRequired { id: tool_id.clone(), tool_name: tool_name.clone(), @@ -5099,21 +5094,19 @@ impl Engine { "caller": "code_mode", "parent_tool_id": parent_id, })); + let (approval_key, approval_grouping_key) = + crate::tools::approval_cache::approval_keys_for_call( + tool_registry, + &plan.name, + &plan.input, + ); let approval_event = Event::ApprovalRequired { id: nested_id.clone(), tool_name: plan.name.clone(), input: plan.input.clone(), description: format!("execute_tools program call: {}", plan.approval_description), - approval_key: crate::tools::approval_cache::build_approval_key( - &plan.name, - &plan.input, - ) - .0, - approval_grouping_key: crate::tools::approval_cache::build_approval_grouping_key( - &plan.name, - &plan.input, - ) - .0, + approval_key: approval_key.0, + approval_grouping_key: approval_grouping_key.0, intent_summary: None, approval_force_prompt: plan.approval_force_prompt, }; diff --git a/crates/tui/src/extension_host/mod.rs b/crates/tui/src/extension_host/mod.rs index 96e491362f..d2a804608a 100644 --- a/crates/tui/src/extension_host/mod.rs +++ b/crates/tui/src/extension_host/mod.rs @@ -8,12 +8,25 @@ //! `ToolSpec`s ([`tool::HostToolSpec`]) behind the existing gate. //! //! Lifecycle: a reviewed, enabled plugin with a `native` entry (activation -//! policy v4, selected by the flag) makes [`ExtensionHostManager::sync`] +//! policy v4, selected by the flag) makes [`ExtensionHostManager::reconcile`] //! spawn the host in the background — never on the first-prompt path — and //! activate one owner per plugin. Tools join the per-turn registry at the next //! rebuild, deferred. Disabling, revoking or updating the plugin revokes its //! registrations synchronously before the host is asked to tear down. //! +//! Engines: the manager and its one host are process-wide, but each engine +//! holds its own [`HostAttachment`] carrying the plugin snapshot of its +//! workspace. Reconcile activates the union of what every attached snapshot +//! desires and revokes only owners no attachment desires. Each snapshot is +//! re-verified against persisted plugin state on every reconcile, so a +//! disable or revoke made through any registry revokes the plugin for every +//! engine. An engine installs only the tools of owners its own snapshot +//! desires, so a workspace never sees another workspace's project plugins. +//! Dropping an attachment detaches without revoking; the next reconcile +//! revokes whatever no remaining attachment desires. Engines without a +//! plugin snapshot of their own (isolated chats, the empty fallback) never +//! attach. +//! //! Known limitations (phase 1, by design — see the design doc §8): //! * Tools only: no commands, hooks, skills, prompt sections, MCP, or //! `core/call` (the host cannot ask the core to do anything). @@ -43,10 +56,11 @@ //! the group and is not killed with it. When the core goes away, the host //! kills its own group at stdin EOF, and a watchdog thread does the same //! when its parent process changes, even if a plugin blocks the event loop. -//! * One manager per process: `sync` reconciles against the calling engine's -//! plugin registry, so engines for different workspaces in one process -//! would revoke each other's plugins. Only one workspace runs per process -//! today. +//! * A running engine's snapshot is replaced only by its own workspace +//! switch or by [`plugins_changed`] for the same workspace. A plugin newly +//! enabled through another workspace's registry reaches an engine at its +//! next snapshot, not at once; disables and revokes always reach it at the +//! next reconcile, through the persisted-state check. //! * The host re-hashes each `native` entry file before importing it; other //! files in the staged snapshot are covered by Rust's per-call receipt //! check, not re-hashed by the host. @@ -180,8 +194,17 @@ struct DesiredOwner { entries: Vec<(PathBuf, String)>, } +/// One engine's view: its plugin snapshot and the owners (plugin id → +/// reviewed content hash) that snapshot desired at the last reconcile. +struct AttachmentState { + plugins: Arc, + desired: BTreeMap, +} + pub(crate) struct ManagerShared { options: ExtensionHostOptions, + attachments: Mutex>, + next_attachment: AtomicU64, registry: Mutex, host: Mutex, host_generation: AtomicU64, @@ -335,6 +358,8 @@ impl ExtensionHostManager { Self { shared: Arc::new(ManagerShared { options, + attachments: Mutex::new(BTreeMap::new()), + next_attachment: AtomicU64::new(0), registry: Mutex::new(OwnerRegistry::new()), host: Mutex::new(HostSlot::Idle), host_generation: AtomicU64::new(0), @@ -423,27 +448,97 @@ impl ExtensionHostManager { self.shared.seen_plugins.lock().expect("seen lock").clear(); } - /// Record the native tool names (the registry before scripts, plugins and - /// extensions are added) so `registry/register` refuses collisions. + /// Record native tool names from one engine's turn build (the registry + /// before scripts, plugins and extensions are added) so + /// `registry/register` refuses collisions. Additive: engines in one + /// process report different native surfaces and none may shrink the set. pub fn note_native_names<'a>(&self, names: impl IntoIterator) { self.shared .registry .lock() .expect("registry lock") - .set_native_names(names); + .add_native_names(names); } - /// Add every live extension tool to `tool_registry`, *after* natives and - /// `~/.codewhale/tools` scripts. A name already present is skipped with a - /// diagnostic — `ToolRegistry::register` would silently overwrite it. - /// Returns the names added. - pub fn install_tools(&self, tool_registry: &mut crate::tools::ToolRegistry) -> Vec { - let tools = self + /// Attach an engine whose workspace plugin snapshot is `plugins`. Nothing + /// is reconciled until [`HostAttachment::sync`] or a background sync. + #[must_use] + pub fn attach(self: &Arc, plugins: Arc) -> HostAttachment { + let id = self.shared.next_attachment.fetch_add(1, Ordering::SeqCst) + 1; + self.shared + .attachments + .lock() + .expect("attachments lock") + .insert( + id, + AttachmentState { + plugins, + desired: BTreeMap::new(), + }, + ); + HostAttachment { + id, + manager: Arc::clone(self), + } + } + + /// How many engines are attached. + #[must_use] + pub fn attached_engines(&self) -> usize { + self.shared + .attachments + .lock() + .expect("attachments lock") + .len() + } + + /// Replace the snapshot of every engine attached to `plugins`'s + /// workspace: a plugin was enabled, disabled, trusted or revoked there. + fn refresh_workspace(&self, plugins: &Arc) { + for state in self + .shared + .attachments + .lock() + .expect("attachments lock") + .values_mut() + { + if state.plugins.workspace() == plugins.workspace() { + state.plugins = Arc::clone(plugins); + state.desired.clear(); + } + } + } + + /// Add the live tools of the owners attachment `id` desires to + /// `tool_registry`, *after* natives and `~/.codewhale/tools` scripts. A + /// name already present is skipped with a diagnostic — + /// `ToolRegistry::register` would silently overwrite it. Returns the + /// names added. + fn install_tools_for( + &self, + id: u64, + tool_registry: &mut crate::tools::ToolRegistry, + ) -> Vec { + let desired = self + .shared + .attachments + .lock() + .expect("attachments lock") + .get(&id) + .map(|state| state.desired.clone()) + .unwrap_or_default(); + if desired.is_empty() { + return Vec::new(); + } + let tools: Vec = self .shared .registry .lock() .expect("registry lock") - .live_tools(); + .live_tools() + .into_iter() + .filter(|tool| desired.get(&tool.owner.plugin_id) == Some(&tool.content_hash)) + .collect(); if tools.is_empty() { return Vec::new(); } @@ -474,32 +569,49 @@ impl ExtensionHostManager { installed } - /// Kick [`Self::sync`] without waiting (turn builds, session start). - pub fn sync_in_background(self: &Arc, plugins: Arc) { + /// Kick [`Self::reconcile`] without waiting (turn builds, session start, + /// plugin changes). + pub fn reconcile_in_background(self: &Arc) { if tokio::runtime::Handle::try_current().is_err() { return; } let manager = Arc::clone(self); tokio::spawn(async move { - if let Err(error) = manager.sync(plugins).await { + if let Err(error) = manager.reconcile().await { manager.shared.diagnostic(error); } }); } /// Reconcile host owners with the reviewed, enabled plugins that declare - /// `native` entries: revoke what is gone or changed (synchronously, then - /// ask the host to tear down), spawn the host if needed, activate the rest. - pub async fn sync(&self, plugins: Arc) -> Result<(), String> { + /// `native` entries in any attached engine's snapshot: revoke what no + /// attachment desires any more or what changed (synchronously, then ask + /// the host to tear down), spawn the host if needed, activate the rest. + pub async fn reconcile(&self) -> Result<(), String> { let shared = &self.shared; let _serial = shared.sync_lock.lock().await; - let policy = activation::extension_host_policy_enabled(); - let (desired, errors) = tokio::task::spawn_blocking(move || { - let _scope = activation::PolicyScope::propagate(policy); - desired_owners(&plugins) - }) - .await - .map_err(|error| format!("plugin scan failed: {error}"))?; + let (desired, errors) = loop { + let snapshots: Vec<(u64, Arc)> = shared + .attachments + .lock() + .expect("attachments lock") + .iter() + .map(|(id, state)| (*id, Arc::clone(&state.plugins))) + .collect(); + let policy = activation::extension_host_policy_enabled(); + let scan = tokio::task::spawn_blocking(move || { + let _scope = activation::PolicyScope::propagate(policy); + union_of_desired_owners(snapshots) + }) + .await + .map_err(|error| format!("plugin scan failed: {error}"))?; + let published = scan.publish(&mut shared.attachments.lock().expect("attachments lock")); + if let Some(published) = published { + break published; + } + // Attach, detach, or workspace refresh raced the blocking scan. + // Rescan before changing either engine views or global owners. + }; for error in errors { shared.diagnostic(error); } @@ -772,6 +884,11 @@ impl ExtensionHostManager { } } + #[cfg(test)] + pub(crate) fn host_requests_started(&self) -> Option { + self.shared.ready_host().map(|host| host.requests_started()) + } + #[cfg(test)] pub(crate) fn host_pid(&self) -> Option { self.shared.ready_host().and_then(|host| host.pid) @@ -820,7 +937,12 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String { } } } - let _ = write!(out, "\n spawn attempts: {}", manager.spawn_attempts()); + let _ = write!( + out, + "\n spawn attempts: {} · engines attached: {}", + manager.spawn_attempts(), + manager.attached_engines() + ); let (tools, owners) = { let registry = manager.shared.registry.lock().expect("registry lock"); let owners = registry @@ -838,7 +960,7 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String { for tool in tools { let _ = write!( out, - "\n tool {} (extension:{}; needs approval, which your approval mode or a session grant for this exact tool may give)", + "\n tool {} (extension:{}; needs approval, which your approval mode or a session grant for this exact call of this plugin build may give)", tool.name, tool.plugin_name ); } @@ -854,7 +976,9 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String { /// down without waiting for the next turn. No-op with the flag off. pub fn plugins_changed(plugins: Arc) { if activation::extension_host_policy_enabled() { - manager().sync_in_background(plugins); + let manager = manager(); + manager.refresh_workspace(&plugins); + manager.reconcile_in_background(); } } @@ -875,14 +999,11 @@ fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap, let mut broken: BTreeSet = BTreeSet::new(); for source in sources { let plugin_id = source.authority.plugin_id.as_str().to_string(); - let is_module = source - .path - .extension() - .is_some_and(|extension| extension == "mjs" || extension == "js"); - let bytes = if is_module { - std::fs::read(&source.path).map_err(|error| error.to_string()) - } else { - Err("a native entry must be one .mjs or .js ES module file".to_string()) + // The rule discovery reports, re-checked on the staged copy: the + // name here, and file-ness by the read itself. + let bytes = match crate::plugins::runtime::native_entry_problem(&source.path, true) { + None => std::fs::read(&source.path).map_err(|error| error.to_string()), + Some(problem) => Err(problem.to_string()), }; match bytes { Ok(bytes) => desired @@ -911,6 +1032,140 @@ fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap, (desired, errors) } +/// One scan of the complete attachment set. Its per-engine views and global +/// owner union must be published together, against those same snapshots. +struct DesiredScan { + attachments: Vec<(u64, Arc, BTreeMap)>, + owners: BTreeMap, + errors: Vec, +} + +impl DesiredScan { + fn publish( + self, + current: &mut BTreeMap, + ) -> Option<(BTreeMap, Vec)> { + if current.len() != self.attachments.len() + || self.attachments.iter().any(|(id, scanned, _)| { + !current + .get(id) + .is_some_and(|state| Arc::ptr_eq(&state.plugins, scanned)) + }) + { + return None; + } + for (id, _, desired) in self.attachments { + current.get_mut(&id).expect("validated attachment").desired = desired; + } + Some((self.owners, self.errors)) + } +} + +/// Scan every attached snapshot (engines sharing one snapshot scan it once) +/// and merge what they desire. Blocking. +/// +/// Two snapshots can disagree about one plugin id only while one of them is +/// stale; the stale one then fails its persisted-state check and desires +/// nothing, so the first valid scan wins and the per-attachment hashes keep +/// each engine's tools to the bytes it desires. +fn union_of_desired_owners(snapshots: Vec<(u64, Arc)>) -> DesiredScan { + let mut union: BTreeMap = BTreeMap::new(); + let mut per_attachment = Vec::with_capacity(snapshots.len()); + let mut scanned: Vec<(Arc, BTreeMap)> = Vec::new(); + let mut errors: Vec = Vec::new(); + for (id, plugins) in snapshots { + if let Some((_, hashes)) = scanned.iter().find(|(seen, _)| Arc::ptr_eq(seen, &plugins)) { + per_attachment.push((id, Arc::clone(&plugins), hashes.clone())); + continue; + } + let (desired, scan_errors) = desired_owners(&plugins); + for error in scan_errors { + if !errors.contains(&error) { + errors.push(error); + } + } + let hashes: BTreeMap = desired + .iter() + .map(|(plugin_id, want)| (plugin_id.clone(), want.authority.content_hash.clone())) + .collect(); + for (plugin_id, want) in desired { + union.entry(plugin_id).or_insert(want); + } + per_attachment.push((id, Arc::clone(&plugins), hashes.clone())); + scanned.push((plugins, hashes)); + } + DesiredScan { + attachments: per_attachment, + owners: union, + errors, + } +} + +/// One engine's hold on the process-wide extension host. +/// +/// The engine publishes its workspace plugin snapshot here and installs only +/// the tools of owners that snapshot desires. Dropping it detaches without +/// revoking anything: the next reconcile revokes owners no remaining +/// attachment desires, so an engine being replaced never tears down plugins +/// its successor is about to use. +pub struct HostAttachment { + id: u64, + manager: Arc, +} + +impl HostAttachment { + #[must_use] + pub fn manager(&self) -> &Arc { + &self.manager + } + + /// The engine switched workspace: publish the new snapshot. + pub fn set_plugins(&self, plugins: Arc) { + if let Some(state) = self + .manager + .shared + .attachments + .lock() + .expect("attachments lock") + .get_mut(&self.id) + { + state.plugins = plugins; + state.desired.clear(); + } + } + + /// Reconcile the host against every attachment, waiting for it. + pub async fn sync(&self) -> Result<(), String> { + self.manager.reconcile().await + } + + /// Reconcile the host against every attachment, without waiting. + pub fn sync_in_background(&self) { + self.manager.reconcile_in_background(); + } + + /// Add this engine's live extension tools to `tool_registry`. + pub fn install_tools(&self, tool_registry: &mut crate::tools::ToolRegistry) -> Vec { + self.manager.install_tools_for(self.id, tool_registry) + } +} + +impl Drop for HostAttachment { + fn drop(&mut self) { + if let Ok(mut attachments) = self.manager.shared.attachments.lock() { + attachments.remove(&self.id); + } + } +} + +impl std::fmt::Debug for HostAttachment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HostAttachment") + .field("id", &self.id) + .finish() + } +} + static GLOBAL: OnceLock> = OnceLock::new(); #[cfg(test)] diff --git a/crates/tui/src/extension_host/registry.rs b/crates/tui/src/extension_host/registry.rs index f6cfc9c942..53ae6e2036 100644 --- a/crates/tui/src/extension_host/registry.rs +++ b/crates/tui/src/extension_host/registry.rs @@ -61,6 +61,9 @@ pub struct ToolRegistration { pub handle: u64, pub owner: OwnerRef, pub plugin_name: String, + /// The reviewed bundle content hash of the owner that registered it: the + /// receipt its approval grants are bound to. + pub content_hash: String, pub name: String, pub description: String, pub input_schema: Value, @@ -74,7 +77,10 @@ pub struct OwnerRegistry { tools: BTreeMap, /// Lower-cased tool name → handle, so `Read` cannot impersonate `read`. by_name: HashMap, - /// Lower-cased names of native tools seen at the last turn build. + /// Lower-cased names of every native tool any engine's turn build has + /// reported, plus the static set. Only ever grows: engines in one + /// process build different native surfaces, and a name that is native + /// anywhere is refused everywhere. native_names: HashSet, } @@ -140,25 +146,26 @@ fn core_special_case(name: &str) -> Option<&'static str> { impl OwnerRegistry { #[must_use] pub fn new() -> Self { - let mut registry = Self::default(); - registry.set_native_names(std::iter::empty::<&str>()); - registry - } - - /// Record the native tool names of the current build (the registry before - /// scripts, plugins or extensions are added), on top of the static set. - pub fn set_native_names<'a>(&mut self, names: impl IntoIterator) { - let mut set: HashSet = RESERVED_NAMES + let mut native_names: HashSet = RESERVED_NAMES .iter() .chain(crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS) .map(|name| name.to_ascii_lowercase()) .collect(); for (family, _, alias) in crate::tools::canonical_action::CANONICAL_ACTION_ALIASES { - set.insert(family.to_ascii_lowercase()); - set.insert(alias.to_ascii_lowercase()); + native_names.insert(family.to_ascii_lowercase()); + native_names.insert(alias.to_ascii_lowercase()); } - set.extend(names.into_iter().map(str::to_ascii_lowercase)); - self.native_names = set; + Self { + native_names, + ..Self::default() + } + } + + /// Add native tool names from one engine's turn build (the registry + /// before scripts, plugins or extensions are added). Never removes one. + pub fn add_native_names<'a>(&mut self, names: impl IntoIterator) { + self.native_names + .extend(names.into_iter().map(str::to_ascii_lowercase)); } /// Start a new activation for `plugin_id`, superseding any previous one. @@ -236,6 +243,7 @@ impl OwnerRegistry { .current(¶ms.owner) .ok_or_else(|| "stale or unknown owner".to_string())?; let plugin_name = entry.plugin_name.clone(); + let content_hash = entry.content_hash.clone(); let spec = ¶ms.spec; let name = spec.name.as_str(); if !valid_tool_name(name) { @@ -322,6 +330,7 @@ impl OwnerRegistry { handle, owner: params.owner.clone(), plugin_name, + content_hash, name: name.to_string(), description: spec.description.clone(), input_schema: schema, diff --git a/crates/tui/src/extension_host/supervisor.rs b/crates/tui/src/extension_host/supervisor.rs index ca538fc3f1..c9586a84f1 100644 --- a/crates/tui/src/extension_host/supervisor.rs +++ b/crates/tui/src/extension_host/supervisor.rs @@ -517,6 +517,12 @@ impl HostProcess { } } + /// How many requests the core has sent this host (handshake included). + #[cfg(test)] + pub(crate) fn requests_started(&self) -> u64 { + self.next_id.load(Ordering::Relaxed) - 1 + } + #[must_use] pub fn has_exited(&self) -> bool { *self.exited.borrow() diff --git a/crates/tui/src/extension_host/tests.rs b/crates/tui/src/extension_host/tests.rs index 9dccf8b747..72733168e6 100644 --- a/crates/tui/src/extension_host/tests.rs +++ b/crates/tui/src/extension_host/tests.rs @@ -16,7 +16,7 @@ use super::protocol::{ parse_host_message, }; use super::registry::{OwnerRegistry, OwnerState}; -use super::{ExtensionHostManager, ExtensionHostOptions, HostStatus}; +use super::{ExtensionHostManager, ExtensionHostOptions, HostAttachment, HostStatus}; use crate::plugins::PluginRegistry; use crate::plugins::activation::TestPolicyGuard; use crate::plugins::discovery::{DiscoveryConfig, discover_with_config}; @@ -132,7 +132,7 @@ fn register(registry: &mut OwnerRegistry, owner: &OwnerRef, name: &str) -> Resul #[test] fn registry_refuses_shadowing_and_foreign_names_and_undoes_exactly_one_entry() { let mut registry = OwnerRegistry::new(); - registry.set_native_names(["grep_files"]); + registry.add_native_names(["grep_files"]); let a = registry.begin_owner("a", "a", fake_authority("a"), "hash-a"); let b = registry.begin_owner("b", "b", fake_authority("b"), "hash-b"); @@ -393,10 +393,14 @@ impl FixturePlugins { } } -fn host_tool(manager: &ExtensionHostManager, workspace: &Path, name: &str) -> Arc { +pub(crate) fn host_tool( + engine: &HostAttachment, + workspace: &Path, + name: &str, +) -> Arc { let mut registry = crate::tools::registry::ToolRegistryBuilder::new().build(ToolContext::new(workspace)); - let installed = manager.install_tools(&mut registry); + let installed = engine.install_tools(&mut registry); assert!( installed.contains(&name.to_string()), "{name} not installed: {installed:?}" @@ -427,7 +431,8 @@ async fn dsh_plugin_runs_end_to_end_behind_the_approval_gate() { ); let started = Instant::now(); - manager.sync(fixture.registry()).await.unwrap(); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); let elapsed = started.elapsed(); let pid = manager.host_pid().expect("host running"); eprintln!( @@ -451,7 +456,7 @@ async fn dsh_plugin_runs_end_to_end_behind_the_approval_gate() { Some(OwnerState::Active) ); - let tool = host_tool(&manager, fixture.workspace(), "load_workspace_dependencies"); + let tool = host_tool(&engine, fixture.workspace(), "load_workspace_dependencies"); assert_eq!(tool.registration_origin(), "extension:dsh-workspace-deps"); // The plugin declares `presentCall: kind 'read'`; approval stays Required. assert_eq!( @@ -486,10 +491,11 @@ async fn execute_tools_refuses_extension_tools_before_any_host_call() { let _policy = TestPolicyGuard::extension_host(true); let fixture = FixturePlugins::new(&["slow-tool"]).await; let manager = fixture.manager(node); - manager.sync(fixture.registry()).await.unwrap(); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); let mut registry = crate::tools::registry::ToolRegistryBuilder::new() .build(ToolContext::new(fixture.workspace())); - manager.install_tools(&mut registry); + engine.install_tools(&mut registry); let context = ToolContext::new(fixture.workspace()); let started = Instant::now(); let result = crate::tools::codemode::execute_tools_tool( @@ -518,8 +524,9 @@ async fn disabling_mid_call_revokes_at_once_and_teardown_waits_for_async_dispose let _policy = TestPolicyGuard::extension_host(true); let fixture = FixturePlugins::new(&["slow-tool"]).await; let manager = fixture.manager(node); - manager.sync(fixture.registry()).await.unwrap(); - let tool = host_tool(&manager, fixture.workspace(), "slow_wait"); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); + let tool = host_tool(&engine, fixture.workspace(), "slow_wait"); let context = ToolContext::new(fixture.workspace()); let call = tokio::spawn(async move { tool.execute(json!({}), &context).await }); tokio::time::sleep(Duration::from_millis(150)).await; @@ -545,9 +552,10 @@ async fn disabling_mid_call_revokes_at_once_and_teardown_waits_for_async_dispose }) }; let sync_started = Instant::now(); + engine.set_plugins(disabled); let sync = { let manager = Arc::clone(&manager); - tokio::spawn(async move { manager.sync(disabled).await }) + tokio::spawn(async move { manager.reconcile().await }) }; let outcome = tokio::time::timeout(Duration::from_secs(2), call) .await @@ -590,10 +598,11 @@ async fn killed_host_fails_calls_with_a_typed_error_and_does_not_respawn() { let _policy = TestPolicyGuard::extension_host(true); let fixture = FixturePlugins::new(&["slow-tool"]).await; let manager = fixture.manager(node); - manager.sync(fixture.registry()).await.unwrap(); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); assert_eq!(manager.spawn_attempts(), 1); let pid = manager.host_pid().unwrap(); - let tool = host_tool(&manager, fixture.workspace(), "slow_wait"); + let tool = host_tool(&engine, fixture.workspace(), "slow_wait"); let context = ToolContext::new(fixture.workspace()); let call = tokio::spawn(async move { tool.execute(json!({}), &context).await }); tokio::time::sleep(Duration::from_millis(150)).await; @@ -628,7 +637,7 @@ async fn killed_host_fails_calls_with_a_typed_error_and_does_not_respawn() { assert!(manager.live_tool_names().is_empty()); let report = super::render_status(&manager); assert!(report.contains("failed"), "{report}"); - manager.sync(fixture.registry()).await.ok(); + engine.sync().await.ok(); assert_eq!(manager.spawn_attempts(), 1, "no respawn within the session"); assert!(matches!(manager.status(), HostStatus::Failed { .. })); } @@ -641,7 +650,8 @@ async fn approval_providing_plugin_fails_activation_and_leaves_nothing_registere let _policy = TestPolicyGuard::extension_host(true); let fixture = FixturePlugins::new(&["refuses-approval", "clash-native"]).await; let manager = fixture.manager(node); - manager.sync(fixture.registry()).await.unwrap(); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); let registry = fixture.registry(); for (name, needle) in [ ("refuses-approval", "approval"), @@ -658,7 +668,7 @@ async fn approval_providing_plugin_fails_activation_and_leaves_nothing_registere assert!(manager.live_tool_names().is_empty()); // A failed activation of the same bytes is not retried every turn. let attempts = manager.spawn_attempts(); - manager.sync(fixture.registry()).await.unwrap(); + engine.sync().await.unwrap(); assert_eq!(manager.spawn_attempts(), attempts); manager.shutdown().await; } @@ -700,12 +710,13 @@ async fn an_extension_named_like_a_script_tool_is_skipped_at_turn_build() { let _policy = TestPolicyGuard::extension_host(true); let fixture = FixturePlugins::new(&["clash-script"]).await; let manager = fixture.manager(node); - manager.sync(fixture.registry()).await.unwrap(); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); assert_eq!(manager.live_tool_names(), vec!["fixture_script_tool"]); let mut registry = crate::tools::registry::ToolRegistryBuilder::new() .build(ToolContext::new(fixture.workspace())); registry.register(Arc::new(FakeScriptTool)); - let installed = manager.install_tools(&mut registry); + let installed = engine.install_tools(&mut registry); assert!(installed.is_empty()); assert_eq!( registry @@ -731,11 +742,12 @@ async fn with_no_native_plugin_the_host_is_never_spawned() { let _policy = TestPolicyGuard::extension_host(true); let temp = tempfile::tempdir().unwrap(); let registry = Arc::new(PluginRegistry::empty(temp.path())); - let manager = ExtensionHostManager::new(ExtensionHostOptions { + let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions { node_override: None, root: Some(temp.path().join("home")), - }); - manager.sync(registry).await.unwrap(); + })); + let engine = manager.attach(registry); + engine.sync().await.unwrap(); assert_eq!(manager.spawn_attempts(), 0); assert_eq!(manager.status(), HostStatus::Idle); assert!(!temp.path().join("home").exists(), "nothing materialized"); @@ -774,7 +786,8 @@ async fn sandboxed_host_cannot_read_codewhale_secrets_or_write_outside_its_data_ std::fs::write(&readable, "plain").unwrap(); let manager = fixture.manager(node); - manager.sync(fixture.registry()).await.unwrap(); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); let HostStatus::Ready { sandbox, .. } = manager.status() else { panic!("host not ready: {:?}", manager.status()); }; @@ -790,8 +803,8 @@ async fn sandboxed_host_cannot_read_codewhale_secrets_or_write_outside_its_data_ assert!(super::render_status(&manager).contains(&format!("{sandbox} sandbox"))); let context = ToolContext::new(fixture.workspace()); - let read = host_tool(&manager, fixture.workspace(), "probe_read"); - let write = host_tool(&manager, fixture.workspace(), "probe_write"); + let read = host_tool(&engine, fixture.workspace(), "probe_read"); + let write = host_tool(&engine, fixture.workspace(), "probe_write"); let plain = probe(&read, &readable, &context).await; assert_eq!( @@ -845,3 +858,314 @@ async fn sandboxed_host_cannot_read_codewhale_secrets_or_write_outside_its_data_ } manager.shutdown().await; } + +// --------------------------------------------------------------------------- +// Receipt-bound approval keys (design §4.3) +// --------------------------------------------------------------------------- + +fn keys_for( + manager: &ExtensionHostManager, + registration: super::registry::ToolRegistration, + input: &Value, +) -> (String, String) { + let name = registration.name.clone(); + let mut registry = crate::tools::ToolRegistry::new(ToolContext::new(Path::new("/w"))); + registry.register(Arc::new(super::tool::HostToolSpec::new( + registration, + Arc::clone(&manager.shared), + ))); + let (exact, grouping) = + crate::tools::approval_cache::approval_keys_for_call(Some(®istry), &name, input); + (exact.0, grouping.0) +} + +/// A session grant for an extension tool covers one reviewed plugin build: +/// an update of the plugin, or another plugin that later registers the same +/// tool name, gets a different key and is asked again. +#[test] +fn extension_approval_keys_are_bound_to_the_plugin_receipt() { + let manager = ExtensionHostManager::new(ExtensionHostOptions::default()); + let input = json!({"path": "x"}); + let mut owners = OwnerRegistry::new(); + let live = |owners: &mut OwnerRegistry, owner: &OwnerRef| { + register(owners, owner, "shared_tool").unwrap(); + owners.mark_active(owner); + owners.live_tools().pop().unwrap() + }; + + let first = owners.begin_owner("a", "a", fake_authority("a"), "hash-a1"); + let first = keys_for(&manager, live(&mut owners, &first), &input); + assert!( + first.0.starts_with("ext:a@hash-a1:shared_tool:"), + "{first:?}" + ); + assert_eq!(first.0, first.1, "a grant covers the exact call only"); + let generic = crate::tools::approval_cache::build_approval_grouping_key("shared_tool", &input); + assert_ne!(first.1, generic.0, "never the name-derived family key"); + + // Same plugin, same input, updated bytes: a different grant. + let updated = owners.begin_owner("a", "a", fake_authority("a"), "hash-a2"); + let updated = keys_for(&manager, live(&mut owners, &updated), &input); + assert_ne!(first.1, updated.1); + + // Another plugin takes the name once the first is gone. + owners.revoke_owner("a"); + let other = owners.begin_owner("b", "b", fake_authority("b"), "hash-a1"); + let other = keys_for(&manager, live(&mut owners, &other), &input); + assert_ne!(first.1, other.1); + assert_ne!(updated.1, other.1); + + // Tools without a scope keep their existing keys. + let shell = json!({"command": "cargo build --release"}); + let (exact, grouping) = + crate::tools::approval_cache::approval_keys_for_call(None, "exec_shell", &shell); + assert_eq!( + exact, + crate::tools::approval_cache::build_approval_key("exec_shell", &shell) + ); + assert_eq!( + grouping, + crate::tools::approval_cache::build_approval_grouping_key("exec_shell", &shell) + ); +} + +// --------------------------------------------------------------------------- +// The native-entry rule at validate / review time +// --------------------------------------------------------------------------- + +fn native_bundle(user: &Path, name: &str, native_path: &str, files: &[&str]) { + let root = user.join(name); + for file in files { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + "export const name = 'x'\nexport function apply() {}\n", + ) + .unwrap(); + } + std::fs::write( + root.join("plugin.json"), + serde_json::to_vec_pretty(&json!({ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": name, + "version": "0.1.0", + "description": "native entry rule fixture", + "license": "MIT", + "extensions": {"net.codewhale": {"native": {"path": native_path}}} + })) + .unwrap(), + ) + .unwrap(); +} + +/// `/plugin validate` and the review screen read plugin diagnostics, so an +/// entry that activation would refuse must fail there too, with the flag on; +/// with it off `native` is inventory-only and any path stays valid. +#[test] +fn native_entry_rule_fails_validation_when_the_host_is_enabled() { + let temp = tempfile::tempdir().unwrap(); + let user = temp.path().join("user"); + native_bundle(&user, "dir-entry", "lib", &["lib/index.mjs"]); + native_bundle(&user, "ts-entry", "index.ts", &["index.ts"]); + native_bundle(&user, "good-entry", "index.mjs", &["index.mjs"]); + let config = DiscoveryConfig { + workspace: temp.path().join("project"), + user_plugins_dir: user, + workspace_plugins_dir: temp.path().join("project/.codewhale/plugins"), + builtin_plugin_dirs: Vec::new(), + state_path: temp.path().join("state/plugin-state.json"), + }; + let native_errors = |registry: &PluginRegistry, name: &str| -> Vec { + registry + .get(name) + .unwrap_or_else(|| panic!("{name} not discovered: {:?}", registry.diagnostics())) + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == "native-entry-invalid") + .map(|diagnostic| { + assert_eq!( + diagnostic.level, + crate::plugins::types::PluginDiagnosticLevel::Error + ); + diagnostic.message.clone() + }) + .collect() + }; + + { + let _policy = TestPolicyGuard::extension_host(true); + let registry = discover_with_config(&config); + for name in ["dir-entry", "ts-entry"] { + let errors = native_errors(®istry, name); + assert_eq!(errors.len(), 1, "{name}: {errors:?}"); + assert!(errors[0].contains(".mjs or .js"), "{name}: {errors:?}"); + } + assert!(native_errors(®istry, "good-entry").is_empty()); + assert!(!registry.validation_is_clean()); + } + let _policy = TestPolicyGuard::extension_host(false); + let registry = discover_with_config(&config); + for name in ["dir-entry", "ts-entry", "good-entry"] { + assert!(native_errors(®istry, name).is_empty(), "{name}"); + } +} + +// --------------------------------------------------------------------------- +// Engines sharing one process-wide host +// --------------------------------------------------------------------------- + +#[test] +fn attachment_changes_discard_the_complete_scan_before_owner_side_effects() { + let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions::default())); + let old = Arc::new(PluginRegistry::empty(Path::new("/old"))); + let current = Arc::new(PluginRegistry::empty(Path::new("/current"))); + let attachment = manager.attach(Arc::clone(&old)); + let scan = |plugins: &Arc| super::DesiredScan { + attachments: vec![( + attachment.id, + Arc::clone(plugins), + [("plugin".into(), "hash-plugin".into())].into(), + )], + owners: [( + "plugin".into(), + super::DesiredOwner { + plugin_name: "plugin".into(), + authority: fake_authority("plugin"), + entries: Vec::new(), + }, + )] + .into(), + errors: Vec::new(), + }; + + // An old scan finishes after the engine has already changed workspace. + attachment.set_plugins(Arc::clone(¤t)); + let mut attachments = manager.shared.attachments.lock().unwrap(); + assert!(scan(&old).publish(&mut attachments).is_none()); + assert!(attachments[&attachment.id].desired.is_empty()); + // A current scan publishes both the engine view and owner union. + let (owners, _) = scan(¤t).publish(&mut attachments).unwrap(); + assert!(owners.contains_key("plugin")); + assert_eq!(attachments[&attachment.id].desired["plugin"], "hash-plugin"); + drop(attachments); + + // A newly attached engine also invalidates the complete scan, even when + // it uses the same snapshot: otherwise its owners could be revoked. + let other = manager.attach(Arc::clone(¤t)); + assert!( + scan(¤t) + .publish(&mut manager.shared.attachments.lock().unwrap()) + .is_none() + ); + drop(other); + let stale = scan(¤t); + drop(attachment); + assert!( + stale + .publish(&mut manager.shared.attachments.lock().unwrap()) + .is_none() + ); +} + +fn installed(engine: &HostAttachment, workspace: &Path) -> Vec { + let mut registry = + crate::tools::registry::ToolRegistryBuilder::new().build(ToolContext::new(workspace)); + engine.install_tools(&mut registry) +} + +fn plugin_id(fixture: &FixturePlugins, name: &str) -> String { + fixture + .registry() + .get(name) + .unwrap() + .id + .as_str() + .to_string() +} + +/// Two engines for different workspaces in one process: syncing either +/// keeps the other's plugin active and its in-flight call running, neither +/// receives the other's tools, and detaching one revokes only its plugin. +#[tokio::test] +async fn engines_in_one_process_never_revoke_each_others_plugins() { + let Some(node) = node_for_tests("engines_in_one_process") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let slow = FixturePlugins::new(&["slow-tool"]).await; + let deps = FixturePlugins::new(&["dsh-workspace-deps"]).await; + let (slow_id, deps_id) = ( + plugin_id(&slow, "slow-tool"), + plugin_id(&deps, "dsh-workspace-deps"), + ); + let manager = slow.manager(node); + let first = manager.attach(slow.registry()); + first.sync().await.unwrap(); + let tool = host_tool(&first, slow.workspace(), "slow_wait"); + let context = ToolContext::new(slow.workspace()); + let call = tokio::spawn(async move { tool.execute(json!({"ms": 600}), &context).await }); + tokio::time::sleep(Duration::from_millis(100)).await; + + // A second workspace's engine attaches and syncs mid-call. + let second = manager.attach(deps.registry()); + second.sync().await.unwrap(); + assert_eq!(manager.owner_state(&slow_id), Some(OwnerState::Active)); + assert_eq!(manager.owner_state(&deps_id), Some(OwnerState::Active)); + let result = tokio::time::timeout(Duration::from_secs(5), call) + .await + .expect("call resolves") + .unwrap() + .expect("the first engine's in-flight call completes"); + assert!(result.success, "{}", result.content); + assert!(result.content.contains("600"), "{}", result.content); + + assert_eq!(installed(&first, slow.workspace()), vec!["slow_wait"]); + assert_eq!( + installed(&second, deps.workspace()), + vec!["load_workspace_dependencies"] + ); + first.sync().await.unwrap(); + assert_eq!(manager.owner_state(&deps_id), Some(OwnerState::Active)); + assert!( + !manager.diagnostics().iter().any(|d| d.contains("revoked")), + "{:?}", + manager.diagnostics() + ); + + // Detaching does not revoke by itself; the next reconcile revokes only + // what no remaining engine desires. + drop(second); + assert_eq!(manager.owner_state(&deps_id), Some(OwnerState::Active)); + first.sync().await.unwrap(); + assert_eq!(manager.owner_state(&deps_id), None); + assert_eq!(manager.owner_state(&slow_id), Some(OwnerState::Active)); + assert_eq!(manager.spawn_attempts(), 1); + manager.shutdown().await; +} + +/// Each snapshot is re-verified against persisted plugin state, so a disable +/// made through one engine's registry revokes the plugin for an engine still +/// holding the older snapshot. +#[tokio::test] +async fn a_disable_through_either_registry_revokes_for_every_engine() { + let Some(node) = node_for_tests("a_disable_through_either_registry") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["slow-tool"]).await; + let id = plugin_id(&fixture, "slow-tool"); + let manager = fixture.manager(node); + let first = manager.attach(fixture.registry()); + let second = manager.attach(fixture.registry()); + first.sync().await.unwrap(); + assert_eq!(installed(&first, fixture.workspace()), vec!["slow_wait"]); + assert_eq!(installed(&second, fixture.workspace()), vec!["slow_wait"]); + + second.set_plugins(fixture.disable("slow-tool")); + second.sync().await.unwrap(); + assert_eq!(manager.owner_state(&id), None, "revoked and forgotten"); + assert!(installed(&first, fixture.workspace()).is_empty()); + assert!(installed(&second, fixture.workspace()).is_empty()); + manager.shutdown().await; +} diff --git a/crates/tui/src/extension_host/tool.rs b/crates/tui/src/extension_host/tool.rs index 5bb26ea81e..abd61762e7 100644 --- a/crates/tui/src/extension_host/tool.rs +++ b/crates/tui/src/extension_host/tool.rs @@ -2,13 +2,18 @@ //! //! Because it is a registry tool, every existing gate applies unchanged: //! plan mode, the authority envelope, deferral, hooks, approval, and code -//! mode (which, on main, refuses it as mutating/needs-approval before any -//! host call). Two rules are specific to extension tools: +//! mode (which suspends gated calls for approval and refuses ungated calls +//! before any host call). Three rules are specific to extension tools: //! //! * **Always `ApprovalRequirement::Required`.** A plugin's own read-only //! hint (`presentCall` `kind: 'read'`, MCP-style annotations) is display //! data at most. Honouring it would let a plugin switch approval off for a //! tool whose body runs arbitrary Node — self-approval. +//! * **Approval grants are receipt-bound.** Keys are +//! `ext:@::` for both the exact +//! and the session-grant key ([`ToolSpec::approval_scope`]), so an updated +//! plugin, or a different plugin that later takes the same tool name, never +//! inherits a grant. //! * **Liveness is re-checked at call time**: the plugin's reviewed receipt, //! the Native adapter in this build's policy, and the exact owner //! generation. A revocation mid-turn fails the call closed. @@ -192,6 +197,14 @@ impl ToolSpec for HostToolSpec { true } + /// Grants are bound to the plugin's reviewed receipt (design §4.3). + fn approval_scope(&self) -> Option { + Some(format!( + "ext:{}@{}", + self.registration.owner.plugin_id, self.registration.content_hash + )) + } + fn prepare(&self, input: Value, _context: &ToolContext) -> Result { Ok(PreparedToolCall { name: self.registration.name.clone(), diff --git a/crates/tui/src/plugins/discovery.rs b/crates/tui/src/plugins/discovery.rs index 3bcffbc56d..ab81e13915 100644 --- a/crates/tui/src/plugins/discovery.rs +++ b/crates/tui/src/plugins/discovery.rs @@ -323,6 +323,26 @@ fn load_plugin( Some(manifest_path.to_path_buf()), )); } + // Under the extension-host policy a `native` entry is executable host + // code. Report invalid entries during validation and review, before + // activation refuses the bundle because it has an error diagnostic. + if super::activation::PluginActivationPolicy::current() + .is_supported(super::activation::PluginActivationCapability::Native) + { + for entry in &validated.components.native { + // Every regular bundle file is hashed; a directory is not. + let is_regular_file = entry + .strip_prefix(&validated.canonical_root) + .is_ok_and(|relative| validated.file_hashes.contains_key(relative)); + if let Some(problem) = super::runtime::native_entry_problem(entry, is_regular_file) { + diagnostics.push(PluginDiagnostic::error( + "native-entry-invalid", + format!("native entry {}: {problem}", entry.display()), + Some(entry.clone()), + )); + } + } + } // Skill parsing happens after hashing. Revalidate once so a concurrent // bundle edit cannot pair a reviewed hash with different in-memory Skill diff --git a/crates/tui/src/plugins/runtime.rs b/crates/tui/src/plugins/runtime.rs index 31df94b552..21428adb3a 100644 --- a/crates/tui/src/plugins/runtime.rs +++ b/crates/tui/src/plugins/runtime.rs @@ -35,6 +35,28 @@ fn component_paths(plugin: &LoadedPlugin, capability: PluginActivationCapability } } +/// Why `path` cannot be an extension-host entry, if it cannot. +/// +/// A `native` entry is one `.mjs` or `.js` ES module file: the host imports +/// exactly that file and re-hashes it first. This is the one statement of the +/// rule. Discovery reports it as an error diagnostic (so `/plugin validate` +/// and the review screen show it) and activation refuses the entry, both only +/// while the activation policy supports `Native` (`[features] +/// extension_host`). With the flag off, `native` stays inventory-only and any +/// path is accepted as before. +/// +/// `is_regular_file` comes from the caller's own view of the bundle (the +/// validated manifest's hashed files, or the activation read), so this +/// function touches no filesystem. +#[must_use] +pub fn native_entry_problem(path: &Path, is_regular_file: bool) -> Option<&'static str> { + const RULE: &str = "a native entry must be one .mjs or .js ES module file"; + let is_module = path + .extension() + .is_some_and(|extension| extension == "mjs" || extension == "js"); + (!is_module || !is_regular_file).then_some(RULE) +} + fn scope_precedence(scope: PluginScope) -> u8 { match scope { PluginScope::Workspace => 0, diff --git a/crates/tui/src/skills/system.rs b/crates/tui/src/skills/system.rs index 445a8761bf..b1e597a59d 100644 --- a/crates/tui/src/skills/system.rs +++ b/crates/tui/src/skills/system.rs @@ -32,7 +32,10 @@ use std::path::Path; /// Generation 15 points `help` and `pdf` at the model-visible `read`/`bash` /// tools instead of the hidden compatibility `File` tool; exact /// generation-14 bodies allow safe upgrades. -const BUNDLED_SKILL_VERSION: &str = "15"; +/// Generation 16 has `plugin-creator` scaffold `plugin.json` and describe +/// `native` entries under the experimental extension host; the exact +/// generation-15 body allows a safe upgrade. +const BUNDLED_SKILL_VERSION: &str = "16"; // ── system & extension (meta) ─────────────────────────────────────────────── const SKILL_CREATOR_BODY: &str = include_str!("../../assets/skills/skill-creator/SKILL.md"); @@ -139,6 +142,10 @@ const SUPERSEDED_BODIES: &[(&str, &str)] = &[ "plugin-creator", include_str!("../../assets/skills/plugin-creator/SKILL.generation-13.md"), ), + ( + "plugin-creator", + include_str!("../../assets/skills/plugin-creator/SKILL.generation-15.md"), + ), ( "help", include_str!("../../assets/skills/help/SKILL.generation-14.md"), diff --git a/crates/tui/src/skills/system/tests.rs b/crates/tui/src/skills/system/tests.rs index 6c4b901be1..75af06baa1 100644 --- a/crates/tui/src/skills/system/tests.rs +++ b/crates/tui/src/skills/system/tests.rs @@ -733,3 +733,38 @@ fn generation_15_refreshes_help_and_pdf_from_generation_14() { ); } } + +#[test] +fn generation_16_refreshes_plugin_creator_from_generation_15() { + let old = include_str!("../../../assets/skills/plugin-creator/SKILL.generation-15.md"); + assert!(is_superseded_shipped_body("plugin-creator", old)); + assert!( + old.contains("Create `plugin.toml`"), + "the retained body is the old one" + ); + let skill = BUNDLED_SKILLS + .iter() + .find(|skill| skill.name == "plugin-creator") + .unwrap(); + assert!(skill.body.contains("Create `plugin.json`")); + for customized in [false, true] { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(skill_dir(&tmp, "plugin-creator")).unwrap(); + let body = if customized { + format!("{old}\nMy instructions.\n") + } else { + old.to_string() + }; + fs::write(skill_file(&tmp, "plugin-creator"), &body).unwrap(); + fs::write(marker_file(&tmp), "15").unwrap(); + install_system_skills(tmp.path()).unwrap(); + assert_eq!( + fs::read_to_string(skill_file(&tmp, "plugin-creator")).unwrap(), + if customized { + body + } else { + skill.body.to_string() + }, + ); + } +} diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index 5a4fddd292..f8ba86a74c 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -143,6 +143,34 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) - ApprovalKey(fingerprint) } +/// Exact and grouping keys for one call, as the engine puts them on an +/// approval request. A tool with an [`approval_scope`] (extension tools) is +/// keyed `::` for both, so its grants are +/// bound to the reviewed plugin build and never widened to a family; every +/// other tool keeps [`build_approval_key`] / [`build_approval_grouping_key`]. +/// +/// [`approval_scope`]: crate::tools::spec::ToolSpec::approval_scope +#[must_use] +pub fn approval_keys_for_call( + registry: Option<&crate::tools::ToolRegistry>, + tool_name: &str, + input: &serde_json::Value, +) -> (ApprovalKey, ApprovalKey) { + let scope = registry + .and_then(|registry| registry.get(tool_name)) + .and_then(|tool| tool.approval_scope()); + match scope { + Some(scope) => { + let key = ApprovalKey(format!("{scope}:{tool_name}:{}", hash_json_value(input))); + (key.clone(), key) + } + None => ( + build_approval_key(tool_name, input), + build_approval_grouping_key(tool_name, input), + ), + } +} + /// The sorted `web.run` action kinds present in `input`, e.g. `open+search_query`. fn web_run_action_class(input: &Value) -> String { const ACTIONS: [&str; 6] = [ diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 84c3dec7aa..7bba8db529 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -1552,6 +1552,17 @@ pub trait ToolSpec: Send + Sync { }) } + /// The approval-grant scope this tool's calls are keyed under instead of + /// the name-derived key families, if it has one. `None` (every built-in, + /// script and MCP tool) keeps [`crate::tools::approval_cache`]'s keys. + /// + /// Extension tools return `ext:@`, so a session + /// grant covers one reviewed plugin build: an updated plugin, or another + /// plugin that later registers the same name, is asked again. + fn approval_scope(&self) -> Option { + None + } + /// Returns whether this tool should be excluded from the model-visible /// tool catalog (deferred loading). Tools marked `true` are registered /// but not sent to the model until explicitly activated via tool search. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 4a94ab3fba..fe8919df9a 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -149,8 +149,14 @@ Declare Commands, Agents, and Hooks paths under `extensions["net.codewhale"]` in `plugin.json`, as specified in [Plugin bundles](PLUGIN_BUNDLES.md#active-and-inactive-component-surfaces). Do not place MCP server fields or arbitrary runtime entrypoints at the manifest -root. LSP and native extensions can be inventoried but are not executable -plugin adapters. +root. LSP can be inventoried but has no executable adapter. A `native` +extension is inventory-only by default; with the experimental +`[features] extension_host` flag on, it names one `.mjs` or `.js` ES module +file that the TypeScript extension host runs, and `/plugin validate` rejects +any other entry. Its tools always use `Required` approval, never a plugin's +read-only hint. Full Access, Bypass, or an exact session grant for the +reviewed build can satisfy that gate without a prompt +([design](design/TS_EXTENSION_HOST.md#as-built-phase-1-2026-09-25)). Plugin trust is **not an OS sandbox**. A local MCP server or hook can launch a process; review its code and authority before enabling it. Skills do not grant diff --git a/docs/PLUGIN_BUNDLES.md b/docs/PLUGIN_BUNDLES.md index 11197ead72..23268bb213 100644 --- a/docs/PLUGIN_BUNDLES.md +++ b/docs/PLUGIN_BUNDLES.md @@ -199,7 +199,7 @@ and no longer disable the whole bundle: path = "lsp" [native] # TOML alias: [native_extension] -path = "native" +path = "native/index.mjs" [capabilities] filesystem_roots = ["workspace"] @@ -217,16 +217,26 @@ The accept/reject behavior is deliberately loud, never silent: activate beside named inactive surfaces, and `unsupported` when the bundle only declares surfaces Codewhale cannot activate yet. The same versioned activation policy (v3) drives those labels, the runtime adapters, and the - capability hash. A future Codewhale that starts executing LSP or native code - must change that policy, which changes the capability hash and forces - re-review. v1 and v2 trust receipts fail closed as - `capabilities-changed`. + capability hash. Executing LSP or native code must change that policy, + which changes the capability hash and forces re-review. v1 and v2 trust + receipts fail closed as `capabilities-changed`. +- **`native` under the experimental extension host.** With + `[features] extension_host` on, the policy becomes v4 and `native` is an + active adapter: each entry is one `.mjs` or `.js` ES module file that the + TypeScript extension host imports. A directory or any other file reports + an error in `/plugin validate` and review and prevents activation. Its tools + always use `Required` approval; + Full Access, Bypass, or an exact session grant for the reviewed build can + satisfy that gate without a prompt. Toggling the flag + re-reviews every plugin. See + [the design](design/TS_EXTENSION_HOST.md#as-built-phase-1-2026-09-25). - A **recognized-but-inactive** declaration (`lsp`, `native`, a non-empty `capabilities.filesystem_roots`, or `capabilities.lifecycle_mutation = true`) parses and is validated like any component (contained, present, link-free). It is counted in the inventory, hashed into the capability receipt, shown in review and `/plugin show` as - inactive, and never executed. A reviewed, trusted, applicable mixed bundle + inactive, and never executed (for `native`, only while the extension host + flag is off). A reviewed, trusted, applicable mixed bundle can still be enabled: supported declarative components become active, and the inactive surfaces stay named as inactive. - An **all-unsupported** bundle can be reviewed and trusted, but `/plugin @@ -384,8 +394,8 @@ parse local Kimi-, Claude-, Codex-, and Codewhale-format catalog documents; see the marketplace section below (`/plugin install` fetches one reviewed source, and `/plugin suggest` ranks only what is already installed), no ambient compatibility discovery, no automatic trust, no -plugin-contributed MCP OAuth, no LSP adapter, native extension runtime, or MCP -subscription adapter, no +plugin-contributed MCP OAuth, no LSP adapter or MCP subscription adapter, no +native extension runtime outside the experimental `extension_host` flag, no foreign executable plugin runtime import, and no on-disk auto-migration of a legacy `plugin.toml` to `plugin.json`. The explicit offline [OpenCode/DSH converter](PLUGIN_AUTHORING.md#convert-an-existing-plugin) supports diff --git a/docs/design/TS_EXTENSION_HOST.md b/docs/design/TS_EXTENSION_HOST.md index f86b6dc14e..22eaf83bcb 100644 --- a/docs/design/TS_EXTENSION_HOST.md +++ b/docs/design/TS_EXTENSION_HOST.md @@ -66,6 +66,49 @@ differences from the text below: - **DSH references** are pinned to `refs/dsh` commit `00102833` (`0.1.7-alpha.2`); the local checkout's HEAD has since moved to `0d1f50007f`. +**Phase-1 fixes (2026-09-28).** + +- **Engines attach; they do not own the host.** The host and its owner + registry are process-wide, but each engine holds a `HostAttachment` with + its own workspace plugin snapshot. Reconcile activates the union of what + every attached snapshot desires, re-verifying each snapshot against + persisted plugin state (so a disable or revoke through any registry + revokes everywhere), and revokes only owners no attachment desires. An + engine installs only the tools of owners its own snapshot desires. Engines + without a snapshot of their own (isolated chats, the empty fallback) do not + attach, and dropping an attachment detaches without revoking. Before this, + every engine's `sync` revoked whatever *its* registry did not desire, so + two workspaces, or one isolated chat, cancelled each other's in-flight + calls. The native-name set is now additive across engines. +- **Session grants are bound to the reviewed build (§4.3).** Extension tools + key both the exact and the session-grant approval key as + `ext:@::`, through the + `ToolSpec::approval_scope` hook, so an updated plugin, or another plugin + that later takes the same tool name, is asked again. This only narrows + grants; widening them is an open decision. +- **The native-entry rule is checked at review time.** "One `.mjs` or `.js` + file" is one function (`plugins::runtime::native_entry_problem`). With the + flag on, discovery reports a violating entry as an error diagnostic, so + `/plugin validate` and the review screen fail it, and activation refuses it. +- **Code mode suspends extension tools for approval.** Lane 6562 landed + (#6583) before phase 1 (#6600), so acceptance 2's code-mode assertion is + "suspends for approval": an `execute_tools` call of an extension tool in a + main-session turn raises `.` approval attributed to + `extension:`, sends no `tool/call` before approval, returns the + result on allow and fails only that call on deny. Direct + `execute_tools_tool` with no gate still refuses it before any host call. +- **The handshake timeout is 5 s**, not the 2 s §1.4 and §8 state + (`supervisor::HANDSHAKE_DEADLINE`). +- **A failed host is retried by a new engine, not by `/plugin enable` + itself.** In the TUI every plugin change respawns the engine, and + `Engine::new` calls `begin_session()`, which resets a failed host. On the + runtime-API path a plugin action retries a failed host only when it makes + a plugin the process has not yet seen desired; otherwise the host stays + failed until a new thread engine starts. Explicit retry is phase-2 + supervision work. +- **`hyperfine` was never run** for acceptance 7; the flag-off guarantees + rest on the never-spawned test and the pinned v3 policy digest. + Status: **proposal**, 2026-09-25. Written for the founder direction of that date: move plugins, hooks, commands, custom tools, agent presets and MCP to TypeScript, using the same model as the DSH (DeepSeek Harness) plugin system, and make only that part of Codewhale extensible. @@ -110,7 +153,7 @@ Checked against `/private/tmp/cw-wt-6446` @ `8a835d7c4`, lane `feat/code-mode-mc |---|---|---|---| | R1 | **A second custom-tool system already ships, outside plugin trust.** Scripts in `~/.codewhale/tools/` become model-visible tools on every turn. Each declares its own approval in frontmatter (`# approval: auto`). `ToolRegistry::register` *overwrites* a built-in of the same name with only a `warn!`. `[tools.overrides]` `Script` / `Command` entries replace built-ins on purpose. | `tools/plugin.rs:1-20,111`; `tools/registry.rs:53-63,375-414`; `core/engine.rs:4910,7360-7385` | Today, a script already approves itself and shadows built-ins. The founder's "only the TS host is extensible" requires moving this, so it is added to the deletion plan (§7) and decision D9. The host must not be weaker than this path, and must not copy it either. | | R2 | **Registry tools are the existing seam for extension tools; `ExternalToolDispatch` is not needed in phase 1.** The registry is rebuilt every turn (`build_turn_tool_registry_and_catalog`). Tools outside `DEFAULT_ACTIVE_NATIVE_TOOLS` are deferred by default (`tool_catalog.rs:136-149`). On main, code mode already sees registry tools and refuses the ones that need approval (`codemode.rs:233-238`). The lane gates "native, plugin, or MCP" nested calls with approval suspension (lane `codemode.rs:1-25`). | as cited | Phase-1 extension tools are `ToolSpec` adapters registered next to `configure_plugin_tools`. They get plan mode, the authority envelope, deferral, approval and code-mode gating from code that already exists. **Phase 1 does not depend on the unmerged lane.** `ExternalToolDispatch` is left as an MCP-only interface that the lane may adopt (§5.2). | -| R3 | **The lane is not merged.** `origin/main` has code-mode Phase 1 (`e23ce514c`, which runs Auto-only nested calls and refuses MCP). The lane is 3 commits ahead. | `git log origin/main..feat/code-mode-mcp-6562` | Phase-1 acceptance cannot require "gated identically from `execute_tools` with `.` ids". On main, the assertion is "refused as needs-approval". Once the lane lands, it is "suspends for approval". | +| R3 | **The lane is not merged.** `origin/main` has code-mode Phase 1 (`e23ce514c`, which runs Auto-only nested calls and refuses MCP). The lane is 3 commits ahead. | `git log origin/main..feat/code-mode-mcp-6562` | Phase-1 acceptance cannot require "gated identically from `execute_tools` with `.` ids". On main, the assertion is "refused as needs-approval". Once the lane lands, it is "suspends for approval". **Resolved:** the lane landed first (#6583, then #6600), and the phase-1 fixes assert "suspends for approval" (`execute_tools_gates_an_extension_tool_before_any_host_call`). | | R4 | **The self-declared read-only hint is an auto-approve.** `approval_hint_for` → `TrustedReadOnly` → `ApprovalRequirement::Auto` (`mcp.rs:1265-1274`, `tool_preparation.rs:46-48`, test at `:537-540`). | as cited | If extension tools honoured `presentCall` / `kind: 'read'` (old §4.3), a plugin would switch off approval for its own tools, and those tools run arbitrary Node. **Removed.** Extension tools are always `Required` (§4.3). | | R5 | **Secrets are on disk, readable by any same-user process.** The default secret backend is `~/.codewhale/secrets/` (`crates/secrets/src/lib.rs:64-69`). MCP OAuth tokens are re-read "from the on-disk credential" (`mcp/oauth.rs:749-752`). | as cited | "Tokens never enter Node" and "one gate *even if the host is compromised*" (old §4.4, §5.1) are false until the phase-5 sandbox denies those paths. They are restated as protocol properties, not containment (§4.1, §4.4). | | R6 | **The protocol names a mechanism that does not exist.** No `change:tool_surface` exists anywhere in `crates/tui/src`. | grep | Removed. Per-turn rebuild plus a liveness check at dispatch time is enough (§3.3). | @@ -739,7 +782,7 @@ This follows "migrate the last consumer or do not start". Every phase's exit cri - the tool is **deferred** and reachable through `tool_search`; - the approval request is raised (`Required`), and its text names `extension:dsh-workspace-deps`; - after approval, the result JSON comes from the fixture payload; - - the same call from `execute_tools` on **main** is refused as needs-approval, with a receipt and no host `tool/call` sent. (Once lane 6562 lands, this assertion becomes "suspends for approval, `.` id"; that edit belongs to whichever of the two PRs lands second.) + - the same call from `execute_tools` in a main-session turn suspends for approval with a `.` id attributed to `extension:`, and no host `tool/call` is sent before approval; allow returns the result to the program and deny fails only that nested call. (Lane 6562 landed first, as #6583; the assertion was updated in the phase-1 fixes. Without a gate, `execute_tools` still refuses it as needs-approval.) 3. Disabling the plugin mid-call: Rust's registry drops the handle at once; the in-flight call resolves as cancelled within 500 ms; the host acks `disposed` only after the async disposer settles, and `leaked` is empty. 4. `kill -9` on the host: the in-flight call fails with the typed `not_available("extension host exited")`; `/plugin` shows *failed* with the stderr tail; nothing respawns until the next session or `/plugin enable`. 5. The `refuses-approval` fixture FAILS activation with a diagnostic, and no registration survives on either side. diff --git a/docs/zh_hans/PLUGIN_AUTHORING.md b/docs/zh_hans/PLUGIN_AUTHORING.md index b98f85683c..2091388b07 100644 --- a/docs/zh_hans/PLUGIN_AUTHORING.md +++ b/docs/zh_hans/PLUGIN_AUTHORING.md @@ -144,7 +144,12 @@ Commands、Agents 和 Hooks 的路径声明放在 `plugin.json` 的 `extensions["net.codewhale"]` 中,详见 [插件组件契约](../PLUGIN_BUNDLES.md#active-and-inactive-component-surfaces)。 不要把 MCP 服务器字段或任意运行时入口放在清单根级。 -LSP 和原生扩展可以列入清单,但目前没有可执行的插件适配器。 +LSP 可以列入清单,但目前没有可执行的适配器。`native` 原生扩展默认只列入清单; +开启实验性的 `[features] extension_host` 后,它必须指向一个 `.mjs` 或 `.js` +ES 模块文件,由 TypeScript 扩展宿主运行,`/plugin validate` 会拒绝其他入口。 +其工具始终使用 `Required` 审批要求,不采信插件自行声明的只读提示; +Full Access、Bypass 或针对该已审查版本和精确调用的会话授权可满足要求而不再弹出审批 +([设计文档](../design/TS_EXTENSION_HOST.md#as-built-phase-1-2026-09-25))。 插件信任**不是操作系统沙箱**。本地 MCP 服务器或 hook 可以启动进程; 启用前需审查其代码和权限。Skills 不授予权限:仓库指令、权限规则、沙箱策略 From 291345d63dbb7af270e18e2390165cacbf6f165f Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 29 Sep 2026 02:31:45 -0700 Subject: [PATCH 3/8] fix(extensions): limit synchronous attachment test seam to tests Production uses background reconciliation; the synchronous attachment helper is used only by host tests. Hosted MSRV and OpenHarmony checks caught its dead-code error in the non-test build. Existing focused tests remain applicable; non-test compilation is pending before this follow-up is pushed. --- crates/tui/src/extension_host/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tui/src/extension_host/mod.rs b/crates/tui/src/extension_host/mod.rs index d2a804608a..003677aed2 100644 --- a/crates/tui/src/extension_host/mod.rs +++ b/crates/tui/src/extension_host/mod.rs @@ -1135,6 +1135,7 @@ impl HostAttachment { } /// Reconcile the host against every attachment, waiting for it. + #[cfg(test)] pub async fn sync(&self) -> Result<(), String> { self.manager.reconcile().await } From ce9cbd9370724eb26a947c01cb62e8282988391b Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 29 Sep 2026 03:01:18 -0700 Subject: [PATCH 4/8] feat(extensions): supervise host recovery with bounded replay Monitor the shared extension host with generation-bound pings, report delayed responses, and kill a hung process tree. Recover unexpected post-handshake exits through the existing reconciliation path with backoff and a shared three-crash/five-minute limit. Opening another engine cannot reset that limit; explicit plugin mutation retries deliberately, while launch failures permit attachment retry only after cooldown. Replay only currently attached, still-authorized plugin receipts with fresh owner tokens and tool handles. Preserve failed/faulted receipts, attribute activation crashes to the activating owner, reject stale callbacks and superseded launches, and never replay pending tool calls. Refresh same-byte authority after an explicit plugin mutation. The experimental feature remains disabled by default with its existing approval and sandbox boundaries. Validation: - Production codewhale-tui library check passed with locked dependencies and warnings denied. - Focused Rust host/engine/approval tests: 48 passed, 0 failed, 0 ignored, 13792 filtered; real Node host fixtures required with CODEWHALE_EXT_HOST_TESTS=1, no provider calls. - Extension-host build and typecheck passed; host/protocol tests: 53 passed, 0 failed, 0 skipped. - npm test: 639 passed, 0 failed (68 wrapper, 16 SDK, 53 host, 502 web). - npm run check:web: exit 0; facts, docs, tokens, lint, types and production build passed. Existing canonical Git objects supplied missing partial-clone history without changing source facts. - cargo fmt --all -- --check and git diff --check passed. The initial production check caught a shutdown-only registry helper after its production caller was replaced; the helper is now test-only and the corrected production check passed. This is the bounded supervision/restart slice of Phase 2a. Owner diagnostics, dirty teardown recovery, author workflow additions and laptop-suspend behavior remain outside this proof. Local fixture success does not claim hosted CI, provider, deployment or release qualification. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks Signed-off-by: Hunter B --- .../dist/codewhale-extension-host.mjs | 7 +- crates/tui/extension-host/dist/protocol.mjs | 3 +- crates/tui/extension-host/src/main.ts | 5 + crates/tui/extension-host/src/protocol.ts | 3 +- crates/tui/extension-host/test/host.test.mjs | 7 + crates/tui/src/core/engine.rs | 1 - crates/tui/src/extension_host/mod.rs | 452 +++++++++++++++--- crates/tui/src/extension_host/protocol.rs | 7 + crates/tui/src/extension_host/registry.rs | 29 +- crates/tui/src/extension_host/supervisor.rs | 22 +- crates/tui/src/extension_host/tests.rs | 436 ++++++++++++++++- crates/tui/src/lib.rs | 1 + .../extension_host/crash-activation/index.mjs | 1 + .../crash-activation/plugin.json | 14 + .../extension_host/crash-tool/index.mjs | 7 + .../extension_host/crash-tool/plugin.json | 14 + .../extension_host/hang-tool/index.mjs | 11 + .../extension_host/hang-tool/plugin.json | 14 + .../extension_host/protocol/29-host-ping.json | 10 + .../protocol/30-host-ping-invalid-params.json | 10 + docs/design/TS_EXTENSION_HOST.md | 39 +- 21 files changed, 983 insertions(+), 110 deletions(-) create mode 100644 crates/tui/tests/fixtures/extension_host/crash-activation/index.mjs create mode 100644 crates/tui/tests/fixtures/extension_host/crash-activation/plugin.json create mode 100644 crates/tui/tests/fixtures/extension_host/crash-tool/index.mjs create mode 100644 crates/tui/tests/fixtures/extension_host/crash-tool/plugin.json create mode 100644 crates/tui/tests/fixtures/extension_host/hang-tool/index.mjs create mode 100644 crates/tui/tests/fixtures/extension_host/hang-tool/plugin.json create mode 100644 crates/tui/tests/fixtures/extension_host/protocol/29-host-ping.json create mode 100644 crates/tui/tests/fixtures/extension_host/protocol/30-host-ping-invalid-params.json diff --git a/crates/tui/extension-host/dist/codewhale-extension-host.mjs b/crates/tui/extension-host/dist/codewhale-extension-host.mjs index eda7cba5aa..454e3a4233 100644 --- a/crates/tui/extension-host/dist/codewhale-extension-host.mjs +++ b/crates/tui/extension-host/dist/codewhale-extension-host.mjs @@ -3969,7 +3969,7 @@ var ErrorCode = { Cancelled: -32800 }; var CORE_TO_HOST = { - requests: ["host/initialize", "host/shutdown", "ext/activate", "ext/deactivate", "tool/call"], + requests: ["host/initialize", "host/shutdown", "host/ping", "ext/activate", "ext/deactivate", "tool/call"], notifications: ["$/cancel"] }; var HOST_TO_CORE = { @@ -4104,6 +4104,7 @@ var PARAMS = { ) }, "host/shutdown": { dir: "core", required: {} }, + "host/ping": { dir: "core", required: {} }, "ext/activate": { dir: "core", required: { owner: "owner", plugin_name: "string", entry: "object" }, @@ -4656,6 +4657,10 @@ rpc.onRequest("host/initialize", (params) => { function requireInitialized() { if (!initialized) throw new RpcError(ErrorCode.InvalidRequest, "host is not initialized"); } +rpc.onRequest("host/ping", () => { + requireInitialized(); + return {}; +}); rpc.onRequest("ext/activate", async (params) => { requireInitialized(); return host.activate(params); diff --git a/crates/tui/extension-host/dist/protocol.mjs b/crates/tui/extension-host/dist/protocol.mjs index ece2da94b0..3f62eb33c2 100644 --- a/crates/tui/extension-host/dist/protocol.mjs +++ b/crates/tui/extension-host/dist/protocol.mjs @@ -19,7 +19,7 @@ var ErrorCode = { Cancelled: -32800 }; var CORE_TO_HOST = { - requests: ["host/initialize", "host/shutdown", "ext/activate", "ext/deactivate", "tool/call"], + requests: ["host/initialize", "host/shutdown", "host/ping", "ext/activate", "ext/deactivate", "tool/call"], notifications: ["$/cancel"] }; var HOST_TO_CORE = { @@ -154,6 +154,7 @@ var PARAMS = { ) }, "host/shutdown": { dir: "core", required: {} }, + "host/ping": { dir: "core", required: {} }, "ext/activate": { dir: "core", required: { owner: "owner", plugin_name: "string", entry: "object" }, diff --git a/crates/tui/extension-host/src/main.ts b/crates/tui/extension-host/src/main.ts index ca6174def7..ef6fa50b38 100644 --- a/crates/tui/extension-host/src/main.ts +++ b/crates/tui/extension-host/src/main.ts @@ -115,6 +115,11 @@ function requireInitialized() { if (!initialized) throw new RpcError(ErrorCode.InvalidRequest, 'host is not initialized') } +rpc.onRequest('host/ping', () => { + requireInitialized() + return {} +}) + rpc.onRequest('ext/activate', async (params: any) => { requireInitialized() return host.activate(params) diff --git a/crates/tui/extension-host/src/protocol.ts b/crates/tui/extension-host/src/protocol.ts index 955a9090f7..8f6103801b 100644 --- a/crates/tui/extension-host/src/protocol.ts +++ b/crates/tui/extension-host/src/protocol.ts @@ -32,7 +32,7 @@ export const ErrorCode = { /** Methods the core sends to the host. */ export const CORE_TO_HOST = { - requests: ['host/initialize', 'host/shutdown', 'ext/activate', 'ext/deactivate', 'tool/call'], + requests: ['host/initialize', 'host/shutdown', 'host/ping', 'ext/activate', 'ext/deactivate', 'tool/call'], notifications: ['$/cancel'], } as const @@ -233,6 +233,7 @@ const PARAMS: Record { + const host = await startHost() + t.after(() => host.stop()) + assert.deepEqual(await host.call('host/ping', {}), {}) + assert.equal(host.registry.length, 0) +}) + test('the published DSH plugin runs unmodified and returns its payload', async (t) => { const host = await startHost() t.after(() => host.stop()) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 08420231a0..e66648b343 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1706,7 +1706,6 @@ impl Engine { .filter(|_| config.features.enabled(Feature::ExtensionHost)) .map(|registry| { let manager = crate::extension_host::manager(); - manager.begin_session(); let attachment = manager.attach(Arc::clone(registry)); attachment.sync_in_background(); attachment diff --git a/crates/tui/src/extension_host/mod.rs b/crates/tui/src/extension_host/mod.rs index 003677aed2..8e34548361 100644 --- a/crates/tui/src/extension_host/mod.rs +++ b/crates/tui/src/extension_host/mod.rs @@ -8,7 +8,7 @@ //! `ToolSpec`s ([`tool::HostToolSpec`]) behind the existing gate. //! //! Lifecycle: a reviewed, enabled plugin with a `native` entry (activation -//! policy v4, selected by the flag) makes [`ExtensionHostManager::reconcile`] +//! policy v4, selected by the flag) makes [`ExtensionHostManager::reconcile_in_background`] //! spawn the host in the background — never on the first-prompt path — and //! activate one owner per plugin. Tools join the per-turn registry at the next //! rebuild, deferred. Disabling, revoking or updating the plugin revokes its @@ -30,9 +30,10 @@ //! Known limitations (phase 1, by design — see the design doc §8): //! * Tools only: no commands, hooks, skills, prompt sections, MCP, or //! `core/call` (the host cannot ask the core to do anything). -//! * No heartbeat and no auto-restart. A dead host fails in-flight calls with -//! a typed error and stays failed until the next session or until a plugin -//! the session has not seen before becomes desired. A teardown that times +//! * Heartbeat and bounded automatic restart preserve the shared crash budget +//! across engine creation and replay. Dead-host calls fail with a typed +//! error and are never replayed. Three crashes in five minutes require an +//! explicit plugin change/reload to retry. A teardown that times //! out or reports leaks is logged in `/plugin`; the plugin's leftover //! JavaScript keeps running until the host process ends. //! * One host per engine process and one trust tier. On macOS (Seatbelt) the @@ -77,6 +78,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::{Duration, Instant}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -162,6 +164,58 @@ pub struct ExtensionHostOptions { pub node_override: Option, /// Where the bundle is materialized; defaults to the Codewhale home. pub root: Option, + /// Per-manager timings; tests can shorten them without global state. + pub supervision: SupervisionOptions, +} + +#[derive(Debug, Clone)] +pub struct SupervisionOptions { + pub heartbeat_interval: Duration, + pub ping_timeout: Duration, + pub hang_timeout: Duration, + pub restart_backoff: Duration, + pub crash_window: Duration, + pub crash_limit: usize, + pub start_retry_cooldown: Duration, +} + +impl Default for SupervisionOptions { + fn default() -> Self { + Self { + heartbeat_interval: Duration::from_secs(3), + ping_timeout: Duration::from_secs(3), + hang_timeout: Duration::from_secs(10), + restart_backoff: Duration::from_millis(250), + crash_window: Duration::from_secs(5 * 60), + crash_limit: 3, + start_retry_cooldown: Duration::from_secs(60), + } + } +} + +#[derive(Default)] +struct SupervisionState { + crashes: VecDeque, + last_start: Option, + launch_failed: bool, + retry_ticket: u64, + policy: bool, +} + +impl SupervisionState { + fn record_crash(&mut self, now: Instant, options: &SupervisionOptions) -> bool { + while self + .crashes + .front() + .is_some_and(|at| now.saturating_duration_since(*at) >= options.crash_window) + { + self.crashes.pop_front(); + } + self.crashes.push_back(now); + self.launch_failed = false; + self.retry_ticket += 1; + self.crashes.len() < options.crash_limit + } } /// Observable host state, for `/plugin`, doctor and tests. @@ -175,6 +229,12 @@ pub enum HostStatus { /// `seatbelt` / `bwrap`, or `None` when the host runs unsandboxed. sandbox: Option, }, + Unresponsive { + pid: Option, + }, + Restarting { + reason: String, + }, Failed { reason: String, stderr_tail: String, @@ -185,6 +245,8 @@ enum HostSlot { Idle, Starting, Ready(Arc), + Unresponsive(Arc), + Restarting { reason: String }, Failed { reason: String, stderr_tail: String }, } @@ -211,8 +273,8 @@ pub(crate) struct ManagerShared { spawn_attempts: AtomicU64, sync_lock: tokio::sync::Mutex<()>, diagnostics: Mutex>, - /// Plugin ids this session has already tried; a new one clears `Failed`. - seen_plugins: Mutex>, + /// Lock order: host, supervision, registry. Never held across await. + supervision: Mutex, } impl ManagerShared { @@ -268,20 +330,30 @@ impl ManagerShared { /// Channel callbacks. Holds a `Weak` so the host process (which owns the /// callbacks) never keeps the manager alive. -struct Events(Weak); +struct Events { + shared: Weak, + generation: u64, +} impl HostEvents for Events { fn register(&self, params: &protocol::RegisterParams) -> RegisterResult { - let Some(shared) = self.0.upgrade() else { + let Some(shared) = self.shared.upgrade() else { return RegisterResult::Refused { refused: "extension host manager is gone".to_string(), }; }; + let slot = shared.host.lock().expect("host lock"); + if shared.host_generation.load(Ordering::SeqCst) != self.generation { + return RegisterResult::Refused { + refused: "stale host generation".to_string(), + }; + } let result = shared .registry .lock() .expect("registry lock") .register_tool(params); + drop(slot); match result { Ok(handle) => RegisterResult::Admitted { handle }, Err(reason) => { @@ -295,7 +367,7 @@ impl HostEvents for Events { } fn unregister(&self, params: &protocol::UnregisterParams) { - if let Some(shared) = self.0.upgrade() { + if let Some(shared) = self.shared.upgrade() { shared .registry .lock() @@ -305,17 +377,25 @@ impl HostEvents for Events { } fn faulted(&self, params: &protocol::FaultedParams) { - let Some(shared) = self.0.upgrade() else { + let Some(shared) = self.shared.upgrade() else { return; }; - shared + let slot = shared.host.lock().expect("host lock"); + if shared.host_generation.load(Ordering::SeqCst) != self.generation { + return; + } + if !shared .registry .lock() .expect("registry lock") - .mark_failed(¶ms.owner, OwnerState::Faulted(params.error.clone())); - if let Some(host) = shared.ready_host() { + .mark_failed(¶ms.owner, OwnerState::Faulted(params.error.clone())) + { + return; + } + if let HostSlot::Ready(host) | HostSlot::Unresponsive(host) = &*slot { host.revoke_calls_of(¶ms.owner.plugin_id); } + drop(slot); shared.diagnostic(format!( "extension `{}` faulted and was disposed: {}", params.owner.plugin_id, params.error @@ -323,28 +403,173 @@ impl HostEvents for Events { } fn exited(&self, host_generation: u64, reason: String, stderr_tail: String) { - let Some(shared) = self.0.upgrade() else { + let Some(shared) = self.shared.upgrade() else { return; }; - if shared.host_generation.load(Ordering::SeqCst) != host_generation { - return; + let retry = { + let mut slot = shared.host.lock().expect("host lock"); + if shared.host_generation.load(Ordering::SeqCst) != host_generation + || !matches!(&*slot, HostSlot::Ready(_) | HostSlot::Unresponsive(_)) + { + return; + } + let mut supervision = shared.supervision.lock().expect("supervision lock"); + let restart = supervision.record_crash(Instant::now(), &shared.options.supervision); + shared + .registry + .lock() + .expect("registry lock") + .host_exited(&reason); + *slot = if restart { + HostSlot::Restarting { + reason: reason.clone(), + } + } else { + HostSlot::Failed { + reason: format!("crash budget exhausted: {reason}"), + stderr_tail, + } + }; + restart.then_some((supervision.retry_ticket, supervision.policy)) + }; + shared.diagnostic(format!("extension host {reason}")); + if let Some((ticket, policy)) = retry { + schedule_restart(&shared, host_generation, ticket, policy); } - shared - .registry - .lock() - .expect("registry lock") - .revoke_all(&format!("extension host exited: {reason}")); + } +} + +/// One scheduled retry owns a ticket, so explicit retry/shutdown and a newer +/// host generation invalidate it. The existing reconcile lock owns replay. +fn schedule_restart(shared: &Arc, generation: u64, ticket: u64, policy: bool) { + let weak = Arc::downgrade(shared); + let backoff = shared.options.supervision.restart_backoff; + tokio::spawn(async move { + tokio::time::sleep(backoff).await; + let Some(shared) = weak.upgrade() else { + return; + }; { + let _serial = shared.sync_lock.lock().await; let mut slot = shared.host.lock().expect("host lock"); - if !matches!(&*slot, HostSlot::Failed { .. } | HostSlot::Idle) { - *slot = HostSlot::Failed { - reason: reason.clone(), - stderr_tail: stderr_tail.clone(), - }; + if shared.host_generation.load(Ordering::SeqCst) != generation + || shared + .supervision + .lock() + .expect("supervision lock") + .retry_ticket + != ticket + || !matches!(&*slot, HostSlot::Restarting { .. }) + { + return; } + *slot = HostSlot::Idle; } - shared.diagnostic(format!("extension host {reason}")); + let manager = ExtensionHostManager { shared }; + if let Err(error) = manager.reconcile_with_policy(policy).await { + manager.shared.diagnostic(error); + } + }); +} + +fn set_host_health(shared: &ManagerShared, generation: u64, unresponsive: bool) -> bool { + let mut slot = shared.host.lock().expect("host lock"); + if shared.host_generation.load(Ordering::SeqCst) != generation { + return false; } + let host = match &*slot { + HostSlot::Ready(host) | HostSlot::Unresponsive(host) => Arc::clone(host), + _ => return false, + }; + *slot = if unresponsive { + HostSlot::Unresponsive(host) + } else { + HostSlot::Ready(host) + }; + true +} + +/// A monitor never owns the manager. Dropping the manager or changing host +/// generation stops its monitor; pending calls are never retried here. +fn monitor_host(shared: &Arc, host: &Arc, generation: u64) { + let weak = Arc::downgrade(shared); + let host = Arc::clone(host); + let options = shared.options.supervision.clone(); + tokio::spawn(async move { + let mut blocked_since = None; + loop { + tokio::time::sleep(options.heartbeat_interval).await; + let Some(shared) = weak.upgrade() else { + return; + }; + if shared.host_generation.load(Ordering::SeqCst) != generation || host.has_exited() { + return; + } + drop(shared); + let (id, mut answer) = match host.start_request(CoreRequest::Ping, None) { + Ok(request) => { + blocked_since = None; + request + } + Err(supervisor::HostCallError::Busy) => { + // A full outbound queue can itself be caused by a hung + // host. Bound that wait too instead of skipping forever. + let elapsed = blocked_since.get_or_insert_with(Instant::now).elapsed(); + if elapsed >= options.hang_timeout { + host.terminate("heartbeat queue remained blocked".into()); + return; + } + if elapsed >= options.ping_timeout { + let Some(shared) = weak.upgrade() else { + return; + }; + if !set_host_health(&shared, generation, true) { + return; + } + } + continue; + } + Err(_) => return, + }; + let result = match tokio::time::timeout(options.ping_timeout, &mut answer).await { + Ok(result) => result, + Err(_) => { + let Some(shared) = weak.upgrade() else { + host.forget(id); + return; + }; + if !set_host_health(&shared, generation, true) { + host.forget(id); + return; + } + drop(shared); + let remaining = options.hang_timeout.saturating_sub(options.ping_timeout); + match tokio::time::timeout(remaining, answer).await { + Ok(result) => result, + Err(_) => { + host.forget(id); + host.terminate("heartbeat timed out".into()); + return; + } + } + } + }; + host.forget(id); + if !matches!(result, Ok(Ok(serde_json::Value::Object(ref object))) if object.is_empty()) + { + if !host.has_exited() { + host.terminate("invalid heartbeat response".into()); + } + return; + } + let Some(shared) = weak.upgrade() else { + return; + }; + if !set_host_health(&shared, generation, false) { + return; + } + } + }); } /// Supervises at most one extension host for this engine process. @@ -366,7 +591,7 @@ impl ExtensionHostManager { spawn_attempts: AtomicU64::new(0), sync_lock: tokio::sync::Mutex::new(()), diagnostics: Mutex::new(VecDeque::new()), - seen_plugins: Mutex::new(BTreeSet::new()), + supervision: Mutex::new(SupervisionState::default()), }), } } @@ -381,6 +606,10 @@ impl ExtensionHostManager { node_version: host.node_version.get().cloned().unwrap_or_default(), sandbox: host.sandbox.clone(), }, + HostSlot::Unresponsive(host) => HostStatus::Unresponsive { pid: host.pid }, + HostSlot::Restarting { reason } => HostStatus::Restarting { + reason: reason.clone(), + }, HostSlot::Failed { reason, stderr_tail, @@ -432,20 +661,39 @@ impl ExtensionHostManager { .map(|entry| entry.state.clone()) } - /// A new session may retry a host that failed in an earlier one. - pub fn begin_session(&self) { - { - let mut slot = self.shared.host.lock().expect("host lock"); - if matches!(&*slot, HostSlot::Failed { .. }) { - *slot = HostSlot::Idle; - } + /// An explicit plugin mutation retries failed receipts and clears the + /// shared crash budget. Merely opening another engine never does this. + pub fn retry(&self) { + let mut slot = self.shared.host.lock().expect("host lock"); + let mut supervision = self.shared.supervision.lock().expect("supervision lock"); + supervision.crashes.clear(); + supervision.launch_failed = false; + supervision.retry_ticket += 1; + if matches!( + &*slot, + HostSlot::Failed { .. } | HostSlot::Restarting { .. } + ) { + *slot = HostSlot::Idle; } self.shared .registry .lock() .expect("registry lock") .forget_inactive(); - self.shared.seen_plugins.lock().expect("seen lock").clear(); + } + + fn retry_launch_on_attach(&self) { + let mut slot = self.shared.host.lock().expect("host lock"); + let mut supervision = self.shared.supervision.lock().expect("supervision lock"); + if matches!(&*slot, HostSlot::Failed { .. }) + && supervision.launch_failed + && supervision.last_start.is_some_and(|at| { + at.elapsed() >= self.shared.options.supervision.start_retry_cooldown + }) + { + *slot = HostSlot::Idle; + supervision.retry_ticket += 1; + } } /// Record native tool names from one engine's turn build (the registry @@ -464,6 +712,7 @@ impl ExtensionHostManager { /// is reconciled until [`HostAttachment::sync`] or a background sync. #[must_use] pub fn attach(self: &Arc, plugins: Arc) -> HostAttachment { + self.retry_launch_on_attach(); let id = self.shared.next_attachment.fetch_add(1, Ordering::SeqCst) + 1; self.shared .attachments @@ -569,15 +818,16 @@ impl ExtensionHostManager { installed } - /// Kick [`Self::reconcile`] without waiting (turn builds, session start, + /// Reconcile without waiting (turn builds, session start, /// plugin changes). pub fn reconcile_in_background(self: &Arc) { if tokio::runtime::Handle::try_current().is_err() { return; } let manager = Arc::clone(self); + let policy = activation::extension_host_policy_enabled(); tokio::spawn(async move { - if let Err(error) = manager.reconcile().await { + if let Err(error) = manager.reconcile_with_policy(policy).await { manager.shared.diagnostic(error); } }); @@ -587,7 +837,13 @@ impl ExtensionHostManager { /// `native` entries in any attached engine's snapshot: revoke what no /// attachment desires any more or what changed (synchronously, then ask /// the host to tear down), spawn the host if needed, activate the rest. + #[cfg(test)] pub async fn reconcile(&self) -> Result<(), String> { + self.reconcile_with_policy(activation::extension_host_policy_enabled()) + .await + } + + async fn reconcile_with_policy(&self, policy: bool) -> Result<(), String> { let shared = &self.shared; let _serial = shared.sync_lock.lock().await; let (desired, errors) = loop { @@ -598,7 +854,6 @@ impl ExtensionHostManager { .iter() .map(|(id, state)| (*id, Arc::clone(&state.plugins))) .collect(); - let policy = activation::extension_host_policy_enabled(); let scan = tokio::task::spawn_blocking(move || { let _scope = activation::PolicyScope::propagate(policy); union_of_desired_owners(snapshots) @@ -616,37 +871,25 @@ impl ExtensionHostManager { shared.diagnostic(error); } - { - let mut seen = shared.seen_plugins.lock().expect("seen lock"); - let fresh = desired.keys().any(|id| !seen.contains(id)); - seen.extend(desired.keys().cloned()); - if fresh { - let mut slot = shared.host.lock().expect("host lock"); - if matches!(&*slot, HostSlot::Failed { .. }) { - *slot = HostSlot::Idle; - } - } - } - // 1. Revoke first — never waits for the host. let mut revoked: Vec = Vec::new(); let mut to_activate: Vec<(String, DesiredOwner)> = Vec::new(); { let mut registry = shared.registry.lock().expect("registry lock"); - let existing: Vec<(String, String, OwnerState)> = registry + let existing: Vec<(String, PluginAuthority)> = registry .owners() - .map(|entry| { - ( - entry.owner.plugin_id.clone(), - entry.content_hash.clone(), - entry.state.clone(), - ) - }) + .map(|entry| (entry.owner.plugin_id.clone(), entry.authority.clone())) .collect(); - for (plugin_id, content_hash, _) in &existing { - let keep = desired - .get(plugin_id) - .is_some_and(|want| &want.authority.content_hash == content_hash); + for (plugin_id, authority) in &existing { + // An explicit trust/enable transition revokes the persisted + // authority even when the bytes stay identical. Refresh that + // owner instead of retaining a tool that can only fail closed. + let keep = desired.get(plugin_id).is_some_and(|want| { + want.authority.content_hash == authority.content_hash + && want.authority.capability_hash == authority.capability_hash + && want.authority.state_generation == authority.state_generation + && want.authority.state_path == authority.state_path + }); if !keep { if let Some(owner) = registry.revoke_owner(plugin_id) { revoked.push(owner); @@ -656,7 +899,8 @@ impl ExtensionHostManager { } for (plugin_id, want) in desired { // A failed or faulted activation of these exact bytes is not - // retried every turn; a content change or a new session is. + // retried every turn; changed authority or an explicit + // plugin mutation/reload permits another attempt. if registry.owner(&plugin_id).is_none() { to_activate.push((plugin_id, want)); } @@ -698,8 +942,11 @@ impl ExtensionHostManager { if to_activate.is_empty() { return Ok(()); } - let host = self.ensure_host().await?; + let host = self.ensure_host(policy).await?; for (plugin_id, want) in to_activate { + if host.has_exited() { + break; + } self.activate_owner(&host, &plugin_id, want).await; } Ok(()) @@ -786,20 +1033,31 @@ impl ExtensionHostManager { } } - async fn ensure_host(&self) -> Result, String> { + async fn ensure_host(&self, policy: bool) -> Result, String> { let shared = &self.shared; - { + let generation = { let mut slot = shared.host.lock().expect("host lock"); match &*slot { HostSlot::Ready(host) if !host.has_exited() => return Ok(Arc::clone(host)), + HostSlot::Ready(_) | HostSlot::Unresponsive(_) => { + return Err("extension host is unavailable; waiting for supervision".into()); + } + HostSlot::Restarting { .. } => return Err("extension host is restarting".into()), HostSlot::Failed { reason, .. } => { return Err(format!( - "extension host is failed ({reason}); it restarts with the next session" + "extension host is failed ({reason}); change/reload a plugin to retry" )); } - _ => *slot = HostSlot::Starting, + HostSlot::Starting => return Err("extension host is starting".into()), + HostSlot::Idle => {} } - } + *slot = HostSlot::Starting; + let mut supervision = shared.supervision.lock().expect("supervision lock"); + supervision.last_start = Some(Instant::now()); + supervision.policy = policy; + supervision.launch_failed = false; + shared.host_generation.fetch_add(1, Ordering::SeqCst) + 1 + }; shared.spawn_attempts.fetch_add(1, Ordering::SeqCst); let options = shared.options.clone(); let prepared = tokio::task::spawn_blocking(move || -> Result { @@ -824,17 +1082,41 @@ impl ExtensionHostManager { .and_then(|result| result); let spawned = match prepared { Ok(launch) => { - let generation = shared.host_generation.fetch_add(1, Ordering::SeqCst) + 1; - let events: Arc = Arc::new(Events(Arc::downgrade(shared))); + let events: Arc = Arc::new(Events { + shared: Arc::downgrade(shared), + generation, + }); HostProcess::spawn(generation, &launch, bundle_sha256(), events).await } Err(error) => Err(error), }; let mut slot = shared.host.lock().expect("host lock"); + if shared.host_generation.load(Ordering::SeqCst) != generation + || !matches!(&*slot, HostSlot::Starting) + { + drop(slot); + if let Ok(host) = spawned { + host.terminate("host startup superseded".into()); + } + return Err("extension host startup was superseded".into()); + } match spawned { Ok(host) => { *slot = HostSlot::Ready(Arc::clone(&host)); drop(slot); + if host.has_exited() { + Events { + shared: Arc::downgrade(shared), + generation, + } + .exited( + generation, + "exited immediately after handshake".into(), + host.stderr_tail(), + ); + return Err("extension host exited immediately after handshake".into()); + } + monitor_host(shared, &host, generation); shared.diagnostic(format!( "extension host started (pid {}, node {}, sandbox {})", host.pid @@ -845,6 +1127,11 @@ impl ExtensionHostManager { Ok(host) } Err(reason) => { + shared + .supervision + .lock() + .expect("supervision lock") + .launch_failed = true; *slot = HostSlot::Failed { reason: reason.clone(), stderr_tail: String::new(), @@ -865,16 +1152,18 @@ impl ExtensionHostManager { pub async fn shutdown(&self) { let host = { let mut slot = self.shared.host.lock().expect("host lock"); + self.shared.host_generation.fetch_add(1, Ordering::SeqCst); + self.shared + .supervision + .lock() + .expect("supervision lock") + .retry_ticket += 1; match std::mem::replace(&mut *slot, HostSlot::Idle) { - HostSlot::Ready(host) => Some(host), - other => { - *slot = other; - None - } + HostSlot::Ready(host) | HostSlot::Unresponsive(host) => Some(host), + _ => None, } }; if let Some(host) = host { - self.shared.host_generation.fetch_add(1, Ordering::SeqCst); self.shared .registry .lock() @@ -921,11 +1210,15 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String { } ); } + HostStatus::Unresponsive { pid } => { + let _ = write!(out, "unresponsive · pid {} (supervisor is waiting for a pong)", pid.map_or_else(|| "?".into(), |pid| pid.to_string())); + } + HostStatus::Restarting { reason } => { let _ = write!(out, "restarting after: {reason}"); } HostStatus::Failed { reason, stderr_tail, } => { - let _ = write!(out, "failed: {reason} (restarts with the next session)"); + let _ = write!(out, "failed: {reason} (change/reload a plugin to retry)"); let tail = stderr_tail.trim(); if !tail.is_empty() { let start = tail @@ -978,6 +1271,7 @@ pub fn plugins_changed(plugins: Arc) { if activation::extension_host_policy_enabled() { let manager = manager(); manager.refresh_workspace(&plugins); + manager.retry(); manager.reconcile_in_background(); } } diff --git a/crates/tui/src/extension_host/protocol.rs b/crates/tui/src/extension_host/protocol.rs index eeb28f3a1a..ee00ae2a32 100644 --- a/crates/tui/src/extension_host/protocol.rs +++ b/crates/tui/src/extension_host/protocol.rs @@ -435,6 +435,7 @@ pub struct ToolCallParams { #[derive(Debug, Clone, PartialEq)] pub enum CoreRequest { + Ping, Initialize(InitializeParams), /// Production relies on stdin EOF at process exit (the host is shared by /// every engine in the process); the bounded shutdown is test-driven. @@ -449,6 +450,7 @@ impl CoreRequest { #[must_use] pub fn method(&self) -> &'static str { match self { + Self::Ping => "host/ping", Self::Initialize(_) => "host/initialize", #[cfg(test)] Self::Shutdown => "host/shutdown", @@ -461,6 +463,7 @@ impl CoreRequest { #[must_use] pub fn params(&self) -> Value { match self { + Self::Ping => json!({}), Self::Initialize(p) => to_value(p), #[cfg(test)] Self::Shutdown => json!({}), @@ -549,6 +552,10 @@ pub fn parse_core_message(value: Value) -> Result { let request = match method.as_str() { "host/initialize" => CoreRequest::Initialize(params(&method, p)?), "host/shutdown" => CoreRequest::Shutdown, + "host/ping" => { + let _: serde_json::Map = params(&method, p)?; + CoreRequest::Ping + } "ext/activate" => CoreRequest::Activate(params(&method, p)?), "ext/deactivate" => CoreRequest::Deactivate(params(&method, p)?), "tool/call" => CoreRequest::ToolCall(params(&method, p)?), diff --git a/crates/tui/src/extension_host/registry.rs b/crates/tui/src/extension_host/registry.rs index 53ae6e2036..3935e264a3 100644 --- a/crates/tui/src/extension_host/registry.rs +++ b/crates/tui/src/extension_host/registry.rs @@ -224,7 +224,7 @@ impl OwnerRegistry { } /// Mark an owner failed or faulted and drop everything it registered. - pub fn mark_failed(&mut self, owner: &OwnerRef, state: OwnerState) { + pub fn mark_failed(&mut self, owner: &OwnerRef, state: OwnerState) -> bool { let matches = self .owners .get(&owner.plugin_id) @@ -235,6 +235,7 @@ impl OwnerRegistry { entry.state = state; } } + matches } /// Admit or refuse one `registry/register`. @@ -391,13 +392,35 @@ impl OwnerRegistry { } /// Forget owners that are not live (failed, faulted, revoked) so a new - /// session retries them. + /// explicit plugin mutation retries them. pub fn forget_inactive(&mut self) { self.owners .retain(|_, entry| matches!(entry.state, OwnerState::Activating | OwnerState::Active)); } - /// The host exited: every owner is revoked and every tool is gone. + /// A crash drops live registrations, preserves failed/faulted receipts, + /// and blames the sole activating owner. Other owners are replayable only + /// after reconciliation verifies their current persisted authority again. + pub fn host_exited(&mut self, reason: &str) { + self.tools.clear(); + self.by_name.clear(); + let activating: Vec<_> = self + .owners + .values() + .filter(|entry| entry.state == OwnerState::Activating) + .map(|entry| entry.owner.plugin_id.clone()) + .collect(); + if let [plugin] = activating.as_slice() { + self.owners.get_mut(plugin).expect("activating owner").state = + OwnerState::Failed(format!("host crashed during activation: {reason}")); + } + self.owners.retain(|_, entry| { + matches!(entry.state, OwnerState::Failed(_) | OwnerState::Faulted(_)) + }); + } + + /// Planned test shutdown drops all tools and fails the remaining live owners. + #[cfg(test)] pub fn revoke_all(&mut self, reason: &str) { self.tools.clear(); self.by_name.clear(); diff --git a/crates/tui/src/extension_host/supervisor.rs b/crates/tui/src/extension_host/supervisor.rs index c9586a84f1..2c995b8ced 100644 --- a/crates/tui/src/extension_host/supervisor.rs +++ b/crates/tui/src/extension_host/supervisor.rs @@ -1,10 +1,8 @@ //! One extension-host process: launch plan, spawn, handshake, channel, exit. //! -//! Phase 1 has no heartbeat and no auto-restart. When the process exits for -//! any reason, every in-flight call fails with a typed error, the owner -//! registry is revoked wholesale, and the host is marked failed with its -//! stderr tail. Nothing respawns until the next session or a newly enabled -//! plugin. +//! When the process exits, every in-flight call fails with a typed error. +//! The existing Rust manager owns heartbeat, crash budget, generation changes, +//! and receipt-checked replay; this channel never replays a tool call. //! //! **OS sandbox.** Where Codewhale's default command sandbox is available //! (Seatbelt on macOS; bubblewrap stays opt-in for shell commands and is not @@ -257,6 +255,7 @@ pub(crate) struct HostProcess { next_id: AtomicU64, stderr_tail: Arc>>, exited: tokio::sync::watch::Receiver, + kill: mpsc::Sender, } fn push_tail(tail: &Mutex>, bytes: &[u8]) { @@ -454,6 +453,7 @@ impl HostProcess { next_id: AtomicU64::new(1), stderr_tail, exited: exited_rx, + kill: kill_tx.clone(), }); let handshake_result = tokio::time::timeout(HANDSHAKE_DEADLINE, async { @@ -528,6 +528,14 @@ impl HostProcess { *self.exited.borrow() } + pub(crate) fn terminate(&self, reason: String) { + let _ = self.kill.try_send(reason); + } + + pub(crate) fn stderr_tail(&self) -> String { + tail_string(&self.stderr_tail) + } + fn send_frame(&self, value: &Value) -> Result<(), HostCallError> { let frame = protocol::encode_frame(value).map_err(|error| HostCallError::Rpc { code: error_code::INVALID_PARAMS, @@ -554,7 +562,9 @@ impl HostProcess { let (tx, rx) = oneshot::channel(); { let mut pending = self.pending.lock().expect("pending lock"); - if pending.len() >= protocol::MAX_INFLIGHT { + // Reserve one control request for the single heartbeat monitor, + // so saturated tool calls cannot make a healthy host look hung. + if pending.len() >= protocol::MAX_INFLIGHT && !matches!(request, CoreRequest::Ping) { return Err(HostCallError::Busy); } pending.insert( diff --git a/crates/tui/src/extension_host/tests.rs b/crates/tui/src/extension_host/tests.rs index 72733168e6..ee125086da 100644 --- a/crates/tui/src/extension_host/tests.rs +++ b/crates/tui/src/extension_host/tests.rs @@ -389,6 +389,7 @@ impl FixturePlugins { Arc::new(ExtensionHostManager::new(ExtensionHostOptions { node_override: Some(node), root: Some(self.root.clone()), + ..Default::default() })) } } @@ -591,7 +592,7 @@ async fn disabling_mid_call_revokes_at_once_and_teardown_waits_for_async_dispose } #[tokio::test] -async fn killed_host_fails_calls_with_a_typed_error_and_does_not_respawn() { +async fn killed_host_fails_calls_once_and_replays_with_fresh_owners() { let Some(node) = node_for_tests("killed_host") else { return; }; @@ -627,19 +628,14 @@ async fn killed_host_fails_calls_with_a_typed_error_and_does_not_respawn() { } other => panic!("expected a typed not-available error, got {other:?}"), } - // Let the exit watcher publish the failure. - tokio::time::sleep(Duration::from_millis(200)).await; - assert!( - matches!(manager.status(), HostStatus::Failed { .. }), - "{:?}", - manager.status() - ); - assert!(manager.live_tool_names().is_empty()); - let report = super::render_status(&manager); - assert!(report.contains("failed"), "{report}"); - engine.sync().await.ok(); - assert_eq!(manager.spawn_attempts(), 1, "no respawn within the session"); - assert!(matches!(manager.status(), HostStatus::Failed { .. })); + wait_host(&manager, || { + manager.spawn_attempts() == 2 && manager.live_tool_names().contains(&"slow_wait".into()) + }) + .await; + assert!(matches!(manager.status(), HostStatus::Ready { .. })); + assert_ne!(manager.host_pid(), Some(pid)); + assert_eq!(manager.shared.supervision.lock().unwrap().crashes.len(), 1); + manager.shutdown().await; } #[tokio::test] @@ -745,6 +741,7 @@ async fn with_no_native_plugin_the_host_is_never_spawned() { let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions { node_override: None, root: Some(temp.path().join("home")), + ..Default::default() })); let engine = manager.attach(registry); engine.sync().await.unwrap(); @@ -1169,3 +1166,414 @@ async fn a_disable_through_either_registry_revokes_for_every_engine() { assert!(installed(&second, fixture.workspace()).is_empty()); manager.shutdown().await; } + +fn fast_supervision() -> super::SupervisionOptions { + super::SupervisionOptions { + heartbeat_interval: Duration::from_millis(50), + ping_timeout: Duration::from_millis(150), + hang_timeout: Duration::from_millis(600), + restart_backoff: Duration::from_millis(25), + ..Default::default() + } +} + +fn supervised_manager(fixture: &FixturePlugins, node: PathBuf) -> Arc { + Arc::new(ExtensionHostManager::new(ExtensionHostOptions { + node_override: Some(node), + root: Some(fixture.root.clone()), + supervision: fast_supervision(), + })) +} + +async fn wait_host(manager: &ExtensionHostManager, predicate: impl Fn() -> bool) { + tokio::time::timeout(Duration::from_secs(10), async { + while !predicate() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "host wait timed out: {:?}; {:?}", + manager.status(), + manager.diagnostics() + ) + }); +} + +#[test] +fn crash_budget_is_bounded_and_expires_only_with_the_window() { + let options = super::SupervisionOptions::default(); + let mut state = super::SupervisionState::default(); + let now = Instant::now(); + assert!(state.record_crash(now, &options)); + assert!(state.record_crash(now + Duration::from_secs(1), &options)); + assert!(!state.record_crash(now + Duration::from_secs(2), &options)); + assert_eq!(state.crashes.len(), 3); + assert!(state.record_crash( + now + options.crash_window + Duration::from_secs(3), + &options + )); + assert_eq!(state.crashes.len(), 1); +} + +#[test] +fn host_exit_preserves_failed_receipts_and_blames_only_the_activating_owner() { + let mut registry = OwnerRegistry::new(); + let active = registry.begin_owner( + "healthy", + "healthy", + fake_authority("healthy"), + "hash-healthy", + ); + registry.mark_active(&active); + register(&mut registry, &active, "healthy_probe").unwrap(); + let failed = registry.begin_owner("failed", "failed", fake_authority("failed"), "hash-failed"); + registry.mark_failed(&failed, OwnerState::Faulted("existing fault".into())); + registry.begin_owner( + "activating", + "activating", + fake_authority("activating"), + "hash-activating", + ); + registry.host_exited("fixture crash"); + assert!(registry.owner("healthy").is_none()); + assert!(registry.live_tools().is_empty()); + assert!(matches!( + registry.owner("failed").unwrap().state, + OwnerState::Faulted(_) + )); + assert!(matches!( + registry.owner("activating").unwrap().state, + OwnerState::Failed(_) + )); + let replay = registry.begin_owner( + "healthy", + "healthy", + fake_authority("healthy"), + "hash-healthy", + ); + assert_ne!(replay.generation, active.generation); + assert_ne!(replay.owner_token, active.owner_token); +} + +#[test] +fn opening_an_engine_never_resets_a_crash_budget() { + let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions::default())); + { + *manager.shared.host.lock().unwrap() = super::HostSlot::Failed { + reason: "budget".into(), + stderr_tail: String::new(), + }; + let mut state = manager.shared.supervision.lock().unwrap(); + for _ in 0..3 { + state.record_crash(Instant::now(), &manager.shared.options.supervision); + } + } + let _engine = manager.attach(Arc::new(PluginRegistry::empty(Path::new("/fixture")))); + assert_eq!(manager.shared.supervision.lock().unwrap().crashes.len(), 3); + assert!(matches!(manager.status(), HostStatus::Failed { .. })); + manager.retry(); + assert!( + manager + .shared + .supervision + .lock() + .unwrap() + .crashes + .is_empty() + ); + assert_eq!(manager.status(), HostStatus::Idle); +} + +#[tokio::test] +async fn three_crashes_stop_replay_until_explicit_retry() { + let Some(node) = node_for_tests("crash budget") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["crash-tool", "refuses-approval"]).await; + let manager = supervised_manager(&fixture, node); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); + let failed_id = plugin_id(&fixture, "refuses-approval"); + let mut previous = None; + for crash in 1..=3 { + let tool = host_tool(&engine, fixture.workspace(), "crash_probe"); + let registration = manager + .shared + .registry + .lock() + .unwrap() + .live_tools() + .into_iter() + .find(|t| t.name == "crash_probe") + .unwrap(); + if let Some(old) = previous { + assert_ne!(registration.owner, old); + } + previous = Some(registration.owner); + let outcome = tool + .execute(json!({}), &ToolContext::new(fixture.workspace())) + .await; + assert!(matches!(outcome, Err(ToolError::NotAvailable { .. }))); + if crash < 3 { + wait_host(&manager, || { + manager.spawn_attempts() == crash + 1 + && manager.live_tool_names().contains(&"crash_probe".into()) + }) + .await; + assert!(matches!( + manager.owner_state(&failed_id), + Some(OwnerState::Failed(_)) + )); + } else { + wait_host(&manager, || { + matches!(manager.status(), HostStatus::Failed { .. }) + }) + .await; + } + } + assert_eq!(manager.spawn_attempts(), 3); + let _another = manager.attach(fixture.registry()); + engine.sync().await.ok(); + assert_eq!(manager.spawn_attempts(), 3); + manager.retry(); + engine.sync().await.unwrap(); + assert_eq!(manager.spawn_attempts(), 4); + assert!( + manager + .shared + .supervision + .lock() + .unwrap() + .crashes + .is_empty() + ); + manager.shutdown().await; +} + +#[tokio::test] +async fn an_activation_crash_does_not_prevent_other_receipts_replaying() { + let Some(node) = node_for_tests("activation crash") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["crash-activation", "clash-script"]).await; + let manager = supervised_manager(&fixture, node); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); + wait_host(&manager, || { + manager.spawn_attempts() == 2 + && manager + .live_tool_names() + .contains(&"fixture_script_tool".into()) + }) + .await; + assert!(matches!( + manager.owner_state(&plugin_id(&fixture, "crash-activation")), + Some(OwnerState::Failed(_)) + )); + assert_eq!(manager.shared.supervision.lock().unwrap().crashes.len(), 1); + manager.shutdown().await; +} + +#[tokio::test] +async fn heartbeat_recovers_a_delayed_pong_then_kills_a_hung_host() { + let Some(node) = node_for_tests("heartbeat") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["hang-tool"]).await; + let manager = supervised_manager(&fixture, node); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); + let tool = host_tool(&engine, fixture.workspace(), "hang_probe"); + let workspace = fixture.workspace().to_path_buf(); + let task = tokio::spawn(async move { + tool.execute(json!({"ms": 400}), &ToolContext::new(&workspace)) + .await + }); + wait_host(&manager, || { + matches!(manager.status(), HostStatus::Unresponsive { .. }) + }) + .await; + assert!(task.await.unwrap().is_ok()); + wait_host(&manager, || { + matches!(manager.status(), HostStatus::Ready { .. }) + }) + .await; + assert_eq!(manager.spawn_attempts(), 1); + let tool = host_tool(&engine, fixture.workspace(), "hang_probe"); + let result = tokio::time::timeout( + Duration::from_secs(5), + tool.execute(json!({}), &ToolContext::new(fixture.workspace())), + ) + .await + .unwrap(); + assert!(matches!(result, Err(ToolError::NotAvailable { .. }))); + wait_host(&manager, || { + manager.spawn_attempts() == 2 && manager.live_tool_names().contains(&"hang_probe".into()) + }) + .await; + assert_eq!(manager.shared.supervision.lock().unwrap().crashes.len(), 1); + manager.shutdown().await; +} + +#[test] +fn old_host_callbacks_cannot_fault_or_remove_a_new_owner() { + use super::supervisor::HostEvents; + let manager = ExtensionHostManager::new(ExtensionHostOptions::default()); + manager + .shared + .host_generation + .store(2, std::sync::atomic::Ordering::SeqCst); + let owner = { + let mut registry = manager.shared.registry.lock().unwrap(); + let owner = registry.begin_owner( + "fixture", + "fixture", + fake_authority("fixture"), + "hash-fixture", + ); + registry.mark_active(&owner); + register(&mut registry, &owner, "fixture_probe").unwrap(); + owner + }; + let old = super::Events { + shared: Arc::downgrade(&manager.shared), + generation: 1, + }; + old.faulted(&protocol::FaultedParams { + owner, + error: "stale fault".into(), + }); + old.exited(1, "stale exit".into(), String::new()); + assert_eq!(manager.owner_state("fixture"), Some(OwnerState::Active)); + assert_eq!(manager.live_tool_names(), ["fixture_probe"]); + assert!( + manager + .shared + .supervision + .lock() + .unwrap() + .crashes + .is_empty() + ); +} + +#[test] +fn a_new_attachment_retries_only_cooled_down_launch_failures() { + let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions::default())); + *manager.shared.host.lock().unwrap() = super::HostSlot::Failed { + reason: "missing Node".into(), + stderr_tail: String::new(), + }; + { + let mut state = manager.shared.supervision.lock().unwrap(); + state.launch_failed = true; + state.last_start = Some(Instant::now()); + } + let plugins = Arc::new(PluginRegistry::empty(Path::new("/fixture"))); + let _first = manager.attach(Arc::clone(&plugins)); + assert!(matches!(manager.status(), HostStatus::Failed { .. })); + manager.shared.supervision.lock().unwrap().last_start = + Some(Instant::now() - Duration::from_secs(61)); + let _later = manager.attach(plugins); + assert_eq!(manager.status(), HostStatus::Idle); +} + +#[tokio::test] +async fn replay_rechecks_persisted_disable_and_keeps_workspace_tools_separate() { + let Some(node) = node_for_tests("replay authority") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let a = FixturePlugins::new(&["crash-tool"]).await; + let b = FixturePlugins::new(&["clash-script"]).await; + let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions { + node_override: Some(node), + root: Some(a.root.clone()), + supervision: super::SupervisionOptions { + restart_backoff: Duration::from_secs(1), + ..fast_supervision() + }, + })); + let first = manager.attach(a.registry()); + let second = manager.attach(b.registry()); + first.sync().await.unwrap(); + let tool = host_tool(&first, a.workspace(), "crash_probe"); + assert!(matches!( + tool.execute(json!({}), &ToolContext::new(a.workspace())) + .await, + Err(ToolError::NotAvailable { .. }) + )); + wait_host(&manager, || { + matches!(manager.status(), HostStatus::Restarting { .. }) + }) + .await; + // Keep the engine's snapshot stale deliberately. Replay must consult the + // persisted state rather than restoring the previous owner's authority. + a.disable("crash-tool"); + wait_host(&manager, || { + manager.spawn_attempts() == 2 + && manager + .live_tool_names() + .contains(&"fixture_script_tool".into()) + }) + .await; + assert!(installed(&first, a.workspace()).is_empty()); + assert_eq!(installed(&second, b.workspace()), ["fixture_script_tool"]); + assert!(manager.owner_state(&plugin_id(&a, "crash-tool")).is_none()); + manager.shutdown().await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(manager.status(), HostStatus::Idle); + assert_eq!( + manager.spawn_attempts(), + 2, + "planned shutdown never restarts" + ); +} + +#[tokio::test] +async fn explicit_retry_refreshes_same_byte_authority_without_inheriting_old_handles() { + let Some(node) = node_for_tests("same byte retry") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["clash-script"]).await; + let manager = supervised_manager(&fixture, node); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); + let old = host_tool(&engine, fixture.workspace(), "fixture_script_tool"); + let old_owner = manager.shared.registry.lock().unwrap().live_tools()[0] + .owner + .clone(); + let mut updated = discover_with_config(&fixture.config); + updated.enable("clash-script").unwrap(); + manager.refresh_workspace(&Arc::new(updated)); + manager.retry(); + engine.sync().await.unwrap(); + let current_owner = manager.shared.registry.lock().unwrap().live_tools()[0] + .owner + .clone(); + assert_ne!(old_owner, current_owner); + assert!(matches!( + old.execute(json!({}), &ToolContext::new(fixture.workspace())) + .await, + Err(ToolError::NotAvailable { .. }) + )); + let current = host_tool(&engine, fixture.workspace(), "fixture_script_tool"); + assert!( + current + .execute(json!({}), &ToolContext::new(fixture.workspace())) + .await + .is_ok() + ); + assert_eq!( + manager.spawn_attempts(), + 1, + "a healthy process need not restart" + ); + manager.shutdown().await; +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 7acfa4c6cc..ed9e00c44a 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -8766,6 +8766,7 @@ fn install_extension_host_boot_config(config: &Config) { .and_then(|table| table.node.as_deref()) .map(|node| PathBuf::from(shellexpand::tilde(node).as_ref())), root: None, + ..Default::default() }); } } diff --git a/crates/tui/tests/fixtures/extension_host/crash-activation/index.mjs b/crates/tui/tests/fixtures/extension_host/crash-activation/index.mjs new file mode 100644 index 0000000000..023764adaf --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/crash-activation/index.mjs @@ -0,0 +1 @@ +export function apply() { process.kill(process.pid, 'SIGKILL') } diff --git a/crates/tui/tests/fixtures/extension_host/crash-activation/plugin.json b/crates/tui/tests/fixtures/extension_host/crash-activation/plugin.json new file mode 100644 index 0000000000..5851b54342 --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/crash-activation/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "crash-activation", + "version": "0.1.0", + "description": "Supervision test fixture.", + "license": "MIT", + "extensions": { + "net.codewhale": { + "native": { + "path": "index.mjs" + } + } + } +} diff --git a/crates/tui/tests/fixtures/extension_host/crash-tool/index.mjs b/crates/tui/tests/fixtures/extension_host/crash-tool/index.mjs new file mode 100644 index 0000000000..1650fe79f6 --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/crash-tool/index.mjs @@ -0,0 +1,7 @@ +export const inject = ['tools'] +export function apply(ctx) { + ctx.tools.register({ name: 'crash_probe', description: 'Terminate this fixture host.', + parameters: { type: 'object', properties: {} }, + execute() { process.kill(process.pid, 'SIGKILL') }, + }) +} diff --git a/crates/tui/tests/fixtures/extension_host/crash-tool/plugin.json b/crates/tui/tests/fixtures/extension_host/crash-tool/plugin.json new file mode 100644 index 0000000000..40709c98ab --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/crash-tool/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "crash-tool", + "version": "0.1.0", + "description": "Supervision test fixture.", + "license": "MIT", + "extensions": { + "net.codewhale": { + "native": { + "path": "index.mjs" + } + } + } +} diff --git a/crates/tui/tests/fixtures/extension_host/hang-tool/index.mjs b/crates/tui/tests/fixtures/extension_host/hang-tool/index.mjs new file mode 100644 index 0000000000..f2eaaa90c1 --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/hang-tool/index.mjs @@ -0,0 +1,11 @@ +export const inject = ['tools'] +export function apply(ctx) { + ctx.tools.register({ name: 'hang_probe', description: 'Block the fixture event loop.', + parameters: { type: 'object', properties: { ms: { type: 'number' } } }, + execute(args) { + const end = args.ms === undefined ? Infinity : Date.now() + args.ms + while (Date.now() < end) {} + return { resumed: true } + }, + }) +} diff --git a/crates/tui/tests/fixtures/extension_host/hang-tool/plugin.json b/crates/tui/tests/fixtures/extension_host/hang-tool/plugin.json new file mode 100644 index 0000000000..9b0092bfbe --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/hang-tool/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "hang-tool", + "version": "0.1.0", + "description": "Supervision test fixture.", + "license": "MIT", + "extensions": { + "net.codewhale": { + "native": { + "path": "index.mjs" + } + } + } +} diff --git a/crates/tui/tests/fixtures/extension_host/protocol/29-host-ping.json b/crates/tui/tests/fixtures/extension_host/protocol/29-host-ping.json new file mode 100644 index 0000000000..5858690c38 --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/protocol/29-host-ping.json @@ -0,0 +1,10 @@ +{ + "direction": "core_to_host", + "valid": true, + "frame": { + "jsonrpc": "2.0", + "id": 29, + "method": "host/ping", + "params": {} + } +} diff --git a/crates/tui/tests/fixtures/extension_host/protocol/30-host-ping-invalid-params.json b/crates/tui/tests/fixtures/extension_host/protocol/30-host-ping-invalid-params.json new file mode 100644 index 0000000000..6846661bdb --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/protocol/30-host-ping-invalid-params.json @@ -0,0 +1,10 @@ +{ + "direction": "core_to_host", + "valid": false, + "frame": { + "jsonrpc": "2.0", + "id": 29, + "method": "host/ping", + "params": [] + } +} diff --git a/docs/design/TS_EXTENSION_HOST.md b/docs/design/TS_EXTENSION_HOST.md index 22eaf83bcb..2f9d8e2ed1 100644 --- a/docs/design/TS_EXTENSION_HOST.md +++ b/docs/design/TS_EXTENSION_HOST.md @@ -2,11 +2,42 @@ > **Repository copy.** This is the design as reviewed on 2026-09-25, copied from > the private release plan (`codewhale-ops/releases/0.10.1/plans-20260925/`) so -> the code and its design live together. Section "As built: phase 1" below -> records where the phase-1 implementation (`crates/tui/extension-host/`, +> the code and its design live together. The "As built" sections below +> record where the implementation (`crates/tui/extension-host/`, > `crates/tui/src/extension_host/`, behind `[features] extension_host`) differs -> from the text that follows. Where they disagree, that section and the code -> are current; the rest is the plan for later phases. +> from the text that follows. Where they disagree, the newest "As built" +> section and the code are current; the rest is the plan for later phases. + +## As built: phase 2a supervision (2026-09-29) + +The experimental host now has bounded lifecycle supervision. It uses the same +Rust manager, owner registry, attachment snapshots, and approval gate: + +- `host/ping` runs every 3 seconds. A ping unanswered for 3 seconds marks the + host **Unresponsive**; after 10 seconds the existing process-tree supervisor + kills it. A pong restores Ready only for that generation. Deadlines use a + monotonic clock; laptop suspend/resume behavior has not been qualified. +- Unexpected exits, protocol violations and hang kills share one crash budget. + Below 3 crashes in 5 minutes, the manager waits 250 ms then revalidates current + attachments and replays eligible owners with fresh generations and tokens. + The third crash stops recovery and retains the failure/stderr diagnostic. + Opening another engine and replaying a host never reset this budget. +- In-flight tool calls fail with the existing typed unavailable error; they + are **never replayed**. Failed/faulted receipts remain suppressed. A crash + during the sole activating owner's initialization is attributed to that + receipt, so other valid plugins can recover. +- Explicit plugin changes/reload clear the crash budget and retry failed + receipts through the existing `plugins_changed` path. Start failures also + permit a new engine attachment to retry once the one-minute cooldown has + elapsed. There is no automatic handshake/start-failure loop. +- Old-generation callbacks and recovery tickets cannot mutate a newer host. + Planned shutdown is not a crash. Native-entry, staged-byte, persisted-state, + approval-grant and platform sandbox rules remain unchanged. + +This slice does **not** add dirty-teardown idle restarts, per-owner author logs, +`exec.cwd`, `.mts` entries, commands, hooks, MCP, `core/call`, or sandbox parity. +Those remain subsequent work. The following phase-1 section is its historical +receipt, including the earlier lack of heartbeat/restart. ## As built: phase 1 (2026-09-25) From 5bb2da5c8125286d2e356b0c7d5928205c4c3dff Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 29 Sep 2026 03:23:40 -0700 Subject: [PATCH 5/8] fix(extensions): retire dirty hosts after active calls finish Count incomplete, leaking, malformed and timed-out teardown results on both revocation and failed-activation cleanup. Two events within ten minutes request one planned restart through the existing generation-bound monitor and reconciliation path. Wait for reconciliation and non-heartbeat requests to become idle, then seal new admission under the pending-map lock before retiring the process tree. Treat the sealed process as retiring before its exit callback, so a new reconcile cannot falsely fail a valid owner's activation. Bind planned maintenance to the exact generation and exit reason; preserve the unexpected-crash budget, failed receipts and normal replay validation. Ignore stale teardown outcomes from the retired host and never replay calls. Validation: - Production codewhale-tui library check passed with locked dependencies and warnings denied. - Focused Rust host/engine/approval tests: 52 passed, 0 failed, 0 ignored, 13792 filtered; real Node fixtures required, including dirty disposal, failed-activation cleanup, atomic admission, live-call completion and fresh-owner replay. - npm test: 639 passed, 0 failed (68 wrapper, 16 SDK, 53 host, 502 web). - npm run check:web: exit 0; facts, docs, tokens, lint, types and production build passed with the canonical local Git object store supplying partial-clone history. - Formatting and diff checks passed. Real-Node fixture smoke verified disposal reports false after its deadline while heartbeat stays responsive. The existing large-unwind linker warning is separate from source diagnostics. This completes Phase 2a dirty-teardown recovery; the scoped owner report, cwd/mts and author guide/example/skill work follow. No hosted CI, provider or release qualification is claimed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks Signed-off-by: Hunter B --- crates/tui/src/extension_host/mod.rs | 153 ++++++++--- crates/tui/src/extension_host/supervisor.rs | 33 ++- crates/tui/src/extension_host/tests.rs | 245 ++++++++++++++++++ .../extension_host/dirty-dispose/index.mjs | 6 + .../extension_host/dirty-dispose/plugin.json | 12 + docs/design/TS_EXTENSION_HOST.md | 13 +- 6 files changed, 420 insertions(+), 42 deletions(-) create mode 100644 crates/tui/tests/fixtures/extension_host/dirty-dispose/index.mjs create mode 100644 crates/tui/tests/fixtures/extension_host/dirty-dispose/plugin.json diff --git a/crates/tui/src/extension_host/mod.rs b/crates/tui/src/extension_host/mod.rs index 8e34548361..20e0fb615d 100644 --- a/crates/tui/src/extension_host/mod.rs +++ b/crates/tui/src/extension_host/mod.rs @@ -33,9 +33,9 @@ //! * Heartbeat and bounded automatic restart preserve the shared crash budget //! across engine creation and replay. Dead-host calls fail with a typed //! error and are never replayed. Three crashes in five minutes require an -//! explicit plugin change/reload to retry. A teardown that times -//! out or reports leaks is logged in `/plugin`; the plugin's leftover -//! JavaScript keeps running until the host process ends. +//! explicit plugin change/reload to retry. Two dirty teardowns within ten +//! minutes retire the process once non-heartbeat calls are idle, without +//! resetting or consuming the unexpected-crash budget. //! * One host per engine process and one trust tier. On macOS (Seatbelt) the //! host has no direct network, and cannot read the Codewhale home (except //! the bundle, its data dir and plugin code), the Codex and DSH credential @@ -97,6 +97,7 @@ use crate::plugins::types::PluginAuthority; const BUNDLE: &[u8] = include_bytes!("../../extension-host/dist/codewhale-extension-host.mjs"); const BUNDLE_FILE_NAME: &str = "codewhale-extension-host.mjs"; const MAX_DIAGNOSTICS: usize = 64; +const DIRTY_RESTART_REASON: &str = "planned restart after repeated dirty teardowns"; fn hex(bytes: impl AsRef<[u8]>) -> String { bytes @@ -177,6 +178,8 @@ pub struct SupervisionOptions { pub crash_window: Duration, pub crash_limit: usize, pub start_retry_cooldown: Duration, + pub dirty_window: Duration, + pub dirty_limit: usize, } impl Default for SupervisionOptions { @@ -189,6 +192,8 @@ impl Default for SupervisionOptions { crash_window: Duration::from_secs(5 * 60), crash_limit: 3, start_retry_cooldown: Duration::from_secs(60), + dirty_window: Duration::from_secs(10 * 60), + dirty_limit: 2, } } } @@ -200,9 +205,28 @@ struct SupervisionState { launch_failed: bool, retry_ticket: u64, policy: bool, + dirty_teardowns: VecDeque, + dirty_restart_pending: bool, + planned_restart: Option, } impl SupervisionState { + fn record_dirty_teardown(&mut self, now: Instant, options: &SupervisionOptions) { + while self + .dirty_teardowns + .front() + .is_some_and(|at| now.saturating_duration_since(*at) >= options.dirty_window) + { + self.dirty_teardowns.pop_front(); + } + self.dirty_teardowns.push_back(now); + let limit = options.dirty_limit.max(1); + self.dirty_restart_pending |= self.dirty_teardowns.len() >= limit; + while self.dirty_teardowns.len() > limit { + self.dirty_teardowns.pop_front(); + } + } + fn record_crash(&mut self, now: Instant, options: &SupervisionOptions) -> bool { while self .crashes @@ -278,6 +302,46 @@ pub(crate) struct ManagerShared { } impl ManagerShared { + async fn deactivate_owner(&self, host: &Arc, owner: &OwnerRef) { + let diagnostic = match host + .request_with_deadline( + CoreRequest::Deactivate(DeactivateParams { + owner: owner.clone(), + }), + None, + DISPOSE_DEADLINE + Duration::from_millis(500), + ) + .await + .map(serde_json::from_value::) + { + Ok(Ok(ack)) if ack.disposed && ack.leaked.is_empty() => return, + Ok(Ok(ack)) => format!( + "extension `{}` teardown incomplete (disposed: {}, leaked: {:?})", + owner.plugin_id, ack.disposed, ack.leaked + ), + Ok(Err(error)) => format!( + "extension `{}` teardown answer malformed: {error}", + owner.plugin_id + ), + Err(error) => format!("extension `{}` teardown failed: {error}", owner.plugin_id), + }; + self.diagnostic(diagnostic); + self.record_dirty_teardown(host); + } + + fn record_dirty_teardown(&self, host: &Arc) { + let slot = self.host.lock().expect("host lock"); + if !matches!(&*slot, HostSlot::Ready(current) | HostSlot::Unresponsive(current) + if Arc::ptr_eq(current, host)) + { + return; + } + self.supervision + .lock() + .expect("supervision lock") + .record_dirty_teardown(Instant::now(), &self.options.supervision); + } + fn diagnostic(&self, message: String) { tracing::info!(target: "extension_host", "{message}"); let mut diagnostics = self.diagnostics.lock().expect("diagnostics lock"); @@ -289,7 +353,9 @@ impl ManagerShared { fn ready_host(&self) -> Option> { match &*self.host.lock().expect("host lock") { - HostSlot::Ready(host) if !host.has_exited() => Some(Arc::clone(host)), + HostSlot::Ready(host) if !host.has_exited() && !host.is_retiring() => { + Some(Arc::clone(host)) + } _ => None, } } @@ -414,7 +480,14 @@ impl HostEvents for Events { return; } let mut supervision = shared.supervision.lock().expect("supervision lock"); - let restart = supervision.record_crash(Instant::now(), &shared.options.supervision); + let planned = supervision.planned_restart.take() == Some(host_generation) + && reason == DIRTY_RESTART_REASON; + let restart = if planned { + supervision.retry_ticket += 1; + true + } else { + supervision.record_crash(Instant::now(), &shared.options.supervision) + }; shared .registry .lock() @@ -489,6 +562,29 @@ fn set_host_health(shared: &ManagerShared, generation: u64, unresponsive: bool) true } +fn restart_dirty_host_when_idle( + shared: &ManagerShared, + host: &Arc, + generation: u64, +) -> bool { + // Reconciliation owns activation/deactivation between wire requests too. + let Ok(_serial) = shared.sync_lock.try_lock() else { + return false; + }; + let slot = shared.host.lock().expect("host lock"); + if shared.host_generation.load(Ordering::SeqCst) != generation + || !matches!(&*slot, HostSlot::Ready(current) if Arc::ptr_eq(current, host)) + { + return false; + } + let mut supervision = shared.supervision.lock().expect("supervision lock"); + if !supervision.dirty_restart_pending || !host.terminate_if_idle(DIRTY_RESTART_REASON) { + return false; + } + supervision.planned_restart = Some(generation); + true +} + /// A monitor never owns the manager. Dropping the manager or changing host /// generation stops its monitor; pending calls are never retried here. fn monitor_host(shared: &Arc, host: &Arc, generation: u64) { @@ -505,6 +601,9 @@ fn monitor_host(shared: &Arc, host: &Arc, generation if shared.host_generation.load(Ordering::SeqCst) != generation || host.has_exited() { return; } + if restart_dirty_host_when_idle(&shared, &host, generation) { + return; + } drop(shared); let (id, mut answer) = match host.start_request(CoreRequest::Ping, None) { Ok(request) => { @@ -601,6 +700,9 @@ impl ExtensionHostManager { match &*self.shared.host.lock().expect("host lock") { HostSlot::Idle => HostStatus::Idle, HostSlot::Starting => HostStatus::Starting, + HostSlot::Ready(host) if host.is_retiring() => HostStatus::Restarting { + reason: DIRTY_RESTART_REASON.to_string(), + }, HostSlot::Ready(host) => HostStatus::Ready { pid: host.pid, node_version: host.node_version.get().cloned().unwrap_or_default(), @@ -911,31 +1013,7 @@ impl ExtensionHostManager { shared.diagnostic(format!("extension `{}` revoked", owner.plugin_id)); if let Some(host) = &host { host.revoke_calls_of(&owner.plugin_id); - match host - .request_with_deadline( - CoreRequest::Deactivate(DeactivateParams { - owner: owner.clone(), - }), - None, - DISPOSE_DEADLINE + std::time::Duration::from_millis(500), - ) - .await - .map(serde_json::from_value::) - { - Ok(Ok(ack)) if ack.disposed && ack.leaked.is_empty() => {} - Ok(Ok(ack)) => shared.diagnostic(format!( - "extension `{}` teardown incomplete (disposed: {}, leaked: {:?})", - owner.plugin_id, ack.disposed, ack.leaked - )), - Ok(Err(error)) => shared.diagnostic(format!( - "extension `{}` teardown answer malformed: {error}", - owner.plugin_id - )), - Err(error) => shared.diagnostic(format!( - "extension `{}` teardown failed: {error}", - owner.plugin_id - )), - } + shared.deactivate_owner(host, &owner).await; } } @@ -1022,13 +1100,7 @@ impl ExtensionHostManager { "extension `{}` failed to activate: {reason}", want.plugin_name )); - let _ = host - .request_with_deadline( - CoreRequest::Deactivate(DeactivateParams { owner }), - None, - DISPOSE_DEADLINE, - ) - .await; + shared.deactivate_owner(host, &owner).await; } } } @@ -1038,7 +1110,9 @@ impl ExtensionHostManager { let generation = { let mut slot = shared.host.lock().expect("host lock"); match &*slot { - HostSlot::Ready(host) if !host.has_exited() => return Ok(Arc::clone(host)), + HostSlot::Ready(host) if !host.has_exited() && !host.is_retiring() => { + return Ok(Arc::clone(host)); + } HostSlot::Ready(_) | HostSlot::Unresponsive(_) => { return Err("extension host is unavailable; waiting for supervision".into()); } @@ -1056,6 +1130,9 @@ impl ExtensionHostManager { supervision.last_start = Some(Instant::now()); supervision.policy = policy; supervision.launch_failed = false; + supervision.dirty_teardowns.clear(); + supervision.dirty_restart_pending = false; + supervision.planned_restart = None; shared.host_generation.fetch_add(1, Ordering::SeqCst) + 1 }; shared.spawn_attempts.fetch_add(1, Ordering::SeqCst); diff --git a/crates/tui/src/extension_host/supervisor.rs b/crates/tui/src/extension_host/supervisor.rs index 2c995b8ced..107c86f1e9 100644 --- a/crates/tui/src/extension_host/supervisor.rs +++ b/crates/tui/src/extension_host/supervisor.rs @@ -20,7 +20,7 @@ use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -236,6 +236,7 @@ struct PendingCall { /// Plugin whose revocation cancels this call. owner: Option, revoked: bool, + heartbeat: bool, } #[derive(Default)] @@ -252,6 +253,9 @@ pub(crate) struct HostProcess { tree: Arc, outbound: mpsc::Sender>, pending: Arc>>, + /// Admission checks and sealing hold `pending`; the manager also reads + /// this flag to avoid activation while the exit callback is still pending. + admission_closed: AtomicBool, next_id: AtomicU64, stderr_tail: Arc>>, exited: tokio::sync::watch::Receiver, @@ -415,6 +419,7 @@ impl HostProcess { let tree = Arc::clone(&tree); tokio::spawn(async move { let reason = tokio::select! { + biased; status = child.wait() => match status { Ok(status) => format!("exited with {status}"), Err(error) => format!("wait failed: {error}"), @@ -450,6 +455,7 @@ impl HostProcess { tree, outbound, pending, + admission_closed: AtomicBool::new(false), next_id: AtomicU64::new(1), stderr_tail, exited: exited_rx, @@ -532,6 +538,27 @@ impl HostProcess { let _ = self.kill.try_send(reason); } + pub(crate) fn is_retiring(&self) -> bool { + self.admission_closed.load(Ordering::Acquire) + } + + /// Seal admission and retire this process only if no non-heartbeat call + /// is pending. The same lock guards admission in `start_request`. + pub(crate) fn terminate_if_idle(&self, reason: &str) -> bool { + let pending = self.pending.lock().expect("pending lock"); + if self.has_exited() + || self.admission_closed.load(Ordering::Relaxed) + || pending.values().any(|call| !call.heartbeat) + { + return false; + } + if self.kill.try_send(reason.to_string()).is_err() { + return false; + } + self.admission_closed.store(true, Ordering::Release); + true + } + pub(crate) fn stderr_tail(&self) -> String { tail_string(&self.stderr_tail) } @@ -562,6 +589,9 @@ impl HostProcess { let (tx, rx) = oneshot::channel(); { let mut pending = self.pending.lock().expect("pending lock"); + if self.admission_closed.load(Ordering::Relaxed) { + return Err(HostCallError::Exited("host is restarting".to_string())); + } // Reserve one control request for the single heartbeat monitor, // so saturated tool calls cannot make a healthy host look hung. if pending.len() >= protocol::MAX_INFLIGHT && !matches!(request, CoreRequest::Ping) { @@ -573,6 +603,7 @@ impl HostProcess { tx, owner, revoked: false, + heartbeat: matches!(request, CoreRequest::Ping), }, ); } diff --git a/crates/tui/src/extension_host/tests.rs b/crates/tui/src/extension_host/tests.rs index ee125086da..3b274a4366 100644 --- a/crates/tui/src/extension_host/tests.rs +++ b/crates/tui/src/extension_host/tests.rs @@ -1217,6 +1217,251 @@ fn crash_budget_is_bounded_and_expires_only_with_the_window() { assert_eq!(state.crashes.len(), 1); } +#[test] +fn dirty_teardown_window_is_bounded_and_a_requested_restart_waits_for_idle() { + let options = super::SupervisionOptions::default(); + let mut state = super::SupervisionState::default(); + let now = Instant::now(); + state.record_dirty_teardown(now, &options); + assert!(!state.dirty_restart_pending); + state.record_dirty_teardown(now + options.dirty_window, &options); + assert!(!state.dirty_restart_pending, "the first event expired"); + state.record_dirty_teardown( + now + options.dirty_window + Duration::from_secs(1), + &options, + ); + assert!(state.dirty_restart_pending); + for second in 2..100 { + state.record_dirty_teardown( + now + options.dirty_window + Duration::from_secs(second), + &options, + ); + } + assert_eq!(state.dirty_teardowns.len(), 2); + state.record_dirty_teardown(now + options.dirty_window * 3, &options); + assert!( + state.dirty_restart_pending, + "an idle request does not expire" + ); + assert!(state.crashes.is_empty()); +} + +#[tokio::test] +async fn idle_retirement_seals_admission_and_does_not_wait_for_heartbeat() { + use futures_util::FutureExt; + + let Some(node) = node_for_tests("idle admission") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["slow-tool"]).await; + let manager = supervised_manager(&fixture, node); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); + let host = manager.shared.ready_host().unwrap(); + let registration = manager.shared.registry.lock().unwrap().live_tools()[0].clone(); + let (_, call) = host + .start_request( + protocol::CoreRequest::ToolCall(protocol::ToolCallParams { + handle: registration.handle, + call_id: "idle-admission".into(), + input: json!({"ms": 100}), + deadline_ms: 5000, + }), + Some(registration.owner.plugin_id), + ) + .unwrap(); + assert!(!host.terminate_if_idle(super::DIRTY_RESTART_REASON)); + assert!(call.await.unwrap().is_ok()); + // No await between admission and retirement: this heartbeat is still in + // the pending map, but it does not make the process busy. + let (_, _heartbeat) = host + .start_request(protocol::CoreRequest::Ping, None) + .unwrap(); + assert!(host.terminate_if_idle(super::DIRTY_RESTART_REASON)); + assert!(manager.shared.ready_host().is_none()); + assert!(matches!(manager.status(), HostStatus::Restarting { .. })); + // Poll without yielding to the exit watcher. A reconcile in this exact + // gap must not start activation on the sealed process and falsely fail + // a valid receipt before replay. + assert!(manager.ensure_host(true).now_or_never().unwrap().is_err()); + assert_eq!( + manager.owner_state(&plugin_id(&fixture, "slow-tool")), + Some(OwnerState::Active) + ); + assert!(matches!( + host.start_request(protocol::CoreRequest::Ping, None), + Err(super::supervisor::HostCallError::Exited(_)) + )); + assert!(!host.terminate_if_idle(super::DIRTY_RESTART_REASON)); + manager.shutdown().await; +} + +#[tokio::test] +async fn two_dirty_teardowns_wait_for_a_live_call_then_replay_without_spending_crash_budget() { + let Some(node) = node_for_tests("dirty teardown") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["dirty-dispose", "slow-tool"]).await; + let manager = supervised_manager(&fixture, node); + let engine = manager.attach(fixture.registry()); + engine.sync().await.unwrap(); + // An existing unexpected crash must survive planned maintenance. + manager + .shared + .supervision + .lock() + .unwrap() + .record_crash(Instant::now(), &manager.shared.options.supervision); + engine.set_plugins(fixture.disable("dirty-dispose")); + engine.sync().await.unwrap(); + assert_eq!( + manager + .shared + .supervision + .lock() + .unwrap() + .dirty_teardowns + .len(), + 1 + ); + tokio::time::sleep(Duration::from_millis(150)).await; + assert_eq!( + manager.spawn_attempts(), + 1, + "one dirty event is insufficient" + ); + + let mut enabled = discover_with_config(&fixture.config); + enabled.enable("dirty-dispose").unwrap(); + engine.set_plugins(Arc::new(enabled)); + engine.sync().await.unwrap(); + let old = host_tool(&engine, fixture.workspace(), "slow_wait"); + let host = manager.shared.ready_host().unwrap(); + let registration = manager.shared.registry.lock().unwrap().live_tools()[0].clone(); + let (_, call) = host + .start_request( + protocol::CoreRequest::ToolCall(protocol::ToolCallParams { + handle: registration.handle, + call_id: "survives-dirty-teardown".into(), + input: json!({"ms": 4000}), + deadline_ms: 10000, + }), + Some(registration.owner.plugin_id.clone()), + ) + .unwrap(); + engine.set_plugins(fixture.disable("dirty-dispose")); + engine.sync().await.unwrap(); + assert!( + manager + .shared + .supervision + .lock() + .unwrap() + .dirty_restart_pending + ); + tokio::time::sleep(Duration::from_millis(150)).await; + assert_eq!(manager.spawn_attempts(), 1, "a live call defers retirement"); + let completed = tokio::time::timeout(Duration::from_secs(10), call) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(completed["structured"]["waited"], 4000); + wait_host(&manager, || { + manager.spawn_attempts() == 2 && manager.live_tool_names().contains(&"slow_wait".into()) + }) + .await; + assert_eq!(manager.shared.supervision.lock().unwrap().crashes.len(), 1); + assert!( + !manager + .shared + .supervision + .lock() + .unwrap() + .dirty_restart_pending + ); + let replayed = manager.shared.registry.lock().unwrap().live_tools()[0].clone(); + assert_ne!(registration.owner, replayed.owner); + assert_ne!(registration.handle, replayed.handle); + assert!(matches!( + old.execute(json!({"ms": 1}), &ToolContext::new(fixture.workspace())) + .await, + Err(ToolError::NotAvailable { .. }) + )); + // A delayed outcome from the retired process cannot dirty its replacement. + manager.shared.record_dirty_teardown(&host); + assert!( + manager + .shared + .supervision + .lock() + .unwrap() + .dirty_teardowns + .is_empty() + ); + manager.shutdown().await; +} + +#[tokio::test] +async fn failed_activation_cleanup_also_records_a_dirty_teardown() { + let Some(node) = node_for_tests("failed activation teardown") else { + return; + }; + let _policy = TestPolicyGuard::extension_host(true); + let fixture = FixturePlugins::new(&["clash-script"]).await; + native_bundle( + &fixture.config.user_plugins_dir, + "failed-disposal", + "index.mjs", + &["index.mjs"], + ); + std::fs::write( + fixture.config.user_plugins_dir.join("failed-disposal/index.mjs"), + "export const name = 'failed-disposal';\nexport function apply(ctx) {\n ctx.effect(() => () => new Promise(() => {}), 'unfinished activation cleanup');\n throw new Error('fixture activation failure');\n}\n", + ).unwrap(); + let mut plugins = discover_with_config(&fixture.config); + plugins.trust("failed-disposal").unwrap(); + plugins.enable("failed-disposal").unwrap(); + let manager = supervised_manager(&fixture, node); + let engine = manager.attach(Arc::new(plugins)); + tokio::time::timeout(Duration::from_secs(15), engine.sync()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + manager.owner_state(&plugin_id(&fixture, "failed-disposal")), + Some(OwnerState::Failed(_)) + )); + assert_eq!( + manager + .shared + .supervision + .lock() + .unwrap() + .dirty_teardowns + .len(), + 1 + ); + assert!( + manager + .shared + .supervision + .lock() + .unwrap() + .crashes + .is_empty() + ); + assert_eq!(manager.spawn_attempts(), 1); + assert!( + manager + .live_tool_names() + .contains(&"fixture_script_tool".into()) + ); + manager.shutdown().await; +} + #[test] fn host_exit_preserves_failed_receipts_and_blames_only_the_activating_owner() { let mut registry = OwnerRegistry::new(); diff --git a/crates/tui/tests/fixtures/extension_host/dirty-dispose/index.mjs b/crates/tui/tests/fixtures/extension_host/dirty-dispose/index.mjs new file mode 100644 index 0000000000..8365f19c50 --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/dirty-dispose/index.mjs @@ -0,0 +1,6 @@ +export const name = 'dirty-dispose' + +export function apply(ctx) { + // Keep the event loop responsive while the existing disposal deadline runs. + ctx.effect(() => () => new Promise(() => {}), 'unfinished fixture disposal') +} diff --git a/crates/tui/tests/fixtures/extension_host/dirty-dispose/plugin.json b/crates/tui/tests/fixtures/extension_host/dirty-dispose/plugin.json new file mode 100644 index 0000000000..8953783b30 --- /dev/null +++ b/crates/tui/tests/fixtures/extension_host/dirty-dispose/plugin.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "dirty-dispose", + "version": "0.1.0", + "description": "Test fixture: an asynchronous disposer that never finishes.", + "license": "MIT", + "extensions": { + "net.codewhale": { + "native": { "path": "index.mjs" } + } + } +} diff --git a/docs/design/TS_EXTENSION_HOST.md b/docs/design/TS_EXTENSION_HOST.md index 2f9d8e2ed1..6efbe536aa 100644 --- a/docs/design/TS_EXTENSION_HOST.md +++ b/docs/design/TS_EXTENSION_HOST.md @@ -33,9 +33,16 @@ Rust manager, owner registry, attachment snapshots, and approval gate: - Old-generation callbacks and recovery tickets cannot mutate a newer host. Planned shutdown is not a crash. Native-entry, staged-byte, persisted-state, approval-grant and platform sandbox rules remain unchanged. - -This slice does **not** add dirty-teardown idle restarts, per-owner author logs, -`exec.cwd`, `.mts` entries, commands, hooks, MCP, `core/call`, or sandbox parity. +- Two incomplete, leaking, malformed or failed teardowns in ten minutes request + one planned restart. The existing monitor waits until reconciliation and + all non-heartbeat requests are idle, then atomically closes request admission + before retiring the process tree. Current valid owners replay with fresh + tokens; calls are never replayed. This maintenance neither consumes nor + resets the unexpected-crash budget. Late old-process outcomes cannot dirty + the replacement. + +This slice does **not** add per-owner author logs, `exec.cwd`, `.mts` entries, +commands, hooks, MCP, `core/call`, or sandbox parity. Those remain subsequent work. The following phase-1 section is its historical receipt, including the earlier lack of heartbeat/restart. From 668c90e538f3947c3da4cb3aba47e2246ed453bf Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 29 Sep 2026 03:53:51 -0700 Subject: [PATCH 6/8] Add a reviewed, tested extension authoring workflow Expose bounded plugin-attributed diagnostics and typed owner state through the existing plugin show surface. Escape plugin-controlled text and translate the report and state labels in all 15 complete locale packs. Preserve the single shared diagnostic ring and existing approval and authority checks. Accept regular .mts entries through the existing staged-byte and receipt validation path, suggest safe plugin-specific tool names, and document the actual experimental host contract. Exercise the exact typed hello example through the host and reviewed installation path. Ship plugin-creator generation 17 while preserving generation 16 and customized installed bodies. The agent stops after install/validate/show; a person reviews/trusts/enables. The execution context does not expose the calling workspace path. Validation on the final source: - Production TUI library check passed with locked dependencies (53.33s). - Focused Rust: 56 passed, 0 failed, 0 ignored; real Node fixtures required. - Focused localization: 3 passed, 0 failed; all 15 locale packs pass parity. - Host build/typecheck passed; host/protocol tests: 54 passed, 0 failed. - npm test: 640 passed, 0 failed (68 wrapper, 16 SDK, 54 host, 502 web). - npm run check:web passed; local Git object store supplied missing history. - Formatting/diff checks passed; exact previous generation body preserved. These are local results; hosted CI and release qualification remain separate. Depends on the Phase 1 attachment/approval fixes in PR #6734. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks Signed-off-by: Hunter B --- crates/localization/locales/ca.json | 7 + crates/localization/locales/de.json | 7 + crates/localization/locales/en.json | 7 + crates/localization/locales/es-419.json | 7 + crates/localization/locales/fr.json | 7 + crates/localization/locales/hi.json | 7 + crates/localization/locales/id.json | 7 + crates/localization/locales/ja.json | 7 + crates/localization/locales/ko.json | 7 + crates/localization/locales/pt-BR.json | 7 + crates/localization/locales/ru.json | 7 + crates/localization/locales/uk.json | 7 + crates/localization/locales/vi.json | 7 + crates/localization/locales/zh-Hans.json | 7 + crates/localization/locales/zh-Hant.json | 7 + crates/localization/src/lib.rs | 45 +++++ crates/tui/assets/skills-catalog-matrix.json | 2 +- .../plugin-creator/SKILL.generation-16.md | 68 +++++++ .../tui/assets/skills/plugin-creator/SKILL.md | 24 ++- crates/tui/extension-host/test/host.test.mjs | 11 ++ crates/tui/src/commands/contract.rs | 7 + crates/tui/src/commands/groups/plugins/mod.rs | 57 +++++- .../tui/src/commands/groups/plugins/tests.rs | 50 ++++++ crates/tui/src/extension_host/mod.rs | 169 +++++++++++++++--- crates/tui/src/extension_host/registry.rs | 13 +- crates/tui/src/extension_host/supervisor.rs | 2 + crates/tui/src/extension_host/tests.rs | 145 ++++++++++++++- crates/tui/src/plugins/runtime.rs | 6 +- crates/tui/src/skills/system.rs | 8 +- crates/tui/src/skills/system/tests.rs | 58 +++--- docs/EXTENSIONS.md | 123 +++++++++++++ docs/PLUGIN_AUTHORING.md | 5 +- docs/PLUGIN_BUNDLES.md | 4 +- docs/design/TS_EXTENSION_HOST.md | 15 +- .../plugins/hello-extension/hello.mts | 22 +++ .../plugins/hello-extension/plugin.json | 12 ++ docs/zh_hans/PLUGIN_AUTHORING.md | 4 +- 37 files changed, 884 insertions(+), 71 deletions(-) create mode 100644 crates/tui/assets/skills/plugin-creator/SKILL.generation-16.md create mode 100644 docs/EXTENSIONS.md create mode 100644 docs/examples/plugins/hello-extension/hello.mts create mode 100644 docs/examples/plugins/hello-extension/plugin.json diff --git a/crates/localization/locales/ca.json b/crates/localization/locales/ca.json index c4db22f5f1..0609f033bb 100644 --- a/crates/localization/locales/ca.json +++ b/crates/localization/locales/ca.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Amfitrió d’extensions:\n Estat: {state}\n Eines actives ({count}): {tools}", + "CmdPluginOwnerActivating": "S’està activant", + "CmdPluginOwnerActive": "Actiu", + "CmdPluginOwnerFailed": "Ha fallat: {reason}", + "CmdPluginOwnerFaulted": "Amb errors: {reason}", + "CmdPluginOwnerRevoked": "Revocat", + "CmdPluginOwnerInactive": "Inactiu", "MobileStreamReplayFailed": "El Runtime no ha pogut llegir l'historial d'aquesta conversa — tornant-ho a provar", "MobileStreamCatchUpFailed": "El Runtime s'ha endarrerit i no s'ha pogut posar al dia — tornant-ho a provar", "MobileStreamRuntimeShutdown": "El Runtime s'està tancant — reconnectant", diff --git a/crates/localization/locales/de.json b/crates/localization/locales/de.json index 94852fc4b6..473d48c135 100644 --- a/crates/localization/locales/de.json +++ b/crates/localization/locales/de.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Erweiterungshost:\n Status: {state}\n Aktive Werkzeuge ({count}): {tools}", + "CmdPluginOwnerActivating": "Wird aktiviert", + "CmdPluginOwnerActive": "Aktiv", + "CmdPluginOwnerFailed": "Fehlgeschlagen: {reason}", + "CmdPluginOwnerFaulted": "Gestört: {reason}", + "CmdPluginOwnerRevoked": "Widerrufen", + "CmdPluginOwnerInactive": "Nicht aktiv", "MobileStreamReplayFailed": "Runtime konnte den Verlauf dieser Unterhaltung nicht lesen — erneuter Versuch", "MobileStreamCatchUpFailed": "Runtime ist zurückgefallen und konnte nicht aufholen — erneuter Versuch", "MobileStreamRuntimeShutdown": "Runtime wird heruntergefahren — Verbindung wird wiederhergestellt", diff --git a/crates/localization/locales/en.json b/crates/localization/locales/en.json index 34dc54b842..2348c21533 100644 --- a/crates/localization/locales/en.json +++ b/crates/localization/locales/en.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Extension host:\n State: {state}\n Live tools ({count}): {tools}", + "CmdPluginOwnerActivating": "Activating", + "CmdPluginOwnerActive": "Active", + "CmdPluginOwnerFailed": "Failed: {reason}", + "CmdPluginOwnerFaulted": "Faulted: {reason}", + "CmdPluginOwnerRevoked": "Revoked", + "CmdPluginOwnerInactive": "Not active", "MobileStreamReplayFailed": "Runtime could not read this conversation's history — retrying", "MobileStreamCatchUpFailed": "Runtime fell behind and could not catch up — retrying", "MobileStreamRuntimeShutdown": "Runtime is shutting down — reconnecting", diff --git a/crates/localization/locales/es-419.json b/crates/localization/locales/es-419.json index 983dbee649..7119756b15 100644 --- a/crates/localization/locales/es-419.json +++ b/crates/localization/locales/es-419.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Host de extensiones:\n Estado: {state}\n Herramientas activas ({count}): {tools}", + "CmdPluginOwnerActivating": "Activando", + "CmdPluginOwnerActive": "Activo", + "CmdPluginOwnerFailed": "Falló: {reason}", + "CmdPluginOwnerFaulted": "Con errores: {reason}", + "CmdPluginOwnerRevoked": "Revocado", + "CmdPluginOwnerInactive": "Inactivo", "MobileStreamReplayFailed": "El Runtime no pudo leer el historial de esta conversación — reintentando", "MobileStreamCatchUpFailed": "El Runtime se atrasó y no pudo ponerse al día — reintentando", "MobileStreamRuntimeShutdown": "El Runtime se está cerrando — reconectando", diff --git a/crates/localization/locales/fr.json b/crates/localization/locales/fr.json index 829a526229..d0bb60e6fd 100644 --- a/crates/localization/locales/fr.json +++ b/crates/localization/locales/fr.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Hôte des extensions :\n État : {state}\n Outils actifs ({count}) : {tools}", + "CmdPluginOwnerActivating": "Activation en cours", + "CmdPluginOwnerActive": "Actif", + "CmdPluginOwnerFailed": "Échec : {reason}", + "CmdPluginOwnerFaulted": "En défaut : {reason}", + "CmdPluginOwnerRevoked": "Révoqué", + "CmdPluginOwnerInactive": "Inactif", "MobileStreamReplayFailed": "Le Runtime n’a pas pu lire l’historique de cette conversation — nouvelle tentative", "MobileStreamCatchUpFailed": "Le Runtime a pris du retard et n’a pas pu le rattraper — nouvelle tentative", "MobileStreamRuntimeShutdown": "Le Runtime est en cours d’arrêt — reconnexion", diff --git a/crates/localization/locales/hi.json b/crates/localization/locales/hi.json index 2905345b40..8729cbc3ac 100644 --- a/crates/localization/locales/hi.json +++ b/crates/localization/locales/hi.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "एक्सटेंशन होस्ट:\n स्थिति: {state}\n सक्रिय टूल ({count}): {tools}", + "CmdPluginOwnerActivating": "सक्रिय किया जा रहा है", + "CmdPluginOwnerActive": "सक्रिय", + "CmdPluginOwnerFailed": "विफल: {reason}", + "CmdPluginOwnerFaulted": "त्रुटिग्रस्त: {reason}", + "CmdPluginOwnerRevoked": "अनुमति रद्द", + "CmdPluginOwnerInactive": "निष्क्रिय", "MobileStreamReplayFailed": "Runtime इस बातचीत का इतिहास नहीं पढ़ सका — फिर से कोशिश हो रही है", "MobileStreamCatchUpFailed": "Runtime पीछे रह गया और छूटी हुई घटनाएँ प्राप्त नहीं कर सका — फिर से कोशिश हो रही है", "MobileStreamRuntimeShutdown": "Runtime बंद हो रहा है — फिर से कनेक्ट हो रहा है", diff --git a/crates/localization/locales/id.json b/crates/localization/locales/id.json index 08cb63291c..316fe95faa 100644 --- a/crates/localization/locales/id.json +++ b/crates/localization/locales/id.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Host ekstensi:\n Status: {state}\n Alat aktif ({count}): {tools}", + "CmdPluginOwnerActivating": "Sedang diaktifkan", + "CmdPluginOwnerActive": "Aktif", + "CmdPluginOwnerFailed": "Gagal: {reason}", + "CmdPluginOwnerFaulted": "Bermasalah: {reason}", + "CmdPluginOwnerRevoked": "Dicabut", + "CmdPluginOwnerInactive": "Tidak aktif", "MobileStreamReplayFailed": "Runtime tidak dapat membaca riwayat percakapan ini — mencoba lagi", "MobileStreamCatchUpFailed": "Runtime tertinggal dan tidak dapat mengejar — mencoba lagi", "MobileStreamRuntimeShutdown": "Runtime sedang dimatikan — menghubungkan kembali", diff --git a/crates/localization/locales/ja.json b/crates/localization/locales/ja.json index ad23d46fb7..44673a5652 100644 --- a/crates/localization/locales/ja.json +++ b/crates/localization/locales/ja.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "拡張機能ホスト:\n 状態: {state}\n 有効なツール ({count}): {tools}", + "CmdPluginOwnerActivating": "有効化中", + "CmdPluginOwnerActive": "有効", + "CmdPluginOwnerFailed": "失敗: {reason}", + "CmdPluginOwnerFaulted": "異常: {reason}", + "CmdPluginOwnerRevoked": "許可取消済み", + "CmdPluginOwnerInactive": "無効", "MobileStreamReplayFailed": "Runtime がこの会話の履歴を読み込めませんでした — 再試行中", "MobileStreamCatchUpFailed": "Runtime のイベント処理が遅れ、追いつけませんでした — 再試行中", "MobileStreamRuntimeShutdown": "Runtime は終了処理中です — 再接続中", diff --git a/crates/localization/locales/ko.json b/crates/localization/locales/ko.json index 00b42f4281..bc4e45a626 100644 --- a/crates/localization/locales/ko.json +++ b/crates/localization/locales/ko.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "확장 호스트:\n 상태: {state}\n 활성 도구 ({count}): {tools}", + "CmdPluginOwnerActivating": "활성화 중", + "CmdPluginOwnerActive": "활성", + "CmdPluginOwnerFailed": "실패: {reason}", + "CmdPluginOwnerFaulted": "오류 발생: {reason}", + "CmdPluginOwnerRevoked": "권한 철회됨", + "CmdPluginOwnerInactive": "비활성", "MobileStreamReplayFailed": "Runtime이 이 대화의 기록을 읽지 못했습니다 — 다시 시도 중", "MobileStreamCatchUpFailed": "Runtime의 이벤트 처리가 지연되어 따라잡지 못했습니다 — 다시 시도 중", "MobileStreamRuntimeShutdown": "Runtime을 종료하고 있습니다 — 다시 연결 중", diff --git a/crates/localization/locales/pt-BR.json b/crates/localization/locales/pt-BR.json index 8f2f6bdd6d..ca292d7f22 100644 --- a/crates/localization/locales/pt-BR.json +++ b/crates/localization/locales/pt-BR.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Host de extensões:\n Estado: {state}\n Ferramentas ativas ({count}): {tools}", + "CmdPluginOwnerActivating": "Ativando", + "CmdPluginOwnerActive": "Ativo", + "CmdPluginOwnerFailed": "Falhou: {reason}", + "CmdPluginOwnerFaulted": "Com erro: {reason}", + "CmdPluginOwnerRevoked": "Revogado", + "CmdPluginOwnerInactive": "Inativo", "MobileStreamReplayFailed": "O Runtime não conseguiu ler o histórico desta conversa — tentando novamente", "MobileStreamCatchUpFailed": "O Runtime ficou para trás e não conseguiu recuperar os eventos — tentando novamente", "MobileStreamRuntimeShutdown": "O Runtime está sendo encerrado — reconectando", diff --git a/crates/localization/locales/ru.json b/crates/localization/locales/ru.json index 741652b9e3..d09ca81104 100644 --- a/crates/localization/locales/ru.json +++ b/crates/localization/locales/ru.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Хост расширений:\n Состояние: {state}\n Активные инструменты ({count}): {tools}", + "CmdPluginOwnerActivating": "Активация", + "CmdPluginOwnerActive": "Активен", + "CmdPluginOwnerFailed": "Сбой: {reason}", + "CmdPluginOwnerFaulted": "Неисправен: {reason}", + "CmdPluginOwnerRevoked": "Доступ отозван", + "CmdPluginOwnerInactive": "Неактивен", "MobileStreamReplayFailed": "Runtime не удалось прочитать историю этой беседы — повторная попытка", "MobileStreamCatchUpFailed": "Runtime отстал и не смог получить пропущенные события — повторная попытка", "MobileStreamRuntimeShutdown": "Runtime завершает работу — переподключение", diff --git a/crates/localization/locales/uk.json b/crates/localization/locales/uk.json index a91fd57fbf..de1b711d00 100644 --- a/crates/localization/locales/uk.json +++ b/crates/localization/locales/uk.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Хост розширень:\n Стан: {state}\n Активні інструменти ({count}): {tools}", + "CmdPluginOwnerActivating": "Активація", + "CmdPluginOwnerActive": "Активний", + "CmdPluginOwnerFailed": "Збій: {reason}", + "CmdPluginOwnerFaulted": "Несправний: {reason}", + "CmdPluginOwnerRevoked": "Доступ відкликано", + "CmdPluginOwnerInactive": "Неактивний", "MobileStreamReplayFailed": "Runtime не вдалося прочитати історію цієї розмови — повторна спроба", "MobileStreamCatchUpFailed": "Runtime відстав і не зміг отримати пропущені події — повторна спроба", "MobileStreamRuntimeShutdown": "Runtime завершує роботу — повторне підключення", diff --git a/crates/localization/locales/vi.json b/crates/localization/locales/vi.json index 6530c0c236..9a4acbeb84 100644 --- a/crates/localization/locales/vi.json +++ b/crates/localization/locales/vi.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "Máy chủ tiện ích mở rộng:\n Trạng thái: {state}\n Công cụ đang hoạt động ({count}): {tools}", + "CmdPluginOwnerActivating": "Đang kích hoạt", + "CmdPluginOwnerActive": "Đang hoạt động", + "CmdPluginOwnerFailed": "Thất bại: {reason}", + "CmdPluginOwnerFaulted": "Gặp lỗi: {reason}", + "CmdPluginOwnerRevoked": "Đã thu hồi", + "CmdPluginOwnerInactive": "Chưa hoạt động", "MobileStreamReplayFailed": "Runtime không thể đọc lịch sử cuộc trò chuyện này — đang thử lại", "MobileStreamCatchUpFailed": "Runtime bị chậm và không thể bắt kịp — đang thử lại", "MobileStreamRuntimeShutdown": "Runtime đang tắt — đang kết nối lại", diff --git a/crates/localization/locales/zh-Hans.json b/crates/localization/locales/zh-Hans.json index 1cd05e3e00..d4a1b1a333 100644 --- a/crates/localization/locales/zh-Hans.json +++ b/crates/localization/locales/zh-Hans.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "扩展宿主:\n 状态:{state}\n 活动工具({count}):{tools}", + "CmdPluginOwnerActivating": "正在激活", + "CmdPluginOwnerActive": "已激活", + "CmdPluginOwnerFailed": "失败:{reason}", + "CmdPluginOwnerFaulted": "故障:{reason}", + "CmdPluginOwnerRevoked": "已撤销", + "CmdPluginOwnerInactive": "未激活", "MobileStreamReplayFailed": "Runtime 无法读取此对话的历史记录 — 正在重试", "MobileStreamCatchUpFailed": "Runtime 处理落后且无法追赶 — 正在重试", "MobileStreamRuntimeShutdown": "Runtime 正在关闭 — 正在重新连接", diff --git a/crates/localization/locales/zh-Hant.json b/crates/localization/locales/zh-Hant.json index 778ba78a4a..030e916d1d 100644 --- a/crates/localization/locales/zh-Hant.json +++ b/crates/localization/locales/zh-Hant.json @@ -1,4 +1,11 @@ { + "CmdPluginOwnerReport": "擴充功能主機:\n 狀態:{state}\n 活動工具({count}):{tools}", + "CmdPluginOwnerActivating": "正在啟用", + "CmdPluginOwnerActive": "已啟用", + "CmdPluginOwnerFailed": "失敗:{reason}", + "CmdPluginOwnerFaulted": "故障:{reason}", + "CmdPluginOwnerRevoked": "已撤銷", + "CmdPluginOwnerInactive": "未啟用", "MobileStreamReplayFailed": "Runtime 無法讀取此對話的歷史記錄 — 正在重試", "MobileStreamCatchUpFailed": "Runtime 處理落後且無法追趕 — 正在重試", "MobileStreamRuntimeShutdown": "Runtime 正在關閉 — 正在重新連線", diff --git a/crates/localization/src/lib.rs b/crates/localization/src/lib.rs index fa42b5341a..b6e8c39870 100644 --- a/crates/localization/src/lib.rs +++ b/crates/localization/src/lib.rs @@ -707,6 +707,13 @@ pub enum MessageId { PluginSuggestionReason, PagerActionConfirm, CmdPluginBundleDetail, + CmdPluginOwnerReport, + CmdPluginOwnerActivating, + CmdPluginOwnerActive, + CmdPluginOwnerFailed, + CmdPluginOwnerFaulted, + CmdPluginOwnerRevoked, + CmdPluginOwnerInactive, CmdPluginBundleDiagnosticsHeader, CmdPluginBundleMutationSuccess, CmdPluginActionFailed, @@ -3238,6 +3245,13 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::PluginSuggestionReason, MessageId::PagerActionConfirm, MessageId::CmdPluginBundleDetail, + MessageId::CmdPluginOwnerReport, + MessageId::CmdPluginOwnerActivating, + MessageId::CmdPluginOwnerActive, + MessageId::CmdPluginOwnerFailed, + MessageId::CmdPluginOwnerFaulted, + MessageId::CmdPluginOwnerRevoked, + MessageId::CmdPluginOwnerInactive, MessageId::CmdPluginBundleDiagnosticsHeader, MessageId::CmdPluginBundleMutationSuccess, MessageId::CmdPluginActionFailed, @@ -5524,6 +5538,37 @@ mod tests { .collect() } + #[test] + fn plugin_owner_report_keeps_translated_state_and_placeholder_parity() { + let english = raw_locale_messages(Locale::En); + let keys: Vec<_> = english + .keys() + .filter(|key| key.starts_with("CmdPluginOwner")) + .collect(); + assert_eq!(keys.len(), 7); + for locale in Locale::shipped_complete() { + let pack = raw_locale_messages(*locale); + for key in &keys { + let original = english[*key].as_str().unwrap(); + let translated = pack[*key].as_str().unwrap(); + assert_eq!( + message_placeholders(translated), + message_placeholders(original), + "{} changed placeholders for {key}", + locale.tag() + ); + if *locale != Locale::En { + assert_ne!( + translated, + original, + "{} left {key} in English", + locale.tag() + ); + } + } + } + } + /// #5906: the parked-agent vocabulary is new copy on the busiest rows in /// the product, so it gets the same hard parity gate coordination copy /// has — and the recovery line must keep the tool tokens it names, or it diff --git a/crates/tui/assets/skills-catalog-matrix.json b/crates/tui/assets/skills-catalog-matrix.json index 4502159113..3f0e3a1082 100644 --- a/crates/tui/assets/skills-catalog-matrix.json +++ b/crates/tui/assets/skills-catalog-matrix.json @@ -15,7 +15,7 @@ "in_model_catalogue": "true when the skill renders as an ambient catalogue line", "shadowed_aliases": "aliases that collide with another canonical bundled name; the canonical skill wins resolution" }, - "generation": "16", + "generation": "17", "skills": [ { "name": "skill-creator", diff --git a/crates/tui/assets/skills/plugin-creator/SKILL.generation-16.md b/crates/tui/assets/skills/plugin-creator/SKILL.generation-16.md new file mode 100644 index 0000000000..14bcbc06d1 --- /dev/null +++ b/crates/tui/assets/skills/plugin-creator/SKILL.generation-16.md @@ -0,0 +1,68 @@ +--- +name: plugin-creator +description: Scaffold a local Codewhale plugin bundle with a versioned manifest, namespaced Skills, and an explicit trust review. +--- + +# Plugin Creator + +Use this skill when a user wants a local Codewhale plugin bundle. Trusted and +enabled bundles may add declarative Skills, commands, agents, hooks, and MCP +servers (stdio and remote) through the existing engines. LSP, filesystem +roots, and lifecycle mutation are inventory-only. Native extensions (host +code) are inventory-only unless the user has turned on the experimental +`[features] extension_host` flag. + +## Workflow + +1. Pick a Codewhale-owned location: + - User bundle: `~/.codewhale/plugins//` + - Workspace bundle: `/.codewhale/plugins//` +2. Normalize the bundle name to lowercase hyphen-case. +3. Create `plugin.json` (Agent Plugins v1.0.0; a legacy `plugin.toml` stays + readable, but new bundles use `plugin.json`): + +```json +{ + "$schema": "https://agent-plugins.org/schemas/plugin.json", + "name": "my-plugin", + "version": "0.1.0", + "description": "What this bundle provides" +} +``` + +4. Put each Skill under `skills//SKILL.md`; Codewhale finds + `skills/` automatically and exposes each as `my-plugin:`, + never as an unqualified command. +5. Add MCP servers in a sibling `mcp.json` only when the bundle needs an + existing MCP engine. Keep stdio commands and paths inside the bundle. Map local + environment values only as exact `${SOURCE_ENV}` references. For remote MCP, + use HTTPS (or loopback HTTP), forbid URL user information/query/fragment, + use only environment-backed headers or bearer tokens, and declare the exact + normalized endpoint host set in `capabilities.network_hosts` under + `extensions["net.codewhale"]`. Never place credentials in the manifest. +6. Commands (`commands/*.md`), agents (`agents/*.toml`), and hooks + (`hooks/*.toml`), declared under `extensions["net.codewhale"]`, activate + under the current policy — workspace bundles win same-name collisions over + user and built-in bundles. LSP, filesystem roots, and lifecycle mutation + are inventory-only: declare them only when inventorying future work. A + `native` entry runs only under the experimental extension host; there it + must be one `.mjs` or `.js` ES module file, `/plugin validate` rejects + anything else, and its tools always use `Required` approval, never a + plugin's read-only hint. Full Access, Bypass, or an exact session grant + for the reviewed build can satisfy that gate without a prompt. A bundle + that declares only unsupported surfaces cannot be enabled. +7. Validate and review without executing bundle content: + - `/plugin validate ` + - `/plugin show ` + - `/plugin enable ` to open the content/capability review + - run the exact `/plugin trust ...` confirmation shown, then enable again +8. Verify `/skills inspect` reports plugin provenance and `/plugin list` + reports the expected trust and activation state. Trust stages the reviewed + content but does not activate it. After enablement, follow the host's + reload notice: use `/reload` or a new session to apply changes to a live + session's pinned skills and tools. + +Every user and workspace bundle starts untrusted and disabled. Reuse the +existing `/plugin marketplace`, install, update, review and reload surfaces; +do not add a parallel installer, registry or automatic trust flow. Catalog +membership alone never installs, trusts or enables a plugin. diff --git a/crates/tui/assets/skills/plugin-creator/SKILL.md b/crates/tui/assets/skills/plugin-creator/SKILL.md index 14bcbc06d1..c37aa67e2f 100644 --- a/crates/tui/assets/skills/plugin-creator/SKILL.md +++ b/crates/tui/assets/skills/plugin-creator/SKILL.md @@ -46,7 +46,7 @@ code) are inventory-only unless the user has turned on the experimental user and built-in bundles. LSP, filesystem roots, and lifecycle mutation are inventory-only: declare them only when inventorying future work. A `native` entry runs only under the experimental extension host; there it - must be one `.mjs` or `.js` ES module file, `/plugin validate` rejects + must be one `.mjs`, `.js` or `.mts` ES module file, `/plugin validate` rejects anything else, and its tools always use `Required` approval, never a plugin's read-only hint. Full Access, Bypass, or an exact session grant for the reviewed build can satisfy that gate without a prompt. A bundle @@ -54,8 +54,9 @@ code) are inventory-only unless the user has turned on the experimental 7. Validate and review without executing bundle content: - `/plugin validate ` - `/plugin show ` - - `/plugin enable ` to open the content/capability review - - run the exact `/plugin trust ...` confirmation shown, then enable again + - stop and present these results; the person runs `/plugin enable ` + to open the content/capability review, reviews it, runs the exact + `/plugin trust ...` confirmation shown, then enables the bundle 8. Verify `/skills inspect` reports plugin provenance and `/plugin list` reports the expected trust and activation state. Trust stages the reviewed content but does not activate it. After enablement, follow the host's @@ -66,3 +67,20 @@ Every user and workspace bundle starts untrusted and disabled. Reuse the existing `/plugin marketplace`, install, update, review and reload surfaces; do not add a parallel installer, registry or automatic trust flow. Catalog membership alone never installs, trusts or enables a plugin. + +## Experimental host code + +Only scaffold host code when the person explicitly uses the experimental +extension-host feature. Start from the tested `hello-extension` example and +`docs/EXTENSIONS.md` in the Codewhale repository. A typed `.mts` entry may use +Node's erasable TypeScript syntax; bundle dependencies locally. Register tools +with a plugin-specific prefix and an object input schema, propagate +`exec.signal`, and use `ctx.effect` for bounded asynchronous cleanup. The +current execution context exposes `signal`, `callId` and `args`; it does not +expose the calling workspace path. Do not change the shared process cwd. + +Stop after install, validate and show; never automate the trust token. A +person reviews, trusts and enables the bundle. `/plugin show ` reports +owner state, live tools and recent attributed diagnostics. Recovery may create +fresh registrations, but never replays an interrupted tool call. Explain the +shared-process and current platform sandbox limits without claiming isolation. diff --git a/crates/tui/extension-host/test/host.test.mjs b/crates/tui/extension-host/test/host.test.mjs index 8e3b72fe4b..4969941ed7 100644 --- a/crates/tui/extension-host/test/host.test.mjs +++ b/crates/tui/extension-host/test/host.test.mjs @@ -32,6 +32,17 @@ test('heartbeat answers after initialization without an owner or tool call', asy assert.equal(host.registry.length, 0) }) +test('the documented typed hello extension activates and executes unchanged', async (t) => { + const host = await startHost() + t.after(() => host.stop()) + const entry = fileURLToPath(new URL('../../../../docs/examples/plugins/hello-extension/hello.mts', import.meta.url)) + const { result } = await activate(host, 'hello-extension', entry) + assert.deepEqual(result, { status: 'ok', tools: ['hello_greet'] }) + const tool = host.registry.find((entry) => entry.op === 'register') + const output = await host.call('tool/call', { handle: tool.handle, call_id: 'hello-1', input: { name: 'Codewhale' }, deadline_ms: 5000 }) + assert.deepEqual(output.structured, { greeting: 'Hello, Codewhale!', callId: 'hello-1' }) +}) + test('the published DSH plugin runs unmodified and returns its payload', async (t) => { const host = await startHost() t.after(() => host.stop()) diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index f28096f151..ed3097269c 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -2049,6 +2049,13 @@ pub(crate) fn key_to_plugin_message_id(key: &str) -> Option { Some(match key { "cmd_plugin_action_failed" => MessageId::CmdPluginActionFailed, "cmd_plugin_bundle_detail" => MessageId::CmdPluginBundleDetail, + "cmd_plugin_owner_report" => MessageId::CmdPluginOwnerReport, + "cmd_plugin_owner_activating" => MessageId::CmdPluginOwnerActivating, + "cmd_plugin_owner_active" => MessageId::CmdPluginOwnerActive, + "cmd_plugin_owner_failed" => MessageId::CmdPluginOwnerFailed, + "cmd_plugin_owner_faulted" => MessageId::CmdPluginOwnerFaulted, + "cmd_plugin_owner_revoked" => MessageId::CmdPluginOwnerRevoked, + "cmd_plugin_owner_inactive" => MessageId::CmdPluginOwnerInactive, "cmd_plugin_bundle_diagnostics_header" => MessageId::CmdPluginBundleDiagnosticsHeader, "cmd_plugin_bundle_list_header" => MessageId::CmdPluginBundleListHeader, "cmd_plugin_bundle_mutation_success" => MessageId::CmdPluginBundleMutationSuccess, diff --git a/crates/tui/src/commands/groups/plugins/mod.rs b/crates/tui/src/commands/groups/plugins/mod.rs index 3e3184ec83..f2aa828669 100644 --- a/crates/tui/src/commands/groups/plugins/mod.rs +++ b/crates/tui/src/commands/groups/plugins/mod.rs @@ -412,7 +412,62 @@ fn show_bundle( ); } }; - CommandResult::message(render::render_bundle_detail(presentation, &detail, true)) + let mut output = render::render_bundle_detail(presentation, &detail, true); + if let Some(report) = crate::extension_host::owner_report(&detail.id) { + append_host_owner_report(presentation, &mut output, &report); + } + CommandResult::message(output) +} + +fn append_host_owner_report( + presentation: &dyn CommandPresentationContext, + output: &mut String, + report: &crate::extension_host::OwnerReport, +) { + use crate::extension_host::registry::OwnerState; + + let (state_key, reason) = match &report.state { + Some(OwnerState::Activating) => ("cmd_plugin_owner_activating", None), + Some(OwnerState::Active) => ("cmd_plugin_owner_active", None), + Some(OwnerState::Failed(reason)) => ("cmd_plugin_owner_failed", Some(reason)), + Some(OwnerState::Faulted(reason)) => ("cmd_plugin_owner_faulted", Some(reason)), + Some(OwnerState::Revoked) => ("cmd_plugin_owner_revoked", None), + None => ("cmd_plugin_owner_inactive", None), + }; + let reason = reason.map(|value| escape_review_text(value)); + let replacements = reason + .as_deref() + .map(|value| vec![("reason", value)]) + .unwrap_or_default(); + let state = presentation + .translate(state_key, &replacements) + .unwrap_or_default(); + let tools = report + .tools + .iter() + .map(|name| escape_review_text(name)) + .collect::>(); + let tool_names = if tools.is_empty() { + "—".to_string() + } else { + tools.join(", ") + }; + output.push('\n'); + output.push_str( + &presentation + .translate( + "cmd_plugin_owner_report", + &[ + ("state", &state), + ("count", &tools.len().to_string()), + ("tools", &tool_names), + ], + ) + .unwrap_or_default(), + ); + for line in &report.diagnostics { + let _ = write!(output, "\n · {}", escape_review_text(line)); + } } /// `/plugin export ` — publish a loaded bundle as a diff --git a/crates/tui/src/commands/groups/plugins/tests.rs b/crates/tui/src/commands/groups/plugins/tests.rs index bc45abd14b..473a0e26d5 100644 --- a/crates/tui/src/commands/groups/plugins/tests.rs +++ b/crates/tui/src/commands/groups/plugins/tests.rs @@ -8,6 +8,56 @@ use std::fs; use std::path::Path; use tempfile::TempDir; +#[test] +fn extension_owner_report_escapes_every_plugin_controlled_field() { + struct Presentation(Locale); + impl CommandPresentationContext for Presentation { + fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result { + let id = crate::commands::contract::key_to_plugin_message_id(key).unwrap(); + let mut output = codewhale_localization::tr(self.0, id).to_string(); + for (name, value) in replacements { + output = output.replace(&format!("{{{name}}}"), value); + } + Ok(output) + } + } + let mut output = String::new(); + append_host_owner_report( + &Presentation(Locale::En), + &mut output, + &crate::extension_host::OwnerReport { + state: Some(crate::extension_host::registry::OwnerState::Failed( + "\u{1b}[31m