From a4a25d77774b37c6949843f3f1356b767cb45e20 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:48:53 +0800 Subject: [PATCH 1/3] feat(grok): manage Grok plugins through `grok plugin` Grok's native plugin system behind the provider plugin contract, through its non-interactive CLI only, one bounded process per command (null stdin; with an inherited non-terminal stdin `grok plugin` fails with ENXIO). Verified against grok 1.0.46 in throwaway HOME/GROK_HOME, each operation followed by a native read (fixtures and provenance in tests/fixtures/plugins/grok). - Listing: `plugin list --json --available` and `plugin marketplace list --json`, plus `plugin details` per installed plugin (its description and native text). Marketplace plugins are `name@marketplace`, as `install` takes them; the other commands take the plain name. Grok installs for the user only. - Install: without `--trust` Grok explains what installing activates and prints the command that proceeds. That becomes the existing AcceptCommand challenge (Grok's command and text, bound by their SHA-256), and `--trust` is passed only on a re-run whose challenge still hashes to the accepted value; anything else is shown again. - Update is offered for marketplace installs (it refreshes a git marketplace's checkout itself); a path install is a live link that `update` leaves as it is. - Uninstall is not offered for a plugin of a multi-plugin repository: Grok then needs `--confirm` and removes them all, and the contract's uninstall carries no consent. `--confirm` and `--force` are never passed. - Marketplaces: add and remove. Removal uninstalls the marketplace's plugins, which the listing reports so the host confirms first. - Enable/Disable are left out: no `grok plugin` read reports enablement, and `grok inspect` (text and --json) reports a disabled plugin as enabled; a session confirmed the disabled plugin did not load. ApplyNote gains ReloadOrNextSession: against a running session, an install (skills, commands, hooks, an MCP server) and an update both applied after `/reload-plugins`, and in sessions started afterwards, but not before. --- Cargo.lock | 1 + crates/agent/Cargo.toml | 2 + crates/agent/examples/probe.rs | 4 +- crates/agent/src/grok.rs | 2 + crates/agent/src/grok/plugins.rs | 781 ++++++++++++++++++ crates/agent/src/lib.rs | 31 +- .../tests/fixtures/plugins/grok/COMMANDS.md | 89 ++ .../fixtures/plugins/grok/details-alpha.txt | 10 + .../fixtures/plugins/grok/details-delta.txt | 8 + .../fixtures/plugins/grok/install-trusted.txt | 1 + .../plugins/grok/install-unknown.stderr.txt | 1 + .../plugins/grok/install-untrusted.stderr.txt | 5 + .../plugins/grok/marketplace-list.json | 17 + .../plugins/grok/plugin-list-available.json | 49 ++ .../plugins/grok/update-path-plugin.txt | 1 + .../tests/fixtures/plugins/grok/update.txt | 1 + crates/ui/src/plugins_settings.rs | 3 + locales/en.yml | 1 + locales/zh-CN.yml | 1 + 19 files changed, 995 insertions(+), 13 deletions(-) create mode 100644 crates/agent/src/grok/plugins.rs create mode 100644 crates/agent/tests/fixtures/plugins/grok/COMMANDS.md create mode 100644 crates/agent/tests/fixtures/plugins/grok/details-alpha.txt create mode 100644 crates/agent/tests/fixtures/plugins/grok/details-delta.txt create mode 100644 crates/agent/tests/fixtures/plugins/grok/install-trusted.txt create mode 100644 crates/agent/tests/fixtures/plugins/grok/install-unknown.stderr.txt create mode 100644 crates/agent/tests/fixtures/plugins/grok/install-untrusted.stderr.txt create mode 100644 crates/agent/tests/fixtures/plugins/grok/marketplace-list.json create mode 100644 crates/agent/tests/fixtures/plugins/grok/plugin-list-available.json create mode 100644 crates/agent/tests/fixtures/plugins/grok/update-path-plugin.txt create mode 100644 crates/agent/tests/fixtures/plugins/grok/update.txt 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..0702a51a --- /dev/null +++ b/crates/agent/src/grok/plugins.rs @@ -0,0 +1,781 @@ +//! 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::{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, + #[serde(default)] + source: 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 { + let source_kind = |plugin: &Listed| match &plugin.marketplace { + Some(name) => marketplaces + .iter() + .find(|marketplace| marketplace.name == *name) + .map_or(PluginSourceKind::Unknown, |marketplace| marketplace.kind), + None => match plugin.source.as_deref() { + Some(source) if Path::new(source).is_absolute() => PluginSourceKind::LocalPath, + Some(_) => PluginSourceKind::Git, + None => PluginSourceKind::Unknown, + }, + }; + 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. +fn apply_details(entry: &mut ProviderPluginEntry, text: &str) { + if entry.description.is_none() { + entry.description = text + .lines() + .find_map(|line| line.trim().strip_prefix("description:")) + .map(|description| description.trim().to_string()); + } + 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`. + for multi in &listing.entries[1..3] { + assert_eq!(multi.source.kind, PluginSourceKind::LocalPath); + assert!(multi.actions.is_empty(), "{multi:?}"); + } + + 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)"))); + apply_details(&mut listing.entries[2], &fixture("details-delta.txt")); + assert_eq!(listing.entries[2].description, None); + } + + #[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/COMMANDS.md b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md new file mode 100644 index 00000000..489896bc --- /dev/null +++ b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md @@ -0,0 +1,89 @@ +# 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 | + +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). + +## 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. +- `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/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/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: "管理插件" From a520b050351dcb227e3287296a129ef19f372298 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:58:04 +0800 Subject: [PATCH 2/3] fix(grok): take a direct plugin install's kind from `grok plugin details` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin installed from a path or a URL has no marketplace, and the listing classified it by whether its `source` parsed as an absolute path on the host. Grok reports the kind itself: `plugin details`, which the listing already runs for every installed plugin, prints `kind: local: ` or `kind: git: ` per repository. The kind now comes from there, so it no longer depends on the host's path syntax (on Windows the recorded `/tmp/…` source read as a git URL), and a git install is classified from Grok's word instead of by elimination. Recorded against grok 1.0.46: the same two-plugin repository installed from a `file://` URL (`plugin-list-git-install.json`, `details-git-install.txt`), and a relative path install, whose source Grok makes absolute against its cwd. --- crates/agent/src/grok/plugins.rs | 70 +++++++++++++------ .../tests/fixtures/plugins/grok/COMMANDS.md | 16 +++++ .../plugins/grok/details-git-install.txt | 8 +++ .../plugins/grok/plugin-list-git-install.json | 20 ++++++ 4 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 crates/agent/tests/fixtures/plugins/grok/details-git-install.txt create mode 100644 crates/agent/tests/fixtures/plugins/grok/plugin-list-git-install.json diff --git a/crates/agent/src/grok/plugins.rs b/crates/agent/src/grok/plugins.rs index 0702a51a..717bd713 100644 --- a/crates/agent/src/grok/plugins.rs +++ b/crates/agent/src/grok/plugins.rs @@ -5,7 +5,7 @@ //! user scope. Enablement is left out: no `grok plugin` read reports it, and //! `grok inspect` reports a disabled plugin as enabled. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::Duration; use serde::Deserialize; @@ -320,8 +320,6 @@ struct Listed { repo_key: Option, #[serde(default)] path: Option, - #[serde(default)] - source: Option, } #[derive(Deserialize)] @@ -372,16 +370,18 @@ fn parse_marketplaces(stdout: &str) -> Result, Ag } fn listing(listed: Vec, marketplaces: Vec) -> PluginListing { - let source_kind = |plugin: &Listed| match &plugin.marketplace { - Some(name) => marketplaces - .iter() - .find(|marketplace| marketplace.name == *name) - .map_or(PluginSourceKind::Unknown, |marketplace| marketplace.kind), - None => match plugin.source.as_deref() { - Some(source) if Path::new(source).is_absolute() => PluginSourceKind::LocalPath, - Some(_) => PluginSourceKind::Git, - None => PluginSourceKind::Unknown, - }, + // 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() @@ -476,13 +476,24 @@ fn listing(listed: Vec, marketplaces: Vec) -> } /// `plugin details` has no `--json`, and it counts component directories -/// rather than naming components, so its text stays a native diagnostic. +/// 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 = text - .lines() - .find_map(|line| line.trim().strip_prefix("description:")) - .map(|description| description.trim().to_string()); + 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 @@ -576,11 +587,15 @@ mod tests { 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`. - for multi in &listing.entries[1..3] { + // `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()); @@ -615,8 +630,19 @@ mod tests { assert!(matches!(alpha.diagnostics.as_slice(), [(key, text)] if key == "details" && text.contains("components: 1 skill dir(s), 1 command dir(s)"))); - apply_details(&mut listing.entries[2], &fixture("details-delta.txt")); - assert_eq!(listing.entries[2].description, None); + } + + /// 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] diff --git a/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md index 489896bc..6c417901 100644 --- a/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md +++ b/crates/agent/tests/fixtures/plugins/grok/COMMANDS.md @@ -47,17 +47,33 @@ | 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 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/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 + } +] From 33655f1976cdd7850b00731013d393a1970ae51e Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sat, 3 Oct 2026 16:58:04 +0800 Subject: [PATCH 3/3] test(grok): keep the recorded plugin output byte for byte on every checkout The install challenge's SHA-256 is taken over Grok's stderr exactly as printed. A checkout that converts line endings (core.autocrlf on Windows) turned the recorded LF into CRLF, so the test hashed bytes Grok never printed. The fixtures are now never converted. The product needs no change for this: the challenge and its acceptance are both hashed on the host from the same Grok's output, so they agree whatever line endings Grok prints. --- crates/agent/tests/fixtures/plugins/grok/.gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 crates/agent/tests/fixtures/plugins/grok/.gitattributes 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