fix(extensions): the extension host asks a runtime-side catalog about built-in commands

Hosted Lint failed on the command-crate boundary ratchet: extension_host
is a runtime module and may not depend on crate::commands or crate::tui,
but the slash-command work called crate::commands::registry() to detect a
built-in name clash and kept command and App tests in its own test file.

- extension_host::command::BuiltinCommandCatalog is a read-only port
  (answers_to(name)). The commands side implements it
  (commands::BuiltinCommandNames) and lib.rs installs it at startup.
  Registration fails closed: with no catalog installed an extension
  command is refused, never accepted unchecked.
- The two tests that drive the real command table and App move to
  crates/tui/src/commands/extension_host_tests.rs with their assertions
  unchanged; extension_host/tests.rs uses a thread-local stub catalog.
- The ratchet baseline is not changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-01 23:26:19 -07:00
1 parent 0f92afdb33
commit e3f644aeaa
7 files changed
+534 -265

No files matched your search

@@ -0,0 +1,351 @@
//! Extension slash commands through the real command table and the TUI's
//! dispatch.
//!
//! These tests drive `crate::commands` (the user registry, `execute`, the
//! built-in table) and `crate::tui::app::App`, so they live on the commands
//! side of the runtime/UI boundary: the extension host is a runtime module and
//! its own tests may not depend on either (`scripts/check-command-crate-boundaries.py`).
//! They run the real Node host over the same fixtures the host's own tests use
//! and, where the table matters, install the real built-in command catalog
//! ([`BuiltinCommandNames`]) in place of the host tests' stub.
use std::path::PathBuf;
use std::sync::Arc;
use super::BuiltinCommandNames;
use crate::extension_host::command::BuiltinCommandsGuard;
use crate::extension_host::protocol::{RegisterKind, RegisterParams, RegisterSpecWire};
use crate::extension_host::registry::{OwnerRegistry, OwnerState};
use crate::extension_host::tests::{
FixturePlugins, fake_authority, installed, node_for_tests, plugin_id,
};
use crate::extension_host::tier::HostTier;
use crate::plugins::activation::TestPolicyGuard;
/// The real catalog answers to every built-in name, every alias and the fixed
/// mode aliases the dispatcher handles ahead of the registry, and an extension
/// command cannot take any of them.
#[test]
fn the_real_built_in_catalog_refuses_every_name_alias_and_mode_alias() {
use crate::extension_host::command::BuiltinCommandCatalog;
let catalog = BuiltinCommandNames;
let mut owned = vec!["jihua".to_string(), "zidong".to_string()];
for info in crate::commands::command_infos() {
owned.push(info.name.to_string());
owned.extend(info.aliases.iter().map(|alias| (*alias).to_string()));
}
assert!(
crate::commands::command_infos()
.iter()
.any(|info| !info.aliases.is_empty()),
"some built-in has an alias"
);
for name in &owned {
assert!(catalog.answers_to(name), "/{name} is built in");
}
assert!(!catalog.answers_to("ext-echo"));
let _catalog = BuiltinCommandsGuard::install(Arc::new(BuiltinCommandNames));
let mut registry = OwnerRegistry::new();
let owner = registry
.begin_owner(
HostTier::Plugin,
"a",
"a",
Some(fake_authority("a")),
"hash-a",
)
.unwrap();
let register = |registry: &mut OwnerRegistry, name: &str| {
registry.register(&RegisterParams {
owner: owner.clone(),
kind: RegisterKind::Command,
spec: RegisterSpecWire {
name: name.to_string(),
description: "d".to_string(),
input_schema: None,
argument_hint: None,
},
})
};
for name in ["help", "trust", "model", "jihua", "zidong"]
.into_iter()
.map(str::to_string)
.chain(owned)
{
let refused =
register(&mut registry, &name).expect_err(&format!("/{name} must be refused"));
// A built-in spelled outside an extension command's grammar (a
// punctuation alias) is refused as invalid before the catalog is asked.
let in_grammar = name.starts_with(|c: char| c.is_ascii_lowercase())
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
let expected = if in_grammar {
"built-in command"
} else {
"invalid"
};
assert!(refused.contains(expected), "{name}: {refused}");
}
register(&mut registry, "ext-not-built-in").unwrap();
}
/// Commands registered by real plugins in a real host: what the user
/// registry loads, what dispatch returns, and what running each one gives.
#[tokio::test]
async fn extension_commands_run_end_to_end_through_the_user_command_registry() {
use crate::extension_host::command::CommandOutcome;
use crate::tui::app::{App, AppAction, TuiOptions};
let Some(node) = node_for_tests("extension_commands_run_end_to_end") else {
return;
};
let _policy = TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["ext-commands"]).await;
let _catalog = BuiltinCommandsGuard::install(Arc::new(BuiltinCommandNames));
let manager = fixture.manager(node);
let _manager = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager));
let engine = manager.attach(fixture.registry());
// Nothing is visible before the host has registered anything.
assert!(manager.live_command_names().is_empty());
engine.sync().await.unwrap();
let mut live = manager.live_command_names();
live.sort();
assert_eq!(
live,
[
"ext-ansi",
"ext-ask",
"ext-dsh",
"ext-echo",
"ext-fail",
"ext-slow",
"ext-throw"
]
);
// Commands are not tools.
assert!(manager.live_tool_names().is_empty());
assert!(installed(&engine, fixture.workspace()).is_empty());
// The user registry loads them for this workspace (and no other).
let hint = |name: &str| {
crate::commands::user_registry::with_registry_for_workspace(
Some(fixture.workspace()),
|registry| {
registry
.get(name)
.map(|command| (command.argument_hint.clone(), command.takes_arguments()))
},
)
};
assert_eq!(hint("ext-echo"), Some((Some("<text>".to_string()), true)));
assert_eq!(hint("ext-ask"), Some((Some("<topic>".to_string()), true)));
assert_eq!(hint("ext-fail"), Some((None, false)));
let other = fixture.workspace().join("elsewhere");
assert!(
crate::commands::user_registry::with_registry_for_workspace(Some(&other), |registry| {
registry.get("ext-echo").is_none()
}),
"another workspace never sees this workspace's extension commands"
);
// Discovery lists them.
let described = crate::commands::user_registry::with_registry_for_workspace(
Some(fixture.workspace()),
|registry| {
registry
.iter()
.filter(|command| command.extension.is_some())
.count()
},
);
assert_eq!(described, 7);
// Dispatch is the ordinary slash-command path, and returns the action
// the UI loop runs; the arguments arrive trimmed.
let mut app = App::new(
TuiOptions {
workspace: fixture.workspace().to_path_buf(),
..crate::test_support::test_tui_options(fixture.workspace())
},
&crate::config::Config::default(),
);
let help = crate::commands::execute("/help ext-echo", &mut app);
assert!(
help.message
.as_deref()
.is_some_and(|text| text.contains("Echo the arguments.")),
"{help:?}"
);
let dispatched = |app: &mut App, input: &str| match crate::commands::execute(input, app).action
{
Some(AppAction::RunExtensionCommand {
command,
name,
input,
}) => (command, name, input),
other => panic!("{input}: expected an extension command action, got {other:?}"),
};
let (echo, name, args) = dispatched(&mut app, "/ext-echo hello there ");
assert_eq!(
(name.as_str(), args.as_str()),
("ext-echo", "hello there")
);
assert_eq!(echo.origin, "extension:ext-commands");
assert_eq!(
crate::extension_host::run_command(&echo, &args).await,
Ok(CommandOutcome::Show {
text: "echo: hello there".to_string()
})
);
let (ask, _, args) = dispatched(&mut app, "/EXT-ASK tokens");
assert_eq!(
crate::extension_host::run_command(&ask, &args).await,
Ok(CommandOutcome::Submit {
prompt: "Summarize: tokens".to_string(),
note: Some("Asking the model.".to_string())
})
);
let (fail, _, args) = dispatched(&mut app, "/ext-fail");
assert_eq!(
crate::extension_host::run_command(&fail, &args).await,
Err("unknown topic".to_string())
);
let (thrown, _, args) = dispatched(&mut app, "/ext-throw");
let error = crate::extension_host::run_command(&thrown, &args)
.await
.unwrap_err();
assert!(
error.starts_with("failed:") && error.contains("boom"),
"{error}"
);
// Escape sequences never reach the transcript.
let (ansi, _, args) = dispatched(&mut app, "/ext-ansi");
assert_eq!(
crate::extension_host::run_command(&ansi, &args).await,
Ok(CommandOutcome::Show {
text: "plain red end".to_string()
})
);
// The DSH-shaped `rawInput` keeps its leading separator.
let (dsh, _, args) = dispatched(&mut app, "/ext-dsh a b");
assert_eq!(
crate::extension_host::run_command(&dsh, &args).await,
Ok(CommandOutcome::Show {
text: "\" a b\"".to_string()
})
);
// Disabling the plugin removes every command at once: the registry stops
// listing them, and the reference a user (or palette) still holds fails
// closed instead of reaching the host.
engine.set_plugins(fixture.disable("ext-commands"));
engine.sync().await.unwrap();
assert!(manager.live_command_names().is_empty());
assert_eq!(hint("ext-echo"), None);
let error = crate::extension_host::run_command(&echo, "x")
.await
.unwrap_err();
assert!(error.contains("no longer registered"), "{error}");
manager.shutdown().await;
}
/// A command may never take a built-in's name or another plugin's: the
/// registration is refused with a reason and that plugin fails to activate,
/// without disturbing the plugin that already holds the name.
#[tokio::test]
async fn extension_commands_never_shadow_built_ins_or_other_plugins() {
let Some(node) = node_for_tests("extension_commands_never_shadow") else {
return;
};
let _policy = TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&[
"ext-commands",
"commands-clash-builtin",
"commands-clash-plugin",
])
.await;
let _catalog = BuiltinCommandsGuard::install(Arc::new(BuiltinCommandNames));
let manager = fixture.manager(node);
let engine = manager.attach(fixture.registry());
engine.sync().await.unwrap();
let state = |name: &str| manager.owner_state(&plugin_id(&fixture, name)).unwrap();
match state("commands-clash-builtin") {
OwnerState::Failed(reason) => {
assert!(
reason.contains("collides with a built-in command"),
"{reason}"
)
}
other => panic!("{other:?}"),
}
// Activation order between the other two is not fixed: exactly one holds
// `/ext-echo`, and the other failed on it.
let (winner, loser) = match (state("ext-commands"), state("commands-clash-plugin")) {
(OwnerState::Active, OwnerState::Failed(reason)) => ("ext-commands", reason),
(OwnerState::Failed(reason), OwnerState::Active) => ("commands-clash-plugin", reason),
other => panic!("{other:?}"),
};
assert!(loser.contains("already registered by extension"), "{loser}");
let live = manager.live_command_names();
assert_eq!(live.iter().filter(|name| *name == "ext-echo").count(), 1);
assert!(!live.contains(&"help".to_string()));
let holder = manager
.live_command_registrations()
.into_iter()
.find(|command| command.name == "ext-echo")
.unwrap();
assert_eq!(holder.plugin_name, winner);
let diagnostics = manager.diagnostics().join("\n");
assert!(
diagnostics.contains("command `help` refused"),
"{diagnostics}"
);
manager.shutdown().await;
// The user registry is the last line: a command already defined by a
// markdown source, or by a built-in, wins the spelling and the extension
// one is left out with a load error naming why.
let mut registry = crate::commands::user_registry::UserCommandRegistry::from_loaded(vec![(
"ext-echo".to_string(),
"markdown wins".to_string(),
)]);
let entry = |name: &str| crate::extension_host::command::ExtensionCommandEntry {
registration: crate::extension_host::registry::CommandRegistration {
handle: 1,
owner: crate::extension_host::protocol::OwnerRef {
plugin_id: "p".into(),
generation: 1,
owner_token: "t".into(),
},
tier: HostTier::Plugin,
plugin_name: "p".into(),
content_hash: "h".into(),
name: name.to_string(),
description: "d".into(),
argument_hint: None,
},
authority: fake_authority("p"),
workspace: PathBuf::from("/w"),
};
registry.load_extension_commands(vec![entry("ext-echo"), entry("help")]);
let errors: Vec<String> = registry
.load_errors()
.iter()
.map(|error| error.message.clone())
.collect();
assert!(
errors
.iter()
.any(|e| e.contains("'/ext-echo' collides with another command")),
"{errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.contains("'/help' collides with a built-in command")),
"{errors:?}"
);
assert!(registry.get("ext-echo").unwrap().extension.is_none());
}
+20
View File
@@ -23,6 +23,12 @@ pub mod user_registry;
#[path = "epic_dispatch_acceptance.rs"]
mod epic_dispatch_acceptance;
// Extension slash commands through the real command table and `App` dispatch;
// they cannot live in `extension_host`, a runtime module that may not depend
// on this one.
#[cfg(test)]
mod extension_host_tests;
#[cfg(test)]
#[path = "epic_discovery_acceptance.rs"]
mod epic_discovery_acceptance;
@@ -195,6 +201,20 @@ pub fn registry() -> &'static traits::CommandRegistry {
REGISTRY.get_or_init(build_registry)
}
/// The built-in command table as the extension host asks about it: the one
/// read-only question "does a built-in command answer to this name?". The
/// composition root installs it at startup (`lib.rs`), so the runtime-side host
/// never depends on this module.
pub(crate) struct BuiltinCommandNames;
impl crate::extension_host::command::BuiltinCommandCatalog for BuiltinCommandNames {
fn answers_to(&self, name: &str) -> bool {
// `jihua` and `zidong` are mode aliases the dispatcher answers ahead
// of the registry.
matches!(name, "jihua" | "zidong") || registry().get(name).is_some()
}
}
pub fn command_infos() -> Vec<&'static CommandInfo> {
registry().infos()
}
+70 -2
View File
@@ -3,8 +3,8 @@
//! A command mirrors a tool. The host proposes it with `registry/register`
//! (`kind = "command"`); [`super::registry::OwnerRegistry`] admits or refuses
//! it under the same owner/generation/handle rules; the admitted commands of
//! the owners an engine's workspace desires are loaded into the existing
//! [`crate::commands::user_registry::UserCommandRegistry`] (at the lowest
//! the owners an engine's workspace desires are loaded into the existing user
//! command registry (`commands::user_registry`, at the lowest
//! precedence, so a built-in or a markdown command always wins the spelling);
//! and a user invocation becomes `command/run` to the host. Revocation,
//! deactivation, crash and generation change remove the registrations the
@@ -21,6 +21,14 @@
//! its own; it still re-checks that the owner is live, that its reviewed
//! receipt is current, and that the host is running before anything is sent.
//!
//! **The built-in command table is a port.** This module is runtime-side and
//! may not reach into `commands`, so registration asks
//! [`BuiltinCommandCatalog`] whether a built-in command answers to a name. The
//! commands side implements it (`commands::BuiltinCommandNames`) and the
//! composition root installs it once at startup ([`install_builtin_commands`]).
//! With none installed a command registration is refused, never accepted
//! unchecked.
//!
//! Known limitations:
//! * The UI event loop awaits the command (like `/balance`), bounded by
//! `SupervisionOptions::command_run_deadline` (30 s) and then cancelled with
@@ -38,6 +46,7 @@
//! prompt over [`MAX_PROMPT_BYTES`] is refused, never truncated.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use serde_json::Value;
@@ -54,6 +63,65 @@ pub const MAX_TEXT_BYTES: usize = 64 * 1024;
/// Longest prompt a command may submit. Over this it is refused outright.
pub const MAX_PROMPT_BYTES: usize = 128 * 1024;
/// What registration needs to know about the built-in slash commands, and
/// nothing more: read-only, implemented by the commands side.
pub trait BuiltinCommandCatalog: Send + Sync {
/// Whether a built-in command answers to `name` (lower case): a canonical
/// name, an alias, or one of the mode aliases the dispatcher answers ahead
/// of the registry.
fn answers_to(&self, name: &str) -> bool;
}
static BUILTIN_COMMANDS: OnceLock<Arc<dyn BuiltinCommandCatalog>> = OnceLock::new();
/// Install the process's catalog, once at startup. The first install wins and
/// returns `true`; a later one is ignored.
pub fn install_builtin_commands(catalog: Arc<dyn BuiltinCommandCatalog>) -> bool {
BUILTIN_COMMANDS.set(catalog).is_ok()
}
/// The installed catalog. `None` means none was installed (or, in a test, the
/// thread asked for none): command registration is refused.
pub(crate) fn builtin_commands() -> Option<Arc<dyn BuiltinCommandCatalog>> {
#[cfg(test)]
if let Some(chosen) = TEST_CATALOG.with(|cell| cell.borrow().clone()) {
return chosen;
}
BUILTIN_COMMANDS.get().cloned()
}
#[cfg(test)]
thread_local! {
/// `Some(None)` is a thread that asked for no catalog at all.
static TEST_CATALOG: std::cell::RefCell<Option<Option<Arc<dyn BuiltinCommandCatalog>>>> =
const { std::cell::RefCell::new(None) };
}
/// Test-only: route [`builtin_commands`] on this thread to a catalog (or to
/// none) until dropped, restoring what was there before, so tests never touch
/// the process-wide install.
#[cfg(test)]
pub(crate) struct BuiltinCommandsGuard(Option<Option<Arc<dyn BuiltinCommandCatalog>>>);
#[cfg(test)]
impl BuiltinCommandsGuard {
pub(crate) fn install(catalog: Arc<dyn BuiltinCommandCatalog>) -> Self {
Self(TEST_CATALOG.with(|cell| cell.replace(Some(Some(catalog)))))
}
/// This thread has no catalog, whatever the process installed.
pub(crate) fn absent() -> Self {
Self(TEST_CATALOG.with(|cell| cell.replace(Some(None))))
}
}
#[cfg(test)]
impl Drop for BuiltinCommandsGuard {
fn drop(&mut self) {
TEST_CATALOG.with(|cell| *cell.borrow_mut() = self.0.take());
}
}
static EPOCH: AtomicU64 = AtomicU64::new(0);
/// Something that may have changed which commands are live: a registration,
+12
View File
@@ -1342,6 +1342,18 @@ impl ExtensionHostManager {
.collect()
}
/// The command registrations currently admitted, for tests on the commands
/// side that drive the real command table.
#[cfg(test)]
#[must_use]
pub(crate) fn live_command_registrations(&self) -> Vec<CommandRegistration> {
self.shared
.registry
.lock()
.expect("registry lock")
.live_commands()
}
#[cfg(test)]
#[must_use]
pub fn owner_state(&self, plugin_id: &str) -> Option<OwnerState> {
+8 -7
View File
@@ -234,12 +234,6 @@ fn valid_command_name(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
/// Whether a built-in command answers to `name`: a canonical name, an alias,
/// or one of the fixed mode aliases dispatched ahead of the registry.
fn builtin_command(name: &str) -> bool {
matches!(name, "jihua" | "zidong") || crate::commands::registry().get(name).is_some()
}
/// Why the core would treat a tool called `name` as something other than an
/// opaque extension tool, if it would.
///
@@ -438,7 +432,14 @@ impl OwnerRegistry {
crate::safe_label::SafeLabel::identifier(name)
));
}
if builtin_command(name) {
// Fail closed: with no catalog installed there is nothing to check the
// name against, so the registration is refused, not accepted unchecked.
let Some(catalog) = super::command::builtin_commands() else {
return Err(format!(
"command `/{name}` cannot be checked against the built-in commands: no built-in command catalog is installed, so extension commands are refused"
));
};
if catalog.answers_to(name) {
return Err(format!(
"command `/{name}` collides with a built-in command; extensions never shadow core commands; use a plugin-specific name, for example `/myplugin-{name}`"
));
+67 -256
View File
@@ -12,6 +12,7 @@ use std::time::{Duration, Instant};
use serde_json::{Value, json};
use super::command::{BuiltinCommandCatalog, BuiltinCommandsGuard};
use super::protocol::{
self, OwnerRef, RegisterKind, RegisterParams, RegisterSpecWire, parse_core_message,
parse_host_message,
@@ -28,6 +29,26 @@ use crate::tools::spec::{ApprovalRequirement, ToolContext, ToolError, ToolSpec};
/// otherwise; the Bun ones pin Bun (`bun_for_tests`).
const NODE: crate::config::ExtensionHostRuntime = crate::config::ExtensionHostRuntime::Node;
/// What the registry is told is a built-in command in the tests that are not
/// about the command table. Those that are (`commands::extension_host_tests`)
/// install the real one: this module may not depend on `crate::commands`.
#[derive(Debug)]
struct StubBuiltinCommands;
impl BuiltinCommandCatalog for StubBuiltinCommands {
fn answers_to(&self, name: &str) -> bool {
matches!(
name,
"help" | "trust" | "model" | "jihua" | "zidong" | "stub-alias"
)
}
}
/// The stub catalog, for this thread until the guard drops.
fn stub_builtin_commands() -> BuiltinCommandsGuard {
BuiltinCommandsGuard::install(Arc::new(StubBuiltinCommands))
}
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/extension_host")
}
@@ -106,7 +127,7 @@ async fn frames_decode_in_order_and_violations_are_typed() {
// Owner registry
// ---------------------------------------------------------------------------
fn fake_authority(plugin_id: &str) -> crate::plugins::types::PluginAuthority {
pub(crate) fn fake_authority(plugin_id: &str) -> crate::plugins::types::PluginAuthority {
crate::plugins::types::PluginAuthority {
plugin_id: crate::plugins::types::PluginId(plugin_id.to_string()),
plugin_name: plugin_id.to_string(),
@@ -623,6 +644,9 @@ pub(crate) struct FixturePlugins {
_temp: tempfile::TempDir,
pub config: DiscoveryConfig,
pub root: PathBuf,
/// The stub built-in command catalog, so a fixture plugin's commands can
/// register; a test about the real table installs its own after this.
_commands: BuiltinCommandsGuard,
}
impl FixturePlugins {
@@ -671,6 +695,7 @@ impl FixturePlugins {
_temp: temp,
config,
root,
_commands: stub_builtin_commands(),
};
let registry = fixture.registry();
for name in names {
@@ -2046,13 +2071,13 @@ fn attachment_changes_discard_the_complete_scan_before_owner_side_effects() {
);
}
fn installed(engine: &HostAttachment, workspace: &Path) -> Vec<String> {
pub(crate) fn installed(engine: &HostAttachment, workspace: &Path) -> Vec<String> {
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 {
pub(crate) fn plugin_id(fixture: &FixturePlugins, name: &str) -> String {
fixture
.registry()
.get(name)
@@ -3659,6 +3684,7 @@ fn register_command(
#[test]
fn command_registry_refuses_shadowing_and_undoes_exactly_one_entry() {
let _catalog = stub_builtin_commands();
let mut registry = OwnerRegistry::new();
let a = registry
.begin_owner(
@@ -3679,12 +3705,9 @@ fn command_registry_refuses_shadowing_and_undoes_exactly_one_entry() {
)
.unwrap();
// Built-in names, their aliases, and the fixed mode aliases.
let builtin_alias = crate::commands::command_infos()
.iter()
.find_map(|info| info.aliases.first().copied())
.expect("some built-in has an alias");
for name in ["help", "trust", "model", "jihua", "zidong", builtin_alias] {
// Built-in names, their aliases, and the fixed mode aliases, as the
// catalog says (the real table: `commands::extension_host_tests`).
for name in ["help", "trust", "model", "jihua", "zidong", "stub-alias"] {
let refused = register_command(&mut registry, &a, name, None)
.expect_err(&format!("/{name} must be refused"));
assert!(refused.contains("built-in command"), "{name}: {refused}");
@@ -3975,266 +3998,54 @@ fn manager_workspace(manager: &ExtensionHostManager) -> PathBuf {
.to_path_buf()
}
/// Commands registered by real plugins in a real host: what the user
/// registry loads, what dispatch returns, and what running each one gives.
#[tokio::test]
async fn extension_commands_run_end_to_end_through_the_user_command_registry() {
use super::command::CommandOutcome;
use crate::tui::app::{App, AppAction, TuiOptions};
let Some(node) = node_for_tests("extension_commands_run_end_to_end") else {
return;
};
let _policy = TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["ext-commands"]).await;
let manager = fixture.manager(node);
let _manager = super::TestManagerGuard::install(Arc::clone(&manager));
let engine = manager.attach(fixture.registry());
// Nothing is visible before the host has registered anything.
assert!(manager.live_command_names().is_empty());
engine.sync().await.unwrap();
let mut live = manager.live_command_names();
live.sort();
assert_eq!(
live,
[
"ext-ansi",
"ext-ask",
"ext-dsh",
"ext-echo",
"ext-fail",
"ext-slow",
"ext-throw"
]
);
// Commands are not tools.
assert!(manager.live_tool_names().is_empty());
assert!(installed(&engine, fixture.workspace()).is_empty());
// The user registry loads them for this workspace (and no other).
let hint = |name: &str| {
crate::commands::user_registry::with_registry_for_workspace(
Some(fixture.workspace()),
|registry| {
registry
.get(name)
.map(|command| (command.argument_hint.clone(), command.takes_arguments()))
},
)
};
assert_eq!(hint("ext-echo"), Some((Some("<text>".to_string()), true)));
assert_eq!(hint("ext-ask"), Some((Some("<topic>".to_string()), true)));
assert_eq!(hint("ext-fail"), Some((None, false)));
let other = fixture.workspace().join("elsewhere");
assert!(
crate::commands::user_registry::with_registry_for_workspace(Some(&other), |registry| {
registry.get("ext-echo").is_none()
}),
"another workspace never sees this workspace's extension commands"
);
// Discovery lists them.
let described = crate::commands::user_registry::with_registry_for_workspace(
Some(fixture.workspace()),
|registry| {
registry
.iter()
.filter(|command| command.extension.is_some())
.count()
},
);
assert_eq!(described, 7);
// Dispatch is the ordinary slash-command path, and returns the action
// the UI loop runs; the arguments arrive trimmed.
let mut app = App::new(
TuiOptions {
workspace: fixture.workspace().to_path_buf(),
..crate::test_support::test_tui_options(fixture.workspace())
},
&crate::config::Config::default(),
);
let help = crate::commands::execute("/help ext-echo", &mut app);
assert!(
help.message
.as_deref()
.is_some_and(|text| text.contains("Echo the arguments.")),
"{help:?}"
);
let dispatched = |app: &mut App, input: &str| match crate::commands::execute(input, app).action
{
Some(AppAction::RunExtensionCommand {
command,
name,
input,
}) => (command, name, input),
other => panic!("{input}: expected an extension command action, got {other:?}"),
};
let (echo, name, args) = dispatched(&mut app, "/ext-echo hello there ");
assert_eq!(
(name.as_str(), args.as_str()),
("ext-echo", "hello there")
);
assert_eq!(echo.origin, "extension:ext-commands");
assert_eq!(
super::run_command(&echo, &args).await,
Ok(CommandOutcome::Show {
text: "echo: hello there".to_string()
})
);
let (ask, _, args) = dispatched(&mut app, "/EXT-ASK tokens");
assert_eq!(
super::run_command(&ask, &args).await,
Ok(CommandOutcome::Submit {
prompt: "Summarize: tokens".to_string(),
note: Some("Asking the model.".to_string())
})
);
let (fail, _, args) = dispatched(&mut app, "/ext-fail");
assert_eq!(
super::run_command(&fail, &args).await,
Err("unknown topic".to_string())
);
let (thrown, _, args) = dispatched(&mut app, "/ext-throw");
let error = super::run_command(&thrown, &args).await.unwrap_err();
assert!(
error.starts_with("failed:") && error.contains("boom"),
"{error}"
);
// Escape sequences never reach the transcript.
let (ansi, _, args) = dispatched(&mut app, "/ext-ansi");
assert_eq!(
super::run_command(&ansi, &args).await,
Ok(CommandOutcome::Show {
text: "plain red end".to_string()
})
);
// The DSH-shaped `rawInput` keeps its leading separator.
let (dsh, _, args) = dispatched(&mut app, "/ext-dsh a b");
assert_eq!(
super::run_command(&dsh, &args).await,
Ok(CommandOutcome::Show {
text: "\" a b\"".to_string()
})
);
// Disabling the plugin removes every command at once: the registry stops
// listing them, and the reference a user (or palette) still holds fails
// closed instead of reaching the host.
engine.set_plugins(fixture.disable("ext-commands"));
engine.sync().await.unwrap();
assert!(manager.live_command_names().is_empty());
assert_eq!(hint("ext-echo"), None);
let error = super::run_command(&echo, "x").await.unwrap_err();
assert!(error.contains("no longer registered"), "{error}");
manager.shutdown().await;
}
/// A command may never take a built-in's name or another plugin's: the
/// registration is refused with a reason and that plugin fails to activate,
/// without disturbing the plugin that already holds the name.
#[tokio::test]
async fn extension_commands_never_shadow_built_ins_or_other_plugins() {
let Some(node) = node_for_tests("extension_commands_never_shadow") else {
return;
};
let _policy = TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&[
"ext-commands",
"commands-clash-builtin",
"commands-clash-plugin",
])
.await;
let manager = fixture.manager(node);
let engine = manager.attach(fixture.registry());
engine.sync().await.unwrap();
let state = |name: &str| manager.owner_state(&plugin_id(&fixture, name)).unwrap();
match state("commands-clash-builtin") {
OwnerState::Failed(reason) => {
assert!(
reason.contains("collides with a built-in command"),
"{reason}"
/// With no built-in command catalog installed, a command cannot be checked
/// against the built-in names, so its registration is refused (never accepted
/// unchecked); tools are not affected. With one, the catalog decides.
#[test]
fn a_command_registration_is_refused_when_no_built_in_catalog_is_installed() {
let owner = |registry: &mut OwnerRegistry| {
registry
.begin_owner(
HostTier::Plugin,
"a",
"a",
Some(fake_authority("a")),
"hash-a",
)
}
other => panic!("{other:?}"),
}
// Activation order between the other two is not fixed: exactly one holds
// `/ext-echo`, and the other failed on it.
let (winner, loser) = match (state("ext-commands"), state("commands-clash-plugin")) {
(OwnerState::Active, OwnerState::Failed(reason)) => ("ext-commands", reason),
(OwnerState::Failed(reason), OwnerState::Active) => ("commands-clash-plugin", reason),
other => panic!("{other:?}"),
.unwrap()
};
assert!(loser.contains("already registered by extension"), "{loser}");
let live = manager.live_command_names();
assert_eq!(live.iter().filter(|name| *name == "ext-echo").count(), 1);
assert!(!live.contains(&"help".to_string()));
let holder = manager
.shared
.registry
.lock()
.unwrap()
.live_commands()
.into_iter()
.find(|command| command.name == "ext-echo")
.unwrap();
assert_eq!(holder.plugin_name, winner);
let diagnostics = manager.diagnostics().join("\n");
let _absent = BuiltinCommandsGuard::absent();
let mut registry = OwnerRegistry::new();
let a = owner(&mut registry);
let refused = register_command(&mut registry, &a, "fine-name", None).unwrap_err();
assert!(
diagnostics.contains("command `help` refused"),
"{diagnostics}"
refused.contains("no built-in command catalog is installed"),
"{refused}"
);
manager.shutdown().await;
assert!(registry.live_commands().is_empty());
// A name the grammar refuses is refused for its own reason first.
assert!(
register_command(&mut registry, &a, "Not Valid", None)
.unwrap_err()
.contains("invalid")
);
// Tools do not consult the command table.
register(&mut registry, &a, "a_tool").unwrap();
// The user registry is the last line: a command already defined by a
// markdown source, or by a built-in, wins the spelling and the extension
// one is left out with a load error naming why.
let mut registry = crate::commands::user_registry::UserCommandRegistry::from_loaded(vec![(
"ext-echo".to_string(),
"markdown wins".to_string(),
)]);
let entry = |name: &str| super::command::ExtensionCommandEntry {
registration: super::registry::CommandRegistration {
handle: 1,
owner: protocol::OwnerRef {
plugin_id: "p".into(),
generation: 1,
owner_token: "t".into(),
},
tier: HostTier::Plugin,
plugin_name: "p".into(),
content_hash: "h".into(),
name: name.to_string(),
description: "d".into(),
argument_hint: None,
},
authority: fake_authority("p"),
workspace: PathBuf::from("/w"),
};
registry.load_extension_commands(vec![entry("ext-echo"), entry("help")]);
let errors: Vec<String> = registry
.load_errors()
.iter()
.map(|error| error.message.clone())
.collect();
let _stub = stub_builtin_commands();
register_command(&mut registry, &a, "fine-name", None).unwrap();
let clash = register_command(&mut registry, &a, "help", None).unwrap_err();
assert!(
errors
.iter()
.any(|e| e.contains("'/ext-echo' collides with another command")),
"{errors:?}"
clash.contains("collides with a built-in command"),
"{clash}"
);
assert!(
errors
.iter()
.any(|e| e.contains("'/help' collides with a built-in command")),
"{errors:?}"
);
assert!(registry.get("ext-echo").unwrap().extension.is_none());
}
/// A command from a host that is down says so immediately instead of
/// hanging, and a revoked registration cannot be run.
#[tokio::test]
async fn a_command_from_a_dead_host_reports_host_down() {
let _catalog = stub_builtin_commands();
let policy = TestPolicyGuard::extension_host(true);
let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions::default()));
let reference = {
+6
View File
@@ -8932,6 +8932,12 @@ fn install_extension_host_boot_config(config: &Config) {
.enabled(crate::features::Feature::ExtensionHost);
crate::plugins::activation::install_extension_host_policy(enabled);
if enabled {
// The host asks the command table whether a built-in command answers
// to a name before it admits an extension command; without this it
// refuses every extension command.
crate::extension_host::command::install_builtin_commands(std::sync::Arc::new(
crate::commands::BuiltinCommandNames,
));
crate::extension_host::configure(crate::extension_host::ExtensionHostOptions::from_config(
config.extension_host.as_ref(),
));