返回 CodeWhale
request_plugin_install.rs
根目录 / crates / tui / src / tools / request_plugin_install.rs
1 //! Model-callable plugin review request. Never installs, trusts, or enables.
2 //!
3 //! 0.10.1 plugin offering policy: the tool is registered only in the
4 //! interactive TUI with contextual tips on (it returns a TUI slash command),
5 //! and a session gets one review request. A second call errors.
6
7 use std::collections::HashSet;
8 use std::sync::{LazyLock, Mutex};
9
10 use async_trait::async_trait;
11 use serde_json::{Value, json};
12
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
15 };
16 use crate::plugins::recommend::{load_marketplace_candidates, lookup_reviewable_plugin};
17
18 pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
19
20 pub struct RequestPluginInstallTool;
21
22 /// Sessions (by `ToolContext::state_namespace`, the session id) that already
23 /// surfaced a review request. The registry is rebuilt every turn, so the
24 /// once-per-session budget cannot live on the tool value.
25 static REQUESTED_SESSIONS: LazyLock<Mutex<HashSet<String>>> =
26 LazyLock::new(|| Mutex::new(HashSet::new()));
27
28 fn session_already_requested(namespace: &str) -> bool {
29 REQUESTED_SESSIONS
30 .lock()
31 .unwrap_or_else(std::sync::PoisonError::into_inner)
32 .contains(namespace)
33 }
34
35 fn record_session_request(namespace: &str) {
36 REQUESTED_SESSIONS
37 .lock()
38 .unwrap_or_else(std::sync::PoisonError::into_inner)
39 .insert(namespace.to_string());
40 }
41
42 #[async_trait]
43 impl ToolSpec for RequestPluginInstallTool {
44 fn name(&self) -> &'static str {
45 REQUEST_PLUGIN_INSTALL_TOOL_NAME
46 }
47
48 fn description(&self) -> &'static str {
49 "Ask the human to review a plugin the current task clearly needs \
50 (installed-but-idle, or in a catalog they added). Never to advertise. \
51 Once per session; a second call fails. Installs, trusts, and enables \
52 nothing. Pass `name` and a short `reason`."
53 }
54
55 fn input_schema(&self) -> Value {
56 json!({
57 "type": "object",
58 "properties": {
59 "name": {
60 "type": "string",
61 "description": "Plugin name as shown by /plugin list or /plugin suggest."
62 },
63 "reason": {
64 "type": "string",
65 "description": "Short reason this plugin fits the current task."
66 }
67 },
68 "required": ["name", "reason"]
69 })
70 }
71
72 fn capabilities(&self) -> Vec<ToolCapability> {
73 vec![ToolCapability::ReadOnly]
74 }
75
76 fn approval_requirement(&self) -> ApprovalRequirement {
77 ApprovalRequirement::Auto
78 }
79
80 async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolResult, ToolError> {
81 let name = required_str(&input, "name")?.trim();
82 let reason = required_str(&input, "reason")?.trim();
83 if name.is_empty() {
84 return Err(ToolError::invalid_input(
85 "request_plugin_install: name must not be empty",
86 ));
87 }
88 if reason.is_empty() {
89 return Err(ToolError::invalid_input(
90 "request_plugin_install: reason must not be empty",
91 ));
92 }
93 if session_already_requested(&ctx.state_namespace) {
94 return Err(ToolError::not_available(
95 "request_plugin_install: already used this session; a plugin review may be requested once per session",
96 ));
97 }
98 let Some(registry) = ctx.plugin_registry.as_ref() else {
99 return Err(ToolError::not_available(
100 "request_plugin_install: plugin registry is not available",
101 ));
102 };
103 let marketplace = load_marketplace_candidates(registry.state_path());
104 let Some(matched) = lookup_reviewable_plugin(name, registry, &marketplace) else {
105 return Err(ToolError::invalid_input(format!(
106 "request_plugin_install: unknown plugin `{name}`"
107 )));
108 };
109 record_session_request(&ctx.state_namespace);
110 let command = matched.command();
111 let payload = json!({
112 "completed": false,
113 "installed": false,
114 "plugin": matched.name,
115 "plugin_id": matched.id,
116 "command": command,
117 "reason": reason,
118 });
119 let mut result = ToolResult::success(format!(
120 "Review requested for {}. Run `{command}` — nothing was installed, trusted, or enabled. Reason: {reason}",
121 matched.name
122 ));
123 result.metadata = Some(payload);
124 Ok(result)
125 }
126 }
127
128 #[cfg(test)]
129 mod tests {
130 use super::*;
131 use crate::test_support::{EnvVarGuard, lock_test_env};
132 use std::fs;
133 use std::sync::Arc;
134 use tempfile::TempDir;
135
136 fn write_keyword_bundle(root: &std::path::Path, name: &str) {
137 let bundle = root.join(".codewhale/plugins").join(name);
138 fs::create_dir_all(&bundle).unwrap();
139 fs::write(
140 bundle.join("plugin.toml"),
141 format!(
142 "schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"{name}\"\nkeywords = [\"{name}\"]\n"
143 ),
144 )
145 .unwrap();
146 }
147
148 #[tokio::test]
149 async fn request_plugin_install_does_not_mutate_disk() {
150 let _lock = lock_test_env();
151 let root = TempDir::new().unwrap();
152 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
153 write_keyword_bundle(root.path(), "supabase");
154 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
155 .registry_for_workspace(root.path());
156 let bundle = root.path().join(".codewhale/plugins/supabase/plugin.toml");
157 let before = fs::read(&bundle).unwrap();
158 let ctx = ToolContext::new(root.path())
159 .with_plugin_registry(Arc::clone(&registry))
160 .with_state_namespace("request-plugin-install-disk-test");
161
162 let err = RequestPluginInstallTool
163 .execute(
164 json!({"name": "not-a-real-plugin", "reason": "guess"}),
165 &ctx,
166 )
167 .await
168 .unwrap_err();
169 assert!(err.to_string().to_lowercase().contains("unknown"), "{err}");
170 assert_eq!(fs::read(&bundle).unwrap(), before);
171
172 let result = RequestPluginInstallTool
173 .execute(
174 json!({"name": "supabase", "reason": "needs hosted auth"}),
175 &ctx,
176 )
177 .await
178 .expect("known idle plugin");
179 assert!(result.success);
180 assert!(result.content.contains("/plugin trust supabase"));
181 let meta = result.metadata.expect("metadata");
182 assert_eq!(meta["installed"], json!(false));
183 assert_eq!(meta["command"], json!("/plugin trust supabase"));
184 assert_eq!(fs::read(&bundle).unwrap(), before);
185 }
186
187 /// Policy rule 4: one review request per session; a second call errors,
188 /// and another session keeps its own budget.
189 #[tokio::test]
190 async fn request_plugin_install_is_once_per_session() {
191 let _lock = lock_test_env();
192 let root = TempDir::new().unwrap();
193 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
194 write_keyword_bundle(root.path(), "supabase");
195 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
196 .registry_for_workspace(root.path());
197 let ctx = ToolContext::new(root.path())
198 .with_plugin_registry(Arc::clone(&registry))
199 .with_state_namespace("request-plugin-install-once-a");
200 let input = json!({"name": "supabase", "reason": "needs hosted auth"});
201
202 assert!(
203 RequestPluginInstallTool
204 .execute(input.clone(), &ctx)
205 .await
206 .is_ok()
207 );
208 let err = RequestPluginInstallTool
209 .execute(input.clone(), &ctx)
210 .await
211 .unwrap_err();
212 assert!(err.to_string().contains("once per session"), "{err}");
213
214 let other = ToolContext::new(root.path())
215 .with_plugin_registry(Arc::clone(&registry))
216 .with_state_namespace("request-plugin-install-once-b");
217 assert!(
218 RequestPluginInstallTool
219 .execute(input, &other)
220 .await
221 .is_ok()
222 );
223 }
224 }
225
225 lines RUST