mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
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());
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}`"
|
||||
));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
Reference in new issue
Block a user