diff --git a/Cargo.lock b/Cargo.lock index 11119b99..eb0b37c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ dependencies = [ "log", "serde", "serde_json", + "sha2 0.11.0", "smol", "toml 1.1.6+spec-1.1.0", "ureq", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index e149771a..f14022cb 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -8,6 +8,7 @@ default = ["process"] process = [ "dep:agent-client-protocol", "dep:base64", + "dep:sha2", "dep:smol", "dep:ureq", "dep:uuid", @@ -22,6 +23,7 @@ log = "0.4" base64 = { version = "0.23", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = { version = "0.11", optional = true } smol = { version = "2", optional = true } ureq = { version = "2", default-features = false, features = ["tls", "gzip"], optional = true } uuid = { version = "1", features = ["v4"], optional = true } diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs index 77652d74..52dd11d7 100644 --- a/crates/agent/examples/probe.rs +++ b/crates/agent/examples/probe.rs @@ -5,8 +5,8 @@ //! lists the native plugin catalog after running the optional operation: //! `install|update [--accept ]`, `uninstall|enable|disable `, //! `add-marketplace ` or `remove-marketplace `. `--home` isolates -//! the provider's native state (`CODEX_HOME` for Codex; for Claude Code `HOME` -//! and `CLAUDE_CONFIG_DIR`). +//! the provider's native state (`CODEX_HOME` for Codex, `GROK_HOME` for Grok; +//! for Claude Code `HOME` and `CLAUDE_CONFIG_DIR`). //! Turn mode: `probe [cwd] [approval] [acp-command args…] [flags]`. //! Flags are `--binary `, `--model `, `--mode plan`, `--effort `, //! `--resume `, `--fork`, `--leave-questions` (user-input requests diff --git a/crates/agent/src/grok.rs b/crates/agent/src/grok.rs index b4db05dc..559a8ee3 100644 --- a/crates/agent/src/grok.rs +++ b/crates/agent/src/grok.rs @@ -40,6 +40,8 @@ const FORK: &str = "_x.ai/session/fork"; const DEFAULT_MODE: &str = "default"; const PLAN_MODE: &str = "plan"; +pub(crate) mod plugins; + /// Start (or resume, or fork) a Grok session. pub async fn start(opts: SessionOptions) -> Result { let grok = Grok { diff --git a/crates/agent/src/grok/plugins.rs b/crates/agent/src/grok/plugins.rs new file mode 100644 index 00000000..717bd713 --- /dev/null +++ b/crates/agent/src/grok/plugins.rs @@ -0,0 +1,807 @@ +//! Grok Build's native plugin management, driven through `grok plugin …` +//! with one bounded child process per command. +//! +//! Grok installs plugins for the user only, so every installation is in the +//! user scope. Enablement is left out: no `grok plugin` read reports it, and +//! `grok inspect` reports a disabled plugin as enabled. + +use std::path::PathBuf; +use std::time::Duration; + +use serde::Deserialize; +use sha2::{Digest as _, Sha256}; + +use crate::{ + AgentError, CommandAcceptance, MarketplaceAction, PluginAction, PluginContext, + PluginInstallation, PluginListing, PluginOp, PluginOpOutcome, PluginScope, PluginSource, + PluginSourceKind, ProviderKind, ProviderPluginEntry, ProviderPluginMarketplace, Tri, + native_message, +}; + +const LIST_TIMEOUT: Duration = Duration::from_secs(60); +/// Installs, updates and marketplace adds may clone repositories. +const MUTATION_TIMEOUT: Duration = Duration::from_secs(300); +const DETAILS_CONCURRENCY: usize = 4; + +pub(crate) async fn list(context: &PluginContext) -> Result { + let (plugins, marketplaces) = smol::future::zip( + run_cli( + context, + &args(&["plugin", "list", "--json", "--available"]), + LIST_TIMEOUT, + ), + run_cli( + context, + &args(&["plugin", "marketplace", "list", "--json"]), + LIST_TIMEOUT, + ), + ) + .await; + let plugins = parse_listed(&succeeded(plugins?)?)?; + let marketplaces = parse_marketplaces(&succeeded(marketplaces?)?)?; + let mut listing = listing(plugins, marketplaces); + + let installed: Vec<(usize, String)> = listing + .entries + .iter() + .enumerate() + .filter(|(_, entry)| !entry.installations.is_empty()) + .map(|(index, entry)| (index, entry.name.clone())) + .collect(); + for chunk in installed.chunks(DETAILS_CONCURRENCY) { + let tasks: Vec<_> = chunk + .iter() + .map(|(index, name)| { + let context = context.clone(); + let (index, name) = (*index, name.clone()); + smol::spawn(async move { + let details = match operand(&name) { + Ok(name) => run_cli( + &context, + &["plugin".into(), "details".into(), name], + LIST_TIMEOUT, + ) + .await + .and_then(succeeded), + Err(error) => Err(error), + }; + (index, details) + }) + }) + .collect(); + for task in tasks { + let (index, details) = task.await; + let entry = &mut listing.entries[index]; + match details { + Ok(text) => apply_details(entry, &text), + Err(error) => entry + .diagnostics + .push(("details_error".into(), native_message(&error))), + } + } + } + Ok(listing) +} + +pub(crate) async fn run( + context: &PluginContext, + op: &PluginOp, +) -> Result { + let mut argv = op_args(op)?; + match op { + PluginOp::Install { accept_command, .. } => { + let untrusted = run_cli(context, &argv, MUTATION_TIMEOUT).await?; + match install_step(untrusted, accept_command.as_deref())? { + InstallStep::Finished(outcome) => Ok(outcome), + InstallStep::Trust => { + argv.push("--trust".into()); + message_outcome(run_cli(context, &argv, MUTATION_TIMEOUT).await?) + } + } + } + PluginOp::Update { .. } => { + let stdout = succeeded(run_cli(context, &argv, MUTATION_TIMEOUT).await?)?; + Ok(update_outcome(&stdout)) + } + _ => message_outcome(run_cli(context, &argv, MUTATION_TIMEOUT).await?), + } +} + +fn args(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_string()).collect() +} + +/// The argv for one mutation, without consent: `--trust` is added only for an +/// install whose challenge a person accepted ([`install_step`]), and +/// `--confirm`, which uninstalls every plugin of a multi-plugin repository, is +/// never passed. Plugins are named as `grok plugin list` names them; installs +/// take the `name@marketplace` id. +fn op_args(op: &PluginOp) -> Result, AgentError> { + let plugin = |verb: &str, target: String, scope: PluginScope| match scope { + PluginScope::User => Ok(vec!["plugin".into(), verb.into(), target]), + other => Err(AgentError::Protocol(format!( + "Grok installs plugins for the user only, not in the {other:?} scope" + ))), + }; + let marketplace = |verb: &str, target: &str| { + Ok(vec![ + "plugin".into(), + "marketplace".into(), + verb.into(), + operand(target)?, + ]) + }; + match op { + PluginOp::Install { id, scope, .. } => plugin("install", operand(id)?, *scope), + PluginOp::Update { id, scope, .. } => plugin("update", name(id)?, *scope), + PluginOp::Uninstall { id, scope } => plugin("uninstall", name(id)?, *scope), + PluginOp::SetEnabled { .. } => Err(AgentError::Protocol( + "Grok plugin enablement cannot be read back through its CLI".into(), + )), + PluginOp::AddMarketplace { source } => marketplace("add", source), + PluginOp::RemoveMarketplace { name } => marketplace("remove", name), + } +} + +fn name(id: &str) -> Result { + operand(split_id(id).0) +} + +/// A value placed in a positional slot must not be read as an option. +fn operand(value: &str) -> Result { + if value.is_empty() || value.starts_with('-') { + return Err(AgentError::Protocol(format!( + "refusing to pass {value:?} to `grok plugin`" + ))); + } + Ok(value.to_string()) +} + +fn split_id(id: &str) -> (&str, Option<&str>) { + match id.rsplit_once('@') { + Some((name, marketplace)) if !name.is_empty() => (name, Some(marketplace)), + _ => (id, None), + } +} + +fn management_command( + context: &PluginContext, + args: &[String], +) -> Result { + let binary = crate::resolve_binary(context.binary_path.as_deref(), "grok")?; + let mut command = crate::process::command(binary); + command.args(args).current_dir(&context.cwd); + for (key, value) in context.launch_env.pairs(ProviderKind::Grok) { + command.env(key, value); + } + Ok(command) +} + +struct CliOutput { + success: bool, + status: String, + stdout: String, + stderr: String, +} + +async fn run_cli( + context: &PluginContext, + args: &[String], + timeout: Duration, +) -> Result { + let display = format!("grok {}", args.join(" ")); + let mut command = smol::process::Command::from(management_command(context, args)?); + // `output()` gives the child a null stdin, so a prompt can never wait on + // input, and dropping it on timeout kills the child. + command.kill_on_drop(true); + let output = smol::future::or(async { Some(command.output().await) }, async { + smol::Timer::after(timeout).await; + None + }) + .await + .ok_or_else(|| AgentError::Provider(format!("`{display}` timed out")))? + .map_err(|error| AgentError::Spawn(format!("spawning `{display}`: {error}")))?; + Ok(CliOutput { + success: output.status.success(), + status: output.status.to_string(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn succeeded(output: CliOutput) -> Result { + if output.success { + Ok(output.stdout) + } else { + Err(failure(&output)) + } +} + +/// A failed command in the CLI's own words. +fn failure(output: &CliOutput) -> AgentError { + let message = [output.stderr.trim(), output.stdout.trim()] + .into_iter() + .find(|text| !text.is_empty()) + .map_or_else(|| output.status.clone(), str::to_string); + AgentError::Provider(message) +} + +fn message_outcome(output: CliOutput) -> Result { + let stdout = succeeded(output)?; + let message = stdout.trim(); + Ok(PluginOpOutcome::Done { + diagnostics: if message.is_empty() { + Vec::new() + } else { + vec![("message".into(), message.to_string())] + }, + }) +} + +enum InstallStep { + Finished(PluginOpOutcome), + /// Re-run with `--trust`: a person accepted exactly the challenge Grok + /// shows now. + Trust, +} + +/// What an install run without `--trust` leaves to do. +fn install_step(untrusted: CliOutput, accepted: Option<&str>) -> Result { + if untrusted.success { + return message_outcome(untrusted).map(InstallStep::Finished); + } + match trust_challenge(&untrusted.stderr) { + Some(challenge) if accepted == Some(challenge.sha256.as_str()) => Ok(InstallStep::Trust), + Some(challenge) => Ok(InstallStep::Finished(PluginOpOutcome::AcceptCommand( + challenge, + ))), + None => Err(failure(&untrusted)), + } +} + +/// Without `--trust`, `install` explains what installing activates and prints +/// the command that proceeds. That command is what a person accepts, bound to +/// the whole explanation: if any of it changes, it is shown again. +fn trust_challenge(stderr: &str) -> Option { + let native_text = stderr.trim(); + let (_, proceed) = native_text.split_once("re-run with --trust:")?; + let command = proceed + .lines() + .map(str::trim) + .find(|line| !line.is_empty())?; + command.ends_with(" --trust").then(|| CommandAcceptance { + command: command.to_string(), + sha256: Sha256::digest(native_text.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + mode: None, + native_text: native_text.to_string(), + }) +} + +/// `: updated ( -> )`, one line per updated repository. +fn update_outcome(stdout: &str) -> PluginOpOutcome { + let message = stdout.trim(); + let parsed = message + .split_once(": ") + .and_then(|(_, result)| result.split_once(" (")) + .and_then(|(outcome, versions)| { + let (old, new) = versions.strip_suffix(')')?.split_once(" -> ")?; + Some([ + ("update_outcome".to_string(), outcome.to_string()), + ("old_version".to_string(), old.to_string()), + ("new_version".to_string(), new.to_string()), + ]) + }) + .filter(|_| message.lines().count() == 1); + PluginOpOutcome::Done { + diagnostics: match parsed { + Some(diagnostics) => diagnostics.into(), + None if message.is_empty() => Vec::new(), + None => vec![("message".into(), message.to_string())], + }, + } +} + +/// One row of `grok plugin list --json --available`. +#[derive(Deserialize)] +struct Listed { + status: String, + name: String, + #[serde(default)] + version: Option, + #[serde(default)] + description: Option, + #[serde(default)] + marketplace: Option, + /// The installed repository; one repository can hold several plugins. + #[serde(default)] + repo_key: Option, + #[serde(default)] + path: Option, +} + +#[derive(Deserialize)] +struct ListedMarketplace { + name: String, + kind: String, + #[serde(default)] + source: ListedMarketplaceSource, +} + +#[derive(Deserialize, Default)] +struct ListedMarketplaceSource { + #[serde(default)] + path: Option, + #[serde(default)] + url: Option, +} + +fn parse_listed(stdout: &str) -> Result, AgentError> { + serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!("unexpected `grok plugin list` output: {error}")) + }) +} + +fn parse_marketplaces(stdout: &str) -> Result, AgentError> { + let listed: Vec = serde_json::from_str(stdout.trim()).map_err(|error| { + AgentError::Protocol(format!( + "unexpected `grok plugin marketplace list` output: {error}" + )) + })?; + Ok(listed + .into_iter() + .map(|marketplace| ProviderPluginMarketplace { + kind: match marketplace.kind.as_str() { + "local" => PluginSourceKind::LocalPath, + "git" => PluginSourceKind::Git, + _ => PluginSourceKind::Unknown, + }, + source: marketplace + .source + .path + .or(marketplace.source.url) + .unwrap_or(marketplace.kind), + name: marketplace.name, + location: None, + }) + .collect()) +} + +fn listing(listed: Vec, marketplaces: Vec) -> PluginListing { + // A plugin installed from a path or a URL takes its kind from its details + // ([`apply_details`]). + let source_kind = |plugin: &Listed| { + plugin + .marketplace + .as_ref() + .and_then(|name| { + marketplaces + .iter() + .find(|marketplace| marketplace.name == *name) + }) + .map_or(PluginSourceKind::Unknown, |marketplace| marketplace.kind) + }; + let repositories: Vec<&str> = listed + .iter() + .filter(|plugin| plugin.status == "installed") + .filter_map(|plugin| plugin.repo_key.as_deref()) + .collect(); + let mut entries = Vec::new(); + for plugin in &listed { + let installed = match plugin.status.as_str() { + "installed" => true, + "available" => false, + other => { + log::warn!( + "grok plugin list: unknown status {other:?} for {}", + plugin.name + ); + continue; + } + }; + let mut actions = Vec::new(); + let scope = PluginScope::User; + if !installed { + actions.push(PluginAction::Install { scope }); + } else { + // A path install is a live link that `update` leaves as it is. + if plugin.marketplace.is_some() { + actions.push(PluginAction::Update { scope }); + } + // Uninstalling one plugin of a multi-plugin repository needs + // `--confirm` and removes them all. + let shared = plugin.repo_key.as_deref().is_some_and(|repository| { + repositories + .iter() + .filter(|key| **key == repository) + .count() + > 1 + }); + if !shared { + actions.push(PluginAction::Uninstall { scope }); + } + } + entries.push(ProviderPluginEntry { + id: match &plugin.marketplace { + Some(marketplace) => format!("{}@{marketplace}", plugin.name), + None => plugin.name.clone(), + }, + name: plugin.name.clone(), + version: plugin.version.clone(), + description: plugin.description.clone(), + source: PluginSource { + marketplace: plugin.marketplace.clone(), + kind: source_kind(plugin), + }, + installations: if installed { + vec![PluginInstallation { + scope, + location: plugin.path.clone(), + version: plugin.version.clone(), + scope_enabled: None, + }] + } else { + Vec::new() + }, + enabled: Tri::Unknown, + declared: None, + errors: Vec::new(), + actions, + diagnostics: Vec::new(), + }); + } + + let mut marketplace_actions = vec![MarketplaceAction::Add]; + for marketplace in &marketplaces { + marketplace_actions.push(MarketplaceAction::Remove { + marketplace: marketplace.name.clone(), + uninstalls: entries + .iter() + .filter(|entry| { + !entry.installations.is_empty() + && entry.source.marketplace.as_deref() == Some(marketplace.name.as_str()) + }) + .map(|entry| entry.id.clone()) + .collect(), + }); + } + PluginListing { + entries, + marketplaces, + marketplace_actions, + errors: Vec::new(), + } +} + +/// `plugin details` has no `--json`, and it counts component directories +/// rather than naming components, so its text stays a native diagnostic. Its +/// `kind:` line (`local: ` or `git: `) is the only place Grok says +/// what a plugin installed from a path or a URL is. +fn apply_details(entry: &mut ProviderPluginEntry, text: &str) { + let detail = |key: &str| { + text.lines() + .find_map(|line| line.trim().strip_prefix(key)) + .map(str::trim) + }; + if entry.description.is_none() { + entry.description = detail("description:").map(str::to_string); + } + if entry.source.marketplace.is_none() { + entry.source.kind = match detail("kind:").and_then(|kind| kind.split_once(':')) { + Some(("local", _)) => PluginSourceKind::LocalPath, + Some(("git", _)) => PluginSourceKind::Git, + _ => PluginSourceKind::Unknown, + }; + } + entry + .diagnostics + .push(("details".into(), text.trim_end().to_string())); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> String { + let path = format!( + "{}/tests/fixtures/plugins/grok/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read_to_string(&path).unwrap_or_else(|error| panic!("{path}: {error}")) + } + + fn output(stdout: &str, stderr: &str, success: bool) -> CliOutput { + CliOutput { + success, + status: if success { + "exit status: 0" + } else { + "exit status: 1" + } + .into(), + stdout: stdout.into(), + stderr: stderr.into(), + } + } + + /// Recorded after installing `alpha@mkt` (local marketplace), `zeta@tools` + /// (git marketplace) and the two-plugin repository `multi` from a path. + #[test] + fn listing_names_plugins_as_the_cli_takes_them_and_offers_what_it_can_carry_out() { + let listed = parse_listed(&fixture("plugin-list-available.json")).unwrap(); + let marketplaces = parse_marketplaces(&fixture("marketplace-list.json")).unwrap(); + assert_eq!( + marketplaces, + [ + ProviderPluginMarketplace { + name: "mkt".into(), + source: "/tmp/tcode-probe-grok/mkt".into(), + kind: PluginSourceKind::LocalPath, + location: None, + }, + ProviderPluginMarketplace { + name: "tools".into(), + source: "file:///tmp/tcode-probe-grok/tools".into(), + kind: PluginSourceKind::Git, + location: None, + }, + ] + ); + let mut listing = listing(listed, marketplaces); + let ids: Vec<&str> = listing.entries.iter().map(|e| e.id.as_str()).collect(); + assert_eq!( + ids, + ["alpha@mkt", "epsilon", "delta", "zeta@tools", "beta@mkt"] + ); + let user = PluginScope::User; + + let alpha = &listing.entries[0]; + assert_eq!( + alpha.installations, + [PluginInstallation { + scope: user, + location: Some( + "/tmp/tcode-probe-grok/home/.grok/installed-plugins/alpha-e84495d9".into() + ), + version: Some("1.0.0".into()), + scope_enabled: None, + }] + ); + assert_eq!( + alpha.source, + PluginSource { + marketplace: Some("mkt".into()), + kind: PluginSourceKind::LocalPath, + } + ); + assert_eq!(alpha.enabled, Tri::Unknown); + assert_eq!( + alpha.actions, + [ + PluginAction::Update { scope: user }, + PluginAction::Uninstall { scope: user } + ] + ); + assert_eq!(listing.entries[3].source.kind, PluginSourceKind::Git); + + // Each plugin of a path-installed repository is a live link that + // `update` leaves alone, and uninstalling one needs `--confirm`. As in + // `list`, its kind comes from its details, which Grok prints per + // repository. + for multi in &mut listing.entries[1..3] { + apply_details(multi, &fixture("details-delta.txt")); + assert_eq!(multi.source.kind, PluginSourceKind::LocalPath); + assert!(multi.actions.is_empty(), "{multi:?}"); + } + assert_eq!(listing.entries[2].description, None); + + let beta = &listing.entries[4]; + assert!(beta.installations.is_empty()); + assert_eq!(beta.version.as_deref(), Some("0.1.0")); + assert_eq!(beta.description.as_deref(), Some("Skill only")); + assert_eq!(beta.actions, [PluginAction::Install { scope: user }]); + + // Removing a marketplace uninstalls its plugins, which the host asks + // about first. + assert_eq!( + listing.marketplace_actions, + [ + MarketplaceAction::Add, + MarketplaceAction::Remove { + marketplace: "mkt".into(), + uninstalls: vec!["alpha@mkt".into()], + }, + MarketplaceAction::Remove { + marketplace: "tools".into(), + uninstalls: vec!["zeta@tools".into()], + }, + ] + ); + + apply_details(&mut listing.entries[0], &fixture("details-alpha.txt")); + let alpha = &listing.entries[0]; + assert_eq!( + alpha.description.as_deref(), + Some("Skill, command, SessionStart hook and an unreachable HTTP MCP server") + ); + assert!(alpha.declared.is_none()); + assert!(matches!(alpha.diagnostics.as_slice(), + [(key, text)] if key == "details" + && text.contains("components: 1 skill dir(s), 1 command dir(s)"))); + } + + /// Recorded after installing the same two-plugin repository from a + /// `file://` URL instead of its path. + #[test] + fn a_repository_installed_from_a_url_is_a_git_checkout() { + let listed = parse_listed(&fixture("plugin-list-git-install.json")).unwrap(); + let mut listing = listing(listed, Vec::new()); + assert_eq!(listing.entries.len(), 2); + for entry in &mut listing.entries { + apply_details(entry, &fixture("details-git-install.txt")); + assert_eq!(entry.source.kind, PluginSourceKind::Git); + } + } + + #[test] + fn trust_is_passed_only_for_the_challenge_a_person_accepted() { + let refused = || output("", &fixture("install-untrusted.stderr.txt"), false); + let InstallStep::Finished(PluginOpOutcome::AcceptCommand(challenge)) = + install_step(refused(), None).unwrap() + else { + panic!("an untrusted install must ask first"); + }; + assert_eq!(challenge.command, "grok plugin install alpha@mkt --trust"); + assert_eq!(challenge.mode, None); + assert!( + challenge.native_text.starts_with( + "Installing \"alpha\" from marketplace \"mkt\" requires confirmation." + ), + "{}", + challenge.native_text + ); + assert_eq!( + challenge.sha256, + "73bbb8b21e0f3eccd8a8aa4b0e57361ff136482a4d41390cc332bfe3314293bc" + ); + + assert!(matches!( + install_step(refused(), Some(&challenge.sha256)).unwrap(), + InstallStep::Trust + )); + // An acceptance of anything other than what Grok shows now is asked again. + let stale = "0".repeat(64); + assert!(matches!( + install_step(refused(), Some(&stale)).unwrap(), + InstallStep::Finished(PluginOpOutcome::AcceptCommand(again)) if again == challenge + )); + assert_eq!( + native_message( + &install_step( + output("", &fixture("install-unknown.stderr.txt"), false), + Some(&challenge.sha256) + ) + .err() + .unwrap() + ), + "Error: No marketplace plugin named \"nosuch\" in \"tools\"." + ); + let InstallStep::Finished(installed) = + install_step(output(&fixture("install-trusted.txt"), "", true), None).unwrap() + else { + panic!("an install Grok did not refuse is finished"); + }; + assert_eq!( + installed, + PluginOpOutcome::Done { + diagnostics: vec![( + "message".into(), + "Installed 1 plugin(s) from mkt: alpha".into() + )], + } + ); + } + + #[test] + fn management_argv_never_consents_and_targets_plugins_by_their_listed_name() { + let user = PluginScope::User; + let cases = [ + ( + PluginOp::Install { + id: "alpha@mkt".into(), + scope: user, + accept_command: Some("0".repeat(64)), + }, + vec!["plugin", "install", "alpha@mkt"], + ), + ( + PluginOp::Update { + id: "alpha@mkt".into(), + scope: user, + accept_command: None, + }, + vec!["plugin", "update", "alpha"], + ), + ( + PluginOp::Uninstall { + id: "alpha@mkt".into(), + scope: user, + }, + vec!["plugin", "uninstall", "alpha"], + ), + ( + PluginOp::AddMarketplace { + source: "file:///tmp/tcode-probe-grok/tools".into(), + }, + vec![ + "plugin", + "marketplace", + "add", + "file:///tmp/tcode-probe-grok/tools", + ], + ), + ( + PluginOp::RemoveMarketplace { name: "mkt".into() }, + vec!["plugin", "marketplace", "remove", "mkt"], + ), + ]; + let context = PluginContext { + binary_path: Some(PathBuf::from("/opt/grok/bin/grok")), + launch_env: crate::LaunchEnv { + env: Vec::new(), + home: Some(PathBuf::from("/tmp/isolated/.grok")), + }, + cwd: std::env::temp_dir(), + project: false, + }; + for (op, expected) in cases { + let argv = op_args(&op).unwrap(); + assert_eq!(argv, expected); + let command = management_command(&context, &argv).unwrap(); + assert!( + command + .get_envs() + .any(|env| env == ("GROK_HOME".as_ref(), Some("/tmp/isolated/.grok".as_ref()))) + ); + } + assert!( + op_args(&PluginOp::Install { + id: "alpha@mkt".into(), + scope: PluginScope::Project, + accept_command: None, + }) + .is_err() + ); + assert!( + op_args(&PluginOp::AddMarketplace { + source: "--force".into() + }) + .is_err() + ); + } + + #[test] + fn update_reports_the_version_change_grok_prints() { + assert_eq!( + update_outcome(&fixture("update.txt")), + PluginOpOutcome::Done { + diagnostics: vec![ + ("update_outcome".into(), "updated".into()), + ("old_version".into(), "1.0.0".into()), + ("new_version".into(), "1.1.0".into()), + ], + } + ); + assert_eq!( + update_outcome(&fixture("update-path-plugin.txt")), + PluginOpOutcome::Done { + diagnostics: vec![( + "message".into(), + "multi-0b1623cc: local symlink, already live".into() + )], + } + ); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 2f91bd88..5165145d 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -262,7 +262,15 @@ impl ProviderKind { option_descriptors: OptionDescriptors::Wire, home_path: true, trust_project_extensions: false, - plugin_management: PluginManagement::NONE, + plugin_management: PluginManagement { + actions: &[ + PluginActionKind::Install, + PluginActionKind::Uninstall, + PluginActionKind::Update, + ], + marketplaces: true, + apply: ApplyNote::ReloadOrNextSession, + }, }, } } @@ -308,6 +316,9 @@ pub enum ApplyNote { /// session or in the next session; MCP servers only in the next session; /// updates need a restart. ReloadOrRestart, + /// Every change, MCP servers and updates included, applies after + /// `/reload-plugins` in a running session or in the next session. + ReloadOrNextSession, /// Sessions started afterwards see the change; what a running session /// picks up is not established. NextSession, @@ -580,11 +591,10 @@ pub async fn list_plugins( match provider { ProviderKind::ClaudeCode => claude_plugins::list(context).await, ProviderKind::Codex => codex::plugins::list(context).await, - ProviderKind::Pi - | ProviderKind::OpenCode - | ProviderKind::Cursor - | ProviderKind::Grok - | ProviderKind::Acp => Err(no_plugin_management(provider)), + ProviderKind::Grok => grok::plugins::list(context).await, + ProviderKind::Pi | ProviderKind::OpenCode | ProviderKind::Cursor | ProviderKind::Acp => { + Err(no_plugin_management(provider)) + } } } @@ -598,11 +608,10 @@ pub async fn run_plugin_op( match provider { ProviderKind::ClaudeCode => claude_plugins::run(context, op).await, ProviderKind::Codex => codex::plugins::run(context, op).await, - ProviderKind::Pi - | ProviderKind::OpenCode - | ProviderKind::Cursor - | ProviderKind::Grok - | ProviderKind::Acp => Err(no_plugin_management(provider)), + ProviderKind::Grok => grok::plugins::run(context, op).await, + ProviderKind::Pi | ProviderKind::OpenCode | ProviderKind::Cursor | ProviderKind::Acp => { + Err(no_plugin_management(provider)) + } } } diff --git a/crates/agent/tests/fixtures/plugins/grok/.gitattributes b/crates/agent/tests/fixtures/plugins/grok/.gitattributes new file mode 100644 index 00000000..8c31e480 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/.gitattributes @@ -0,0 +1,3 @@ +# Recorded CLI output, byte for byte: a challenge's SHA-256 is taken over it, +# so no checkout may convert its line endings. +* -text diff --git a/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md new file mode 100644 index 00000000..6c417901 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md @@ -0,0 +1,105 @@ +# Grok Build plugin probe — commands + +- CLI: `grok 1.0.46 (2765805b9442)`, darwin-arm64, binary + `/tmp/grok-inspect.D1FK/bin/grok`. Date 2026-10-03, macOS (Darwin 27.0.0, + arm64). +- Every command ran with stdin = `/dev/null` (with an inherited non-terminal + stdin, `grok plugin` fails with `Device not configured (os error 6)`), from + the cwd `/tmp/tcode-probe-grok/outside`, stdout and stderr captured + separately. +- Env: `PATH USER LOGNAME SHELL TMPDIR LANG` plus `HOME=/tmp/tcode-probe-grok/home` + and `GROK_HOME=/tmp/tcode-probe-grok/home/.grok`. No `XAI_API_KEY`; plugin + management needs none. With the real `HOME`, Grok also reads the user's + Claude Code settings and lists their marketplaces, so `HOME` is isolated too. +- Paths: local marketplace `/tmp/tcode-probe-grok/mkt` (plugins `alpha`: + skill, command, SessionStart hook and an HTTP MCP server; `beta`: skill), + git marketplace `/tmp/tcode-probe-grok/tools` added as a `file://` URL + (plugin `zeta`: skill and an HTTP MCP server), and the two-plugin repository + `/tmp/tcode-probe-grok/multi` (`delta`, `epsilon`, one directory each, each + with `.grok-plugin/plugin.json`). All three are git repositories. + Marketplace indexes are `.grok-plugin/marketplace.json`; Grok names a + source after its directory or repository (`mkt`, `tools`), not after the + index's `name`. + +## Recorded commands + +| # | Command | Exit | Fixture | +|---|---------|------|---------| +| 01 | `grok plugin list --json --available` | 0 | (`[]`) | +| 02 | `grok plugin marketplace add /tmp/tcode-probe-grok/mkt` | 0 | `Added marketplace source: mkt (…)` | +| 03 | `grok plugin marketplace add file:///tmp/tcode-probe-grok/tools` | 0 | `Added marketplace source: tools (…)` | +| 04 | `grok plugin marketplace list --json` | 0 | `marketplace-list.json` | +| 05 | `grok plugin list --json --available` | 0 | three available plugins | +| 06 | `grok plugin install alpha@mkt` | 1 | `install-untrusted.stderr.txt` | +| 07 | `grok plugin install alpha@mkt --trust` | 0 | `install-trusted.txt` | +| 08 | `grok plugin install zeta@tools --trust` | 0 | `Installed 1 plugin(s) from tools: zeta` | +| 09 | `grok plugin install /tmp/tcode-probe-grok/multi --trust` | 0 | `Installed 2 plugin(s) from …: epsilon, delta` | +| 10 | `grok plugin list --json --available` | 0 | `plugin-list-available.json` | +| 11 | `grok plugin details alpha` | 0 | `details-alpha.txt` | +| 13 | `grok plugin details delta` | 0 | `details-delta.txt` | +| 14 | `grok plugin update alpha` (after alpha → 1.1.0 in `mkt`) | 0 | `update.txt` | +| 16 | `grok plugin uninstall delta` | 1 | `Plugin "delta" belongs to repo "multi-0b1623cc" which also contains: - epsilon … To proceed: grok plugin uninstall delta --confirm` | +| 17 | `grok plugin uninstall zeta` | 0 | `Uninstalled 1 plugin(s): zeta` | +| 19 | `grok plugin marketplace remove mkt` | 0 | `Removed marketplace source and uninstalled 1 plugin(s): alpha-e84495d9` | +| 22 | `grok plugin install nosuch@tools --trust` | 1 | `install-unknown.stderr.txt` | +| 23 | `grok plugin marketplace add file:///tmp/tcode-probe-grok/tools` | 1 | `Error: Marketplace source already configured: …` | +| 24 | `grok plugin disable delta` | 0 | `Disabled plugin: delta`; `config.toml` `[plugins] disabled = ["delta"]` | +| 25 | `grok inspect --json` | 0 | `plugins: [{"name": "delta", "enabled": true, …}, …]` | +| 26 | `grok plugin enable delta` | 0 | `Enabled plugin: delta` | +| 27 | `grok plugin update delta` (after delta → 1.0.1 in `multi`) | 0 | `update-path-plugin.txt`; the list still reports 1.0.0 | +| 29 | `grok plugin install file:///tmp/tcode-probe-grok/multi --trust` (second home) | 0 | `Installed 2 plugin(s) from file:///tmp/tcode-probe-grok/multi: epsilon, delta` | +| 30 | `grok plugin list --json --available` (second home) | 0 | `plugin-list-git-install.json` | +| 31 | `grok plugin details delta` (second home) | 0 | `details-git-install.txt` | + +Each mutation was followed by a `plugin list --json --available` or +`plugin marketplace list --json` showing its effect (05, 10, 15, 18, 20, 21, +28 in the run). + +29–31 ran in a second, empty home (`HOME=/tmp/tcode-probe-grok-git/home`, +`GROK_HOME` under it), otherwise as above. In a third throwaway home, +`grok plugin install ../multi --trust` from the cwd `outside` listed both +plugins with `source` `/private/tmp/tcode-probe-grok/outside/../multi`; no +fixture was kept. + +## Findings the implementation relies on + +- Installing without `--trust` exits 1 and prints, on stderr, what installing + activates and the exact command that proceeds (06). That command and text + are the challenge a person accepts; `--trust` is passed only on a re-run + whose challenge text hashes to the accepted SHA-256. +- A plugin installed from a path or a URL has no marketplace, and its listed + `source` is the path made absolute against the cwd, or the URL as given + (10, 30); the listing carries no kind for it. `plugin details`, which + prints one block per repository (the same for `delta` and `epsilon`), + says `kind: local: ` (13) or `kind: git: ` (31), and the kind + is taken from there rather than from the path's syntax, which is the + host's. +- `plugin update` refreshes a git marketplace's checkout itself (verified + 1.1.0 → 1.2.0 without `marketplace update`). For a path install it reports + `local symlink, already live` and changes nothing (27), so Update is offered + for marketplace installs only. A plugin added to a git marketplace after it + was added is not installable until `grok plugin marketplace update`, which + the plugin contract has no action for. +- Uninstalling one plugin of a multi-plugin repository needs `--confirm` and + removes them all (16). The contract's uninstall carries no consent, so + Uninstall is not offered for those plugins. +- `marketplace remove` uninstalls the marketplace's plugins without asking + (19); the listing reports them so the host confirms first. +- Enablement: `install --trust` adds the plugin to `[plugins] enabled` in + `config.toml`; `disable`/`enable` move it between `enabled` and `disabled`. + No `grok plugin` read reports it, and `grok inspect` (text and `--json`) + reports a disabled plugin as enabled (25). A session started while `alpha` + was disabled listed none of its commands; after `enable`, a new session + listed `alpha-cmd` and `alpha-note`. Enable and Disable are therefore not + offered. + +## When a change applies + +Checked against a running `grok agent stdio` session (scripted model backend): +after `grok plugin install zeta@tools --trust` ran outside the session, +nothing changed until the prompt `/reload-plugins`, which answered `Plugin +registry rebuilt: 4 plugin(s), 1 hook(s) reloaded, MCP refreshed, 6 skill(s) +refreshed.`, listed `zeta-note`, and connected zeta's MCP server (`zeta-http` +ready, with its bearer header). After `grok plugin update beta` added a skill, +`/reload-plugins` listed `beta-extra`. A session started afterwards had both. +Hence `ApplyNote::ReloadOrNextSession`. diff --git a/crates/agent/tests/fixtures/plugins/grok/details-alpha.txt b/crates/agent/tests/fixtures/plugins/grok/details-alpha.txt new file mode 100644 index 00000000..3b2e3fc7 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/details-alpha.txt @@ -0,0 +1,10 @@ +alpha-e84495d9 + path: /tmp/tcode-probe-grok/home/.grok/installed-plugins/alpha-e84495d9 + kind: local: /private/tmp/tcode-probe-grok/mkt/plugins/alpha + source: mkt + installed: 2026-10-03T07:22:24.112923+00:00 + updated: 2026-10-03T07:22:24.112923+00:00 + plugins (1): + alpha v1.0.0 + description: Skill, command, SessionStart hook and an unreachable HTTP MCP server + components: 1 skill dir(s), 1 command dir(s), 0 agent dir(s), hooks, MCP servers diff --git a/crates/agent/tests/fixtures/plugins/grok/details-delta.txt b/crates/agent/tests/fixtures/plugins/grok/details-delta.txt new file mode 100644 index 00000000..1aeeaff9 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/details-delta.txt @@ -0,0 +1,8 @@ +multi-0b1623cc + path: /tmp/tcode-probe-grok/home/.grok/installed-plugins/multi-0b1623cc + kind: local: /tmp/tcode-probe-grok/multi + installed: 2026-10-03T07:22:24.557732+00:00 + updated: 2026-10-03T07:22:24.557732+00:00 + plugins (2): + epsilon v0.1.0 (subdir: epsilon) + delta v1.0.0 (subdir: delta) diff --git a/crates/agent/tests/fixtures/plugins/grok/details-git-install.txt b/crates/agent/tests/fixtures/plugins/grok/details-git-install.txt new file mode 100644 index 00000000..a0f689f9 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/details-git-install.txt @@ -0,0 +1,8 @@ +multi-b1627a84 + path: /tmp/tcode-probe-grok-git/home/.grok/installed-plugins/multi-b1627a84 + kind: git: file:///tmp/tcode-probe-grok/multi + installed: 2026-10-03T08:53:48.955178+00:00 + updated: 2026-10-03T08:53:48.955178+00:00 + plugins (2): + epsilon v0.1.0 (subdir: epsilon) + delta v1.0.1 (subdir: delta) diff --git a/crates/agent/tests/fixtures/plugins/grok/install-trusted.txt b/crates/agent/tests/fixtures/plugins/grok/install-trusted.txt new file mode 100644 index 00000000..0beca35c --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/install-trusted.txt @@ -0,0 +1 @@ +Installed 1 plugin(s) from mkt: alpha diff --git a/crates/agent/tests/fixtures/plugins/grok/install-unknown.stderr.txt b/crates/agent/tests/fixtures/plugins/grok/install-unknown.stderr.txt new file mode 100644 index 00000000..79bc3bf7 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/install-unknown.stderr.txt @@ -0,0 +1 @@ +Error: No marketplace plugin named "nosuch" in "tools". diff --git a/crates/agent/tests/fixtures/plugins/grok/install-untrusted.stderr.txt b/crates/agent/tests/fixtures/plugins/grok/install-untrusted.stderr.txt new file mode 100644 index 00000000..d0362e61 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/install-untrusted.stderr.txt @@ -0,0 +1,5 @@ +Installing "alpha" from marketplace "mkt" requires confirmation. +Plugins can run hooks, MCP servers, and skills on your machine, so installation needs explicit trust. + +To proceed, re-run with --trust: + grok plugin install alpha@mkt --trust diff --git a/crates/agent/tests/fixtures/plugins/grok/marketplace-list.json b/crates/agent/tests/fixtures/plugins/grok/marketplace-list.json new file mode 100644 index 00000000..103a2b0b --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/marketplace-list.json @@ -0,0 +1,17 @@ +[ + { + "name": "mkt", + "kind": "local", + "source": { + "path": "/tmp/tcode-probe-grok/mkt" + } + }, + { + "name": "tools", + "kind": "git", + "source": { + "url": "file:///tmp/tcode-probe-grok/tools", + "branch": null + } + } +] diff --git a/crates/agent/tests/fixtures/plugins/grok/plugin-list-available.json b/crates/agent/tests/fixtures/plugins/grok/plugin-list-available.json new file mode 100644 index 00000000..d5c028fd --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/plugin-list-available.json @@ -0,0 +1,49 @@ +[ + { + "status": "installed", + "name": "alpha", + "repo_key": "alpha-e84495d9", + "version": "1.0.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/alpha-e84495d9", + "source": "/private/tmp/tcode-probe-grok/mkt/plugins/alpha", + "marketplace": "mkt" + }, + { + "status": "installed", + "name": "epsilon", + "repo_key": "multi-0b1623cc", + "version": "0.1.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/multi-0b1623cc", + "source": "/tmp/tcode-probe-grok/multi", + "marketplace": null + }, + { + "status": "installed", + "name": "delta", + "repo_key": "multi-0b1623cc", + "version": "1.0.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/multi-0b1623cc", + "source": "/tmp/tcode-probe-grok/multi", + "marketplace": null + }, + { + "status": "installed", + "name": "zeta", + "repo_key": "zeta-49137176", + "version": "0.1.0", + "path": "/tmp/tcode-probe-grok/home/.grok/installed-plugins/zeta-49137176", + "source": "/private/tmp/tcode-probe-grok/home/.grok/marketplace-cache/f86a7344487eb091/plugins/zeta", + "marketplace": "tools" + }, + { + "status": "available", + "name": "beta", + "version": "0.1.0", + "description": "Skill only", + "marketplace": "mkt", + "skill_count": 1, + "has_hooks": false, + "has_agents": false, + "has_mcp": false + } +] diff --git a/crates/agent/tests/fixtures/plugins/grok/plugin-list-git-install.json b/crates/agent/tests/fixtures/plugins/grok/plugin-list-git-install.json new file mode 100644 index 00000000..5642159b --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/plugin-list-git-install.json @@ -0,0 +1,20 @@ +[ + { + "status": "installed", + "name": "delta", + "repo_key": "multi-b1627a84", + "version": "1.0.1", + "path": "/tmp/tcode-probe-grok-git/home/.grok/installed-plugins/multi-b1627a84", + "source": "file:///tmp/tcode-probe-grok/multi", + "marketplace": null + }, + { + "status": "installed", + "name": "epsilon", + "repo_key": "multi-b1627a84", + "version": "0.1.0", + "path": "/tmp/tcode-probe-grok-git/home/.grok/installed-plugins/multi-b1627a84", + "source": "file:///tmp/tcode-probe-grok/multi", + "marketplace": null + } +] diff --git a/crates/agent/tests/fixtures/plugins/grok/update-path-plugin.txt b/crates/agent/tests/fixtures/plugins/grok/update-path-plugin.txt new file mode 100644 index 00000000..c4aef1a3 --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/update-path-plugin.txt @@ -0,0 +1 @@ +multi-0b1623cc: local symlink, already live diff --git a/crates/agent/tests/fixtures/plugins/grok/update.txt b/crates/agent/tests/fixtures/plugins/grok/update.txt new file mode 100644 index 00000000..efe2428c --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/update.txt @@ -0,0 +1 @@ +alpha-e84495d9: updated (1.0.0 -> 1.1.0) diff --git a/crates/ui/src/plugins_settings.rs b/crates/ui/src/plugins_settings.rs index 5f1b71b6..258849f9 100644 --- a/crates/ui/src/plugins_settings.rs +++ b/crates/ui/src/plugins_settings.rs @@ -1634,6 +1634,9 @@ fn verbatim(text: String, cx: &App) -> AnyElement { fn apply_note(apply: ApplyNote) -> String { match apply { ApplyNote::ReloadOrRestart => crate::tr!("providers.plugins.apply_reload_or_restart"), + ApplyNote::ReloadOrNextSession => { + crate::tr!("providers.plugins.apply_reload_or_next_session") + } ApplyNote::NextSession => crate::tr!("providers.plugins.apply_next_session"), ApplyNote::Unverified => crate::tr!("providers.plugins.apply_unverified"), } diff --git a/locales/en.yml b/locales/en.yml index 6b50d302..dcc71816 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -698,6 +698,7 @@ providers: add: "Add" remove: "Remove" apply_reload_or_restart: "Skills, commands and hooks apply after /reload-plugins in a running session or in the next session; MCP servers apply in the next session; updates need a restart." + apply_reload_or_next_session: "Changes, MCP servers and updates included, apply after /reload-plugins in a running session or in the next session." apply_next_session: "Changes apply to sessions started afterwards; whether a running session picks them up is not verified." apply_unverified: "Whether a running session picks up changes is not verified; start a new session." manage: "Manage plugins" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index d1f6767c..8f57ddeb 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -691,6 +691,7 @@ providers: add: "添加" remove: "移除" apply_reload_or_restart: "技能、命令和钩子在运行中的会话执行 /reload-plugins 后或在下一个会话中生效;MCP 服务器在下一个会话中生效;更新需要重启。" + apply_reload_or_next_session: "所有变更(包括 MCP 服务器和更新)在运行中的会话执行 /reload-plugins 后或在下一个会话中生效。" apply_next_session: "变更对之后开始的会话生效;尚未验证运行中的会话是否会应用。" apply_unverified: "尚未验证运行中的会话是否会应用变更;请开始新会话。" manage: "管理插件"