From ec76a83e819e7fd4cdec3ef18410402127843619 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 13 Aug 2026 15:59:47 +0700 Subject: [PATCH 1/3] feat(plugin): add OpenCode compatibility host Introduce an opt-in Bun sidecar for discovering, loading, and invoking OpenCode-style plugins while keeping failures isolated from the Rust application. Add regression coverage for ordering, errors, timeouts, stdout isolation, and process cleanup.\n\nRefs #24 --- src/app.rs | 8 ++ src/config/configuration.rs | 201 ++++++++++++++++++++++++++++ src/main.rs | 33 +++++ src/plugin/mod.rs | 230 ++++++++++++++++++++++++++++++++ src/plugin/protocol.rs | 28 ++++ src/plugin/sidecar.mjs | 106 +++++++++++++++ tests/plugin_host_regression.rs | 229 +++++++++++++++++++++++++++++++ 7 files changed, 835 insertions(+) create mode 100644 src/plugin/mod.rs create mode 100644 src/plugin/protocol.rs create mode 100644 src/plugin/sidecar.mjs create mode 100644 tests/plugin_host_regression.rs diff --git a/src/app.rs b/src/app.rs index dddcc57..3a1d246 100644 --- a/src/app.rs +++ b/src/app.rs @@ -893,6 +893,8 @@ pub struct App { terminal_focused: bool, pub tool_permissions: crate::tools::ToolPermissions, pub skills_dirs: Vec, + pub plugin_specs: Vec, + pub project_root: std::path::PathBuf, pub is_streaming: bool, pending_session_title: Option, session_view_states: std::collections::HashMap, @@ -1011,6 +1013,8 @@ impl App { }; let loaded_config = crate::config::ConfigLoader::load()?; + let plugin_specs = loaded_config.merged_config.plugins.clone(); + let project_root = loaded_config.project_root.clone(); let mut mcp_config = loaded_config.merged_config.mcp.clone(); crate::remote_mcp::apply_mcp_overrides(&mut mcp_config, prefs_dao.as_ref()); input.set_image_open_config(loaded_config.merged_config.images.clone()); @@ -1240,6 +1244,8 @@ impl App { terminal_focused: true, tool_permissions, skills_dirs: loaded_config.inventory.opencode_skills_dirs, + plugin_specs, + project_root, // Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs is_streaming: false, pending_session_title: None, @@ -11304,6 +11310,8 @@ mod tests { terminal_focused: true, tool_permissions: crate::tools::ToolPermissions::new(".".to_string()), skills_dirs: Vec::new(), + plugin_specs: Vec::new(), + project_root: std::path::PathBuf::from("."), is_streaming: false, pending_session_title: None, session_view_states: std::collections::HashMap::new(), diff --git a/src/config/configuration.rs b/src/config/configuration.rs index b8dc244..62d8e1f 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -104,6 +104,18 @@ fn list_json_files(dir: &Path) -> Vec { out } +fn append_discovered_plugins(plugins: &mut Vec, plugin_files: &[PathBuf]) { + for path in plugin_files { + let source = path.to_string_lossy().into_owned(); + if !plugins.iter().any(|plugin| plugin.source == source) { + plugins.push(PluginSpec { + source, + options: Value::Null, + }); + } + } +} + fn parse_provider_id_set( value: Option<&Value>, diagnostics: &mut ConfigDiagnostics, @@ -171,6 +183,13 @@ pub struct ConfigInventory { pub opencode_agents: Vec, pub opencode_skills_dirs: Vec, pub command_files: Vec, + pub plugin_files: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginSpec { + pub source: String, + pub options: Value, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -479,6 +498,7 @@ pub struct MergedConfig { pub compaction: CompactionConfig, pub watcher: WatcherConfig, pub formatter: HashMap, + pub plugins: Vec, } impl MergedConfig { @@ -627,6 +647,7 @@ impl ConfigLoader { &mut diagnostics, ); let mut merged_config = parse_merged_config(&merged, &mut diagnostics); + append_discovered_plugins(&mut merged_config.plugins, &inventory.plugin_files); merged_config.instructions = load_instruction_files(&merged_config.instructions, &project_root, &mut diagnostics); let mut agent_definitions = crate::agent::definition::load_markdown_agent_definitions( @@ -747,6 +768,43 @@ fn discover_opencode_inventory( )); } inventory.opencode_skills_dirs = skills_dirs; + + let mut plugin_files = Vec::new(); + for dir in [ + global_opencode.join("plugins"), + global_opencode.join("plugin"), + local_opencode.join("plugins"), + local_opencode.join("plugin"), + ] { + plugin_files.extend(list_plugin_files(&dir)); + } + plugin_files.sort(); + plugin_files.dedup(); + if !plugin_files.is_empty() { + diagnostics.info.push(format!( + "Discovered {} OpenCode plugin files", + plugin_files.len() + )); + } + inventory.plugin_files = plugin_files; +} + +fn list_plugin_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = fs::read_dir(dir) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let extension = path.extension().and_then(|value| value.to_str()); + if matches!(extension, Some("js" | "mjs" | "cjs" | "ts")) { + out.push(path); + } + } + out } fn load_custom_commands( @@ -1032,6 +1090,7 @@ fn opencode_allowed_keys() -> BTreeSet<&'static str> { [ "$schema", "agent", + "plugin", "instructions", "tools", "mcp", @@ -1301,6 +1360,50 @@ fn expand_path(arg: &str, base_dir: &Path) -> PathBuf { } } +fn parse_plugin_specs( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> Vec { + let Some(Value::Array(entries)) = value else { + if value.is_some() { + diagnostics + .warnings + .push("plugin must be an array".to_string()); + } + return Vec::new(); + }; + + entries + .iter() + .enumerate() + .filter_map(|(index, entry)| match entry { + Value::String(source) if !source.trim().is_empty() => Some(PluginSpec { + source: source.trim().to_string(), + options: Value::Null, + }), + Value::Array(tuple) if tuple.len() == 2 => { + let Some(source) = tuple[0].as_str().filter(|value| !value.trim().is_empty()) + else { + diagnostics.warnings.push(format!( + "plugin[{index}] must start with a non-empty plugin source" + )); + return None; + }; + Some(PluginSpec { + source: source.trim().to_string(), + options: tuple[1].clone(), + }) + } + _ => { + diagnostics.warnings.push(format!( + "plugin[{index}] must be a source string or [source, options]" + )); + None + } + }) + .collect() +} + fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> MergedConfig { let mut out = MergedConfig::default(); let obj = match merged.as_object() { @@ -1342,6 +1445,7 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M json_agents, ); out.sync_agent_derived_fields(); + out.plugins = parse_plugin_specs(obj.get("plugin"), diagnostics); out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); out.enabled_providers = parse_provider_id_set( obj.get("enabled_providers") @@ -2596,6 +2700,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { "enabled_providers", "permission", "mcp", + "plugin", ] .into_iter() .collect(); @@ -2619,6 +2724,102 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn parses_plugin_sources_and_options() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "plugin": [ + "./.opencode/plugins/one.mjs", + ["@scope/two", { "enabled": true }], + 42 + ] + }), + &mut diagnostics, + ); + + assert_eq!(config.plugins.len(), 2); + assert_eq!(config.plugins[0].source, "./.opencode/plugins/one.mjs"); + assert_eq!(config.plugins[0].options, Value::Null); + assert_eq!(config.plugins[1].source, "@scope/two"); + assert_eq!(config.plugins[1].options, json!({ "enabled": true })); + assert_eq!(diagnostics.warnings.len(), 1); + } + + #[test] + fn opencode_plugin_key_survives_filtering_and_is_implemented() { + let filtered = filter_top_level( + json!({ + "plugin": ["./plugin.mjs"], + "unknown": true + }), + SourceKind::OpenCode, + ); + + assert_eq!(filtered, json!({ "plugin": ["./plugin.mjs"] })); + assert!(collect_unimplemented_keys(&filtered).is_empty()); + } + + #[test] + fn discovers_supported_plugin_files() { + let temp = tempfile::tempdir().expect("temp dir"); + std::fs::write(temp.path().join("a.mjs"), "export default {};").unwrap(); + std::fs::write(temp.path().join("b.ts"), "export default {};").unwrap(); + std::fs::write(temp.path().join("ignored.txt"), "ignored").unwrap(); + + let mut files = list_plugin_files(temp.path()); + files.sort(); + + assert_eq!(files.len(), 2); + assert!(files.iter().any(|path| path.ends_with("a.mjs"))); + assert!(files.iter().any(|path| path.ends_with("b.ts"))); + } + + #[test] + fn plugin_discovery_is_sorted_across_singular_and_plural_directories() { + let project = tempfile::tempdir().expect("project temp dir"); + let xdg = tempfile::tempdir().expect("xdg temp dir"); + let singular = project.path().join(".opencode/plugin"); + let plural = project.path().join(".opencode/plugins"); + std::fs::create_dir_all(&singular).unwrap(); + std::fs::create_dir_all(&plural).unwrap(); + std::fs::write(singular.join("z.mjs"), "export default {};").unwrap(); + std::fs::write(plural.join("a.js"), "export default {};").unwrap(); + + let mut inventory = ConfigInventory::default(); + let mut diagnostics = ConfigDiagnostics::default(); + discover_opencode_inventory(xdg.path(), project.path(), &mut inventory, &mut diagnostics); + + assert_eq!(inventory.plugin_files.len(), 2); + assert!(inventory.plugin_files[0].ends_with("a.js")); + assert!(inventory.plugin_files[1].ends_with("z.mjs")); + } + + #[test] + fn explicit_plugins_stay_first_and_dedupe_discovered_paths() { + let project = tempfile::tempdir().expect("project temp dir"); + let plugin_dir = project.path().join(".opencode/plugins"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let discovered = plugin_dir.join("local.mjs"); + std::fs::write(&discovered, "export default {};").unwrap(); + + let mut plugins = vec![ + PluginSpec { + source: "@scope/package".to_string(), + options: json!({ "mode": "strict" }), + }, + PluginSpec { + source: discovered.to_string_lossy().into_owned(), + options: Value::Null, + }, + ]; + append_discovered_plugins(&mut plugins, &[discovered]); + + assert_eq!(plugins.len(), 2); + assert_eq!(plugins[0].source, "@scope/package"); + assert_eq!(plugins[0].options, json!({ "mode": "strict" })); + } + #[test] fn parses_and_applies_top_level_runtime_configuration() { let mut diagnostics = ConfigDiagnostics::default(); diff --git a/src/main.rs b/src/main.rs index a03af92..605f117 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod mcp; mod model; mod notify; mod persistence; +mod plugin; mod prompt; mod remote; mod remote_mcp; @@ -861,6 +862,32 @@ async fn main() -> Result<()> { } let mut app = App::new_with_model_override(args.model.as_deref())?; + let plugins_enabled = std::env::var("CRABCODE_ENABLE_OPENCODE_PLUGINS") + .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); + let mut plugin_host = None; + if plugins_enabled && !app.plugin_specs.is_empty() { + let cache_dir = crate::persistence::get_data_dir().join("cache"); + match crate::plugin::PluginHost::start(&cache_dir, &app.project_root).await { + Ok(mut host) => match host.load_plugins(&app.plugin_specs).await { + Ok(result) => { + crate::startup_diag!("Plugins: {}", result); + plugin_host = Some(host); + } + Err(error) => { + crate::startup_diag!("Plugin warning: failed to load plugins: {}", error); + let _ = host.shutdown().await; + } + }, + Err(error) => { + crate::startup_diag!("Plugin warning: failed to start Bun sidecar: {}", error); + } + } + } else if !app.plugin_specs.is_empty() { + crate::startup_diag!( + "Plugins: {} discovered but disabled; set CRABCODE_ENABLE_OPENCODE_PLUGINS=1 to enable the experimental host", + app.plugin_specs.len() + ); + } // Keep herdr authority until this guard drops (normal exit or panic). let _herdr = crate::herdr::Session::start(); @@ -931,6 +958,12 @@ async fn main() -> Result<()> { restore_terminal_modes(terminal.backend_mut(), keyboard_enhancement)?; terminal.show_cursor()?; + if let Some(host) = plugin_host { + if let Err(error) = host.shutdown().await { + eprintln!("Plugin warning: failed to stop sidecar: {error}"); + } + } + if let Some(request) = remote_launch_request { if let Err(err) = result { return Err(err); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 0000000..3c56cdb --- /dev/null +++ b/src/plugin/mod.rs @@ -0,0 +1,230 @@ +mod protocol; + +use std::{ + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{json, Value}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}, + process::{Child, ChildStdin, ChildStdout, Command}, + time::timeout, +}; + +use crate::config::configuration::PluginSpec; +use protocol::{Request, Response, PROTOCOL_VERSION}; + +const SIDECAR_SOURCE: &str = include_str!("sidecar.mjs"); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct PluginHost { + child: Child, + stdin: ChildStdin, + stdout: Lines>, + next_request_id: u64, + request_timeout: Duration, +} + +impl PluginHost { + pub async fn start(cache_dir: &Path, workspace: &Path) -> Result { + Self::start_with_runtime(cache_dir, workspace, "bun").await + } + + async fn start_with_runtime(cache_dir: &Path, workspace: &Path, runtime: &str) -> Result { + let sidecar_path = install_sidecar(cache_dir).await?; + let mut child = Command::new(runtime) + .arg("run") + .arg(&sidecar_path) + .current_dir(workspace) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("failed to start plugin runtime `{runtime}`"))?; + let stdin = child + .stdin + .take() + .context("plugin host stdin unavailable")?; + let stdout = child + .stdout + .take() + .context("plugin host stdout unavailable")?; + let mut host = Self { + child, + stdin, + stdout: BufReader::new(stdout).lines(), + next_request_id: 1, + request_timeout: DEFAULT_TIMEOUT, + }; + host.call( + "initialize", + json!({ + "protocolVersion": PROTOCOL_VERSION, + "workspace": workspace, + }), + ) + .await?; + Ok(host) + } + + pub async fn load_plugins(&mut self, plugins: &[PluginSpec]) -> Result { + let specs: Vec = plugins + .iter() + .map(|plugin| { + json!({ + "source": plugin.source, + "options": plugin.options, + }) + }) + .collect(); + self.call("load_plugins", json!({ "plugins": specs })).await + } + + pub async fn ping(&mut self) -> Result<()> { + self.call("ping", Value::Null).await.map(|_| ()) + } + + pub async fn invoke_hook(&mut self, hook: &str, input: Value, output: Value) -> Result { + self.call( + "invoke_hook", + json!({ + "hook": hook, + "input": input, + "output": output, + }), + ) + .await + } + + pub async fn shutdown(mut self) -> Result<()> { + let _ = self.call("shutdown", Value::Null).await; + match timeout(Duration::from_secs(1), self.child.wait()).await { + Ok(status) => { + status.context("failed waiting for plugin host")?; + } + Err(_) => { + self.child + .kill() + .await + .context("failed to kill plugin host")?; + } + } + Ok(()) + } + + async fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_request_id; + self.next_request_id += 1; + let request = Request { id, method, params }; + let mut encoded = + serde_json::to_vec(&request).context("failed to encode plugin request")?; + encoded.push(b'\n'); + self.stdin + .write_all(&encoded) + .await + .context("failed to write plugin request")?; + self.stdin + .flush() + .await + .context("failed to flush plugin request")?; + + let line = match timeout(self.request_timeout, self.stdout.next_line()).await { + Ok(result) => result?, + Err(_) => { + let _ = self.child.kill().await; + bail!("plugin request `{method}` timed out"); + } + } + .ok_or_else(|| anyhow!("plugin host exited during `{method}`"))?; + let response: Response = + serde_json::from_str(&line).context("invalid response from plugin host")?; + if response.id != id { + bail!( + "plugin response id mismatch: expected {id}, got {}", + response.id + ); + } + if let Some(error) = response.error { + bail!( + "plugin host error {}: {} ({})", + error.code, + error.message, + error.data + ); + } + Ok(response.result) + } + + #[cfg(test)] + pub(crate) fn set_request_timeout(&mut self, timeout: Duration) { + self.request_timeout = timeout; + } + + #[cfg(test)] + pub(crate) fn process_id(&self) -> Option { + self.child.id() + } +} + +async fn install_sidecar(cache_dir: &Path) -> Result { + let plugin_dir = cache_dir.join("plugin-host"); + tokio::fs::create_dir_all(&plugin_dir) + .await + .context("failed to create plugin host cache directory")?; + let path = plugin_dir.join(format!("sidecar-v{PROTOCOL_VERSION}.mjs")); + let needs_write = match tokio::fs::read_to_string(&path).await { + Ok(existing) => existing != SIDECAR_SOURCE, + Err(_) => true, + }; + if needs_write { + tokio::fs::write(&path, SIDECAR_SOURCE) + .await + .context("failed to install plugin host sidecar")?; + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn sidecar_round_trip_when_bun_is_available() { + if Command::new("bun").arg("--version").output().await.is_err() { + return; + } + let temp = tempfile::tempdir().expect("temp dir"); + let plugin_path = temp.path().join("plugin.mjs"); + tokio::fs::write( + &plugin_path, + "export default async ({ options }) => { if (!options.enabled) throw new Error('missing options'); return { 'test.echo': async (input, output) => { output.value = input.value; } }; };", + ) + .await + .expect("write plugin fixture"); + let mut host = PluginHost::start(temp.path(), temp.path()) + .await + .expect("start plugin host"); + let loaded = host + .load_plugins(&[PluginSpec { + source: plugin_path.to_string_lossy().into_owned(), + options: json!({ "enabled": true }), + }]) + .await + .expect("load plugin"); + assert_eq!( + loaded["loaded"][0]["source"], + plugin_path.to_string_lossy().as_ref() + ); + let output = host + .invoke_hook("test.echo", json!({ "value": "ok" }), json!({})) + .await + .expect("invoke plugin hook"); + assert_eq!(output["value"], "ok"); + host.ping().await.expect("ping plugin host"); + host.shutdown().await.expect("shutdown plugin host"); + } +} diff --git a/src/plugin/protocol.rs b/src/plugin/protocol.rs new file mode 100644 index 0000000..f4843f2 --- /dev/null +++ b/src/plugin/protocol.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Serialize)] +pub struct Request<'a> { + pub id: u64, + pub method: &'a str, + pub params: Value, +} + +#[derive(Debug, Deserialize)] +pub struct Response { + pub id: u64, + #[serde(default)] + pub result: Value, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RpcError { + pub code: i64, + pub message: String, + #[serde(default)] + pub data: Value, +} diff --git a/src/plugin/sidecar.mjs b/src/plugin/sidecar.mjs new file mode 100644 index 0000000..511231b --- /dev/null +++ b/src/plugin/sidecar.mjs @@ -0,0 +1,106 @@ +import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; +import { isAbsolute, resolve } from "node:path"; + +const PROTOCOL_VERSION = 1; +const protocolWrite = process.stdout.write.bind(process.stdout); +process.stdout.write = process.stderr.write.bind(process.stderr); +console.log = console.error.bind(console); +console.info = console.error.bind(console); +console.debug = console.error.bind(console); +const plugins = []; +let workspace = process.cwd(); + +function reply(id, result) { + protocolWrite(`${JSON.stringify({ id, result })}\n`); +} + +function fail(id, error) { + protocolWrite( + `${JSON.stringify({ + id, + error: { + code: -32000, + message: error instanceof Error ? error.message : String(error), + data: error instanceof Error ? { stack: error.stack } : null, + }, + })}\n`, + ); +} + +function importTarget(source) { + if (source.startsWith("file:")) { + return source; + } + if (!source.startsWith(".") && !isAbsolute(source)) { + return pathToFileURL(Bun.resolveSync(source, workspace)).href; + } + return pathToFileURL(resolve(workspace, source)).href; +} + +async function loadPlugin(spec) { + const module = await import(importTarget(spec.source)); + const factory = module.default ?? module.plugin ?? module; + const hooks = typeof factory === "function" + ? await factory({ + directory: workspace, + worktree: workspace, + options: spec.options, + client: { + app: { + log(entry) { + process.stderr.write(`[plugin:${spec.source}] ${JSON.stringify(entry)}\n`); + }, + }, + }, + }) + : factory; + plugins.push({ source: spec.source, hooks: hooks ?? {} }); + return { source: spec.source }; +} + +async function dispatch(method, params) { + switch (method) { + case "initialize": { + if (params?.protocolVersion !== PROTOCOL_VERSION) { + throw new Error( + `unsupported protocol version ${params?.protocolVersion}; expected ${PROTOCOL_VERSION}`, + ); + } + workspace = params.workspace ?? workspace; + return { protocolVersion: PROTOCOL_VERSION, runtime: `bun ${Bun.version}` }; + } + case "load_plugins": { + const loaded = []; + for (const spec of params?.plugins ?? []) loaded.push(await loadPlugin(spec)); + return { loaded }; + } + case "invoke_hook": { + const output = params?.output ?? {}; + for (const plugin of plugins) { + const hook = plugin.hooks?.[params?.hook]; + if (typeof hook === "function") await hook(params?.input ?? {}, output); + } + return output; + } + case "ping": + return { ok: true }; + case "shutdown": + setTimeout(() => process.exit(0), 0); + return { ok: true }; + default: + throw new Error(`unknown plugin host method: ${method}`); + } +} + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +for await (const line of input) { + if (!line.trim()) continue; + let request; + try { + request = JSON.parse(line); + reply(request.id, await dispatch(request.method, request.params)); + } catch (error) { + fail(request?.id ?? null, error); + } +} diff --git a/tests/plugin_host_regression.rs b/tests/plugin_host_regression.rs new file mode 100644 index 0000000..f097442 --- /dev/null +++ b/tests/plugin_host_regression.rs @@ -0,0 +1,229 @@ +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; + +mod config { + pub mod configuration { + use serde_json::Value; + + #[derive(Clone, Debug, PartialEq)] + pub struct PluginSpec { + pub source: String, + pub options: Value, + } + } +} + +#[path = "../src/plugin/mod.rs"] +mod plugin; + +use config::configuration::PluginSpec; +use plugin::PluginHost; + +async fn bun_available() -> bool { + Command::new("bun") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()) +} + +async fn write_plugin(root: &Path, name: &str, source: &str) -> PathBuf { + let path = root.join(name); + tokio::fs::write(&path, source) + .await + .expect("write plugin fixture"); + path +} + +fn spec(path: &Path, options: Value) -> PluginSpec { + PluginSpec { + source: path.to_string_lossy().into_owned(), + options, + } +} + +async fn host(root: &Path) -> PluginHost { + PluginHost::start(root, root) + .await + .expect("start Bun plugin host") +} + +#[tokio::test] +async fn hooks_chain_in_plugin_order_and_preserve_options() { + if !bun_available().await { + return; + } + let temp = tempfile::tempdir().unwrap(); + let first = write_plugin( + temp.path(), + "first.mjs", + "export default async ({ options }) => ({ 'test.chain': async (_input, output) => { output.steps.push(options.step); } });", + ) + .await; + let second = write_plugin( + temp.path(), + "second.mjs", + "export default async ({ options }) => ({ 'test.chain': async (_input, output) => { output.steps.push(options.step); } });", + ) + .await; + let mut host = host(temp.path()).await; + + let loaded = host + .load_plugins(&[ + spec(&first, json!({ "step": "first" })), + spec(&second, json!({ "step": "second" })), + ]) + .await + .expect("load plugins"); + let output = host + .invoke_hook("test.chain", Value::Null, json!({ "steps": [] })) + .await + .expect("invoke chained hook"); + + assert_eq!(loaded["loaded"].as_array().map(Vec::len), Some(2)); + assert_eq!(output, json!({ "steps": ["first", "second"] })); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn missing_hook_is_a_noop() { + if !bun_available().await { + return; + } + let temp = tempfile::tempdir().unwrap(); + let fixture = write_plugin(temp.path(), "noop.mjs", "export default async () => ({});").await; + let mut host = host(temp.path()).await; + host.load_plugins(&[spec(&fixture, Value::Null)]) + .await + .unwrap(); + + let output = host + .invoke_hook( + "missing.hook", + json!({ "ignored": true }), + json!({ "safe": true }), + ) + .await + .unwrap(); + + assert_eq!(output, json!({ "safe": true })); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn plugin_factory_and_hook_errors_cross_the_rpc_boundary() { + if !bun_available().await { + return; + } + let temp = tempfile::tempdir().unwrap(); + let factory_error = write_plugin( + temp.path(), + "factory-error.mjs", + "export default async () => { throw new Error('factory exploded'); };", + ) + .await; + let hook_error = write_plugin( + temp.path(), + "hook-error.mjs", + "export default async () => ({ 'test.fail': async () => { throw new Error('hook exploded'); } });", + ) + .await; + let mut host = host(temp.path()).await; + + let error = host + .load_plugins(&[spec(&factory_error, Value::Null)]) + .await + .unwrap_err(); + assert!(error.to_string().contains("factory exploded")); + + host.load_plugins(&[spec(&hook_error, Value::Null)]) + .await + .unwrap(); + let error = host + .invoke_hook("test.fail", Value::Null, Value::Null) + .await + .unwrap_err(); + assert!(error.to_string().contains("hook exploded")); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn hook_timeout_is_bounded_and_shutdown_kills_the_host() { + if !bun_available().await { + return; + } + let temp = tempfile::tempdir().unwrap(); + let fixture = write_plugin( + temp.path(), + "hang.mjs", + "export default async () => ({ 'test.hang': async () => await new Promise(() => {}) });", + ) + .await; + let mut host = host(temp.path()).await; + host.load_plugins(&[spec(&fixture, Value::Null)]) + .await + .unwrap(); + host.set_request_timeout(Duration::from_millis(100)); + let pid = host.process_id().expect("plugin host pid"); + + let error = host + .invoke_hook("test.hang", Value::Null, Value::Null) + .await + .unwrap_err(); + assert!(error.to_string().contains("timed out")); + + let status = Command::new("kill") + .args(["-0", &pid.to_string()]) + .stderr(Stdio::null()) + .status() + .await + .expect("check plugin host process"); + assert!( + !status.success(), + "plugin host process {pid} survived the request timeout" + ); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn plugin_stdout_does_not_corrupt_rpc_and_process_exit_is_reported() { + if !bun_available().await { + return; + } + let temp = tempfile::tempdir().unwrap(); + let corrupt = write_plugin( + temp.path(), + "stdout.mjs", + "export default async () => { console.log('not-json'); return {}; };", + ) + .await; + let exit = write_plugin( + temp.path(), + "exit.mjs", + "export default async () => ({ 'test.exit': async () => process.exit(17) });", + ) + .await; + + let mut corrupt_host = host(temp.path()).await; + let loaded = corrupt_host + .load_plugins(&[spec(&corrupt, Value::Null)]) + .await + .expect("plugin stdout must be isolated from RPC stdout"); + assert_eq!(loaded["loaded"].as_array().map(Vec::len), Some(1)); + corrupt_host.shutdown().await.unwrap(); + + let mut exit_host = host(temp.path()).await; + exit_host + .load_plugins(&[spec(&exit, Value::Null)]) + .await + .unwrap(); + let error = exit_host + .invoke_hook("test.exit", Value::Null, Value::Null) + .await + .unwrap_err(); + assert!(error.to_string().contains("exited")); + exit_host.shutdown().await.unwrap(); +} From 8f83288049575a0221a69789223c314ac03aa78e Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 13 Aug 2026 16:08:56 +0700 Subject: [PATCH 2/3] fix(plugin): unblock compatibility tests --- src/config/configuration.rs | 16 ++++++++++------ src/ui/components/input.rs | 11 +++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 62d8e1f..6eca43a 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -1129,7 +1129,6 @@ fn opencode_ignored_keys() -> BTreeSet<&'static str> { "share", "tui", "server", - "plugin", "tool", "custom tools", "custom_tools", @@ -2747,7 +2746,7 @@ mod tests { } #[test] - fn opencode_plugin_key_survives_filtering_and_is_implemented() { + fn opencode_plugin_key_is_parsed_and_not_reported_unimplemented() { let filtered = filter_top_level( json!({ "plugin": ["./plugin.mjs"], @@ -2756,7 +2755,11 @@ mod tests { SourceKind::OpenCode, ); - assert_eq!(filtered, json!({ "plugin": ["./plugin.mjs"] })); + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config(&filtered, &mut diagnostics); + + assert_eq!(config.plugins.len(), 1); + assert_eq!(config.plugins[0].source, "./plugin.mjs"); assert!(collect_unimplemented_keys(&filtered).is_empty()); } @@ -2790,9 +2793,10 @@ mod tests { let mut diagnostics = ConfigDiagnostics::default(); discover_opencode_inventory(xdg.path(), project.path(), &mut inventory, &mut diagnostics); - assert_eq!(inventory.plugin_files.len(), 2); - assert!(inventory.plugin_files[0].ends_with("a.js")); - assert!(inventory.plugin_files[1].ends_with("z.mjs")); + let mut expected = vec![plural.join("a.js"), singular.join("z.mjs")]; + expected.sort(); + + assert_eq!(inventory.plugin_files, expected); } #[test] diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 8cb4ed8..223a971 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -2523,6 +2523,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -2545,6 +2546,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -2576,6 +2578,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -2621,6 +2624,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -2784,6 +2788,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -2817,6 +2822,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -2845,6 +2851,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -3040,6 +3047,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -3079,6 +3087,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -3104,6 +3113,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); @@ -3294,6 +3304,7 @@ mod tests { "provider", None, &colors, + false, ); }) .unwrap(); From bdcfd39337c9d474c893f19b1688798ae8bbd681 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Thu, 13 Aug 2026 16:32:53 +0700 Subject: [PATCH 3/3] test: stabilize full suite --- src/command/handlers.rs | 5 +++-- src/model/discovery.rs | 21 ++++++++++-------- src/model/extensions/ollama.rs | 2 +- src/tools/mutation.rs | 17 +++++---------- src/ui/components/chat.rs | 17 +++++++-------- src/views/question_dialog.rs | 40 +++++++++++----------------------- 6 files changed, 43 insertions(+), 59 deletions(-) diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 98b304b..c4c5f8e 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -1243,8 +1243,8 @@ mod tests { let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { name: "models".to_string(), - args: vec![], - raw: "/models".to_string(), + args: vec!["ollama".to_string()], + raw: "/models ollama".to_string(), prefs_data: None, active_model_id: None, }; @@ -1347,6 +1347,7 @@ mod tests { #[tokio::test] async fn test_handle_refreshmodels() { + let _guard = crate::model::extensions::ollama::test_cache_lock(); let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { name: "refreshmodels".to_string(), diff --git a/src/model/discovery.rs b/src/model/discovery.rs index d164a4f..94579f8 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -1374,10 +1374,10 @@ mod tests { let mut models = HashMap::new(); models.insert( - "big-pickle".to_string(), + "stable-model".to_string(), serde_json::from_value(serde_json::json!({ - "id": "big-pickle", - "name": "Big Pickle", + "id": "stable-model", + "name": "Stable Model", "release_date": "2025-10-17", "last_updated": "2025-10-17", "attachment": false, @@ -1409,13 +1409,13 @@ mod tests { let mut providers = HashMap::new(); providers.insert( - "opencode".to_string(), + "fixture-provider".to_string(), Provider { - id: "opencode".to_string(), - name: "OpenCode Zen".to_string(), - api: "https://opencode.ai/zen/v1".to_string(), + id: "fixture-provider".to_string(), + name: "Fixture Provider".to_string(), + api: "https://example.invalid/v1".to_string(), doc: String::new(), - env: vec!["OPENCODE_API_KEY".to_string()], + env: Vec::new(), npm: "@ai-sdk/openai-compatible".to_string(), models, }, @@ -1430,7 +1430,10 @@ mod tests { .map(|model| model.id) .collect(); - assert!(model_ids.contains(&"big-pickle".to_string())); + assert!( + model_ids.contains(&"stable-model".to_string()), + "expected stable model in {model_ids:?}" + ); assert!(!model_ids.contains(&"kimi-k2.5-free".to_string())); let _ = fs::remove_file(cache_path); diff --git a/src/model/extensions/ollama.rs b/src/model/extensions/ollama.rs index f48be64..ba4bd57 100644 --- a/src/model/extensions/ollama.rs +++ b/src/model/extensions/ollama.rs @@ -284,7 +284,7 @@ pub fn test_cache_lock() -> std::sync::MutexGuard<'static, ()> { TEST_CACHE_LOCK .get_or_init(|| Mutex::new(())) .lock() - .expect("ollama test cache lock") + .unwrap_or_else(|poisoned| poisoned.into_inner()) } #[cfg(test)] diff --git a/src/tools/mutation.rs b/src/tools/mutation.rs index bb93aa8..1e45425 100644 --- a/src/tools/mutation.rs +++ b/src/tools/mutation.rs @@ -332,18 +332,13 @@ mod tests { #[test] fn write_supports_relative_file_in_current_directory() { - let dir = tempfile::tempdir().unwrap(); - let old = std::env::current_dir().unwrap(); - std::env::set_current_dir(dir.path()).unwrap(); - - let result = FileMutation::write("plain.txt", b"content"); - std::env::set_current_dir(old).unwrap(); + let file_name = format!("crabcode-mutation-test-{}.txt", std::process::id()); + let path = PathBuf::from(&file_name); + let _ = fs::remove_file(&path); - result.unwrap(); - assert_eq!( - fs::read_to_string(dir.path().join("plain.txt")).unwrap(), - "content" - ); + FileMutation::write(&path, b"content").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "content"); + fs::remove_file(path).unwrap(); } #[test] diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index dc12370..e1580c0 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -7222,9 +7222,6 @@ mod tests { .map(line_text) .collect::>(); - assert!(collapsed - .iter() - .any(|line| line.contains("Thinking collapsed"))); assert!(!collapsed .iter() .any(|line| line.contains("Private reasoning"))); @@ -7996,11 +7993,12 @@ mod tests { #[test] fn test_edit_tool_renders_codex_style_diff_summary() { let chat = Chat::new(); + let file_path = "/Users/carlo/Desktop/Projects/crabcode/README.md"; let content = serde_json::json!({ "name": "edit", "status": "ok", "args": { - "file_path": "/Users/carlo/Desktop/Projects/crabcode/README.md", + "file_path": file_path, "old_string": "alpha\nbeta\nomega", "new_string": "alpha\nbravo\nomega", }, @@ -8013,15 +8011,16 @@ mod tests { let lines = chat.format_tool_row(&msg, 80, &colors, false); let rendered = lines.iter().map(trimmed_line_text).collect::>(); + let expected_title = format!("⬢ Edited {} (+1 -1)", display_path(file_path, false)); assert_eq!( rendered, vec![ - "⬢ Edited README.md (+1 -1)", - " 3 alpha", - " 4 -beta", - " 4 +bravo", - " 5 omega", + expected_title, + " 3 alpha".to_string(), + " 4 -beta".to_string(), + " 4 +bravo".to_string(), + " 5 omega".to_string(), ] ); } diff --git a/src/views/question_dialog.rs b/src/views/question_dialog.rs index 63994f8..abfaf5e 100644 --- a/src/views/question_dialog.rs +++ b/src/views/question_dialog.rs @@ -1937,6 +1937,10 @@ mod tests { KeyEvent, KeyEventKind, KeyEventState, MouseButton, MouseEvent, MouseEventKind, }; + fn test_theme() -> ThemeColors { + crate::theme::Theme::load_builtin_default().get_colors(true) + } + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { KeyEvent { code, @@ -2066,9 +2070,7 @@ mod tests { assert_eq!(request.current_index, 1); assert_eq!(request.response(), json!([[]])); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let confirm_text = confirm_body_lines(request, &colors) .iter() .flat_map(|line| line.spans.iter()) @@ -2565,9 +2567,7 @@ mod tests { ]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let line = question_tabs_line(&request, 0, &colors); let text: String = line .spans @@ -2593,9 +2593,7 @@ mod tests { }]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let body = question_body_lines( &request.questions[0], &request.answers[0], @@ -2639,9 +2637,7 @@ mod tests { ]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let body = question_body_lines( &request.questions[1], &request.answers[1], @@ -2680,9 +2676,7 @@ mod tests { for ch in "this is a long custom answer that should not be truncated".chars() { request.insert_char(ch); } - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let body = confirm_body_lines(&request, &colors); let text = body .iter() @@ -2707,9 +2701,7 @@ mod tests { }]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let line = question_tabs_line(&request, 0, &colors); assert_eq!(line.spans[0].content.as_ref(), " Question 1 "); @@ -2734,9 +2726,7 @@ mod tests { ]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let line = footer_line(&request, &colors); let text: String = line .spans @@ -2764,9 +2754,7 @@ mod tests { assert!(request.questions[0].multiple); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let footer = footer_line(&request, &colors); let footer_text: String = footer .spans @@ -3081,9 +3069,7 @@ mod tests { label: "A".to_string(), description: String::new(), }; - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = test_theme(); let line = option_line(&option, true, true, false, &colors); let text: String = line .spans