返回 CodeWhale
builtin.rs
根目录 / crates / tui / src / plugins / builtin.rs
1 //! First-party plugin bundles that ship inside the binary.
2 //!
3 //! [`super::discovery::DiscoveryConfig::builtin_plugin_dirs`] and
4 //! [`super::types::PluginScope::Builtin`] have existed since plugin discovery
5 //! landed, with no producer: every construction site passed an empty list, so
6 //! the in-repo `crates/tui/plugins/computer-use` bundle reached nobody who had
7 //! not cloned the repository. This module is that producer. It is not a second install
8 //! path — installed bundles still arrive through
9 //! [`super::install`], and discovery, trust, and enablement are unchanged.
10 //!
11 //! The bundle is embedded with `include_bytes!` (the same way locale packs and
12 //! the mobile client are embedded) and written under
13 //! `$CODEWHALE_HOME/builtin-plugins` on first run, so one binary carries it to
14 //! every distribution channel — npm, tarball, `cargo install`, brew — without
15 //! any of them learning about plugin files.
16 //! macOS builds also carry the native helper built from the vendored sources,
17 //! so operating the computer never requires a compiler or a separate app
18 //! installation. The helper targets macOS 13+; OS permissions are still user
19 //! controlled, and the MCP server uses the host's Node.js runtime.
20 //!
21 //! Two properties this must not lose:
22 //!
23 //! * **Materializing is not enabling.** A freshly written builtin bundle is
24 //! `NeverReviewed` and disabled like any other, because
25 //! [`super::registry`] enables only what the user's `state.json` says.
26 //! Computer use can drive the desktop; it waits to be reviewed.
27 //! * **Each build keeps its own complete tree.** A unique private stage is
28 //! published once under its embedded-content digest. Discovery receives
29 //! only that snapshot root, so another binary cannot replace a live bundle.
30 //! Old bundles are not migrated. Their review is: when a new build's
31 //! bundle has the same capability hash, the prior review and enablement
32 //! carry to its new id; otherwise it reports `capabilities-changed`
33 //! ([`super::registry::PluginRegistry::carry_forward_builtin_trust`]).
34
35 use std::collections::{BTreeMap, BTreeSet};
36 use std::fs;
37 use std::io::{self, Read};
38 use std::path::{Component, Path, PathBuf};
39
40 use sha2::{Digest, Sha256};
41
42 use super::path_identity::metadata_is_link_or_reparse;
43
44 /// Directory under the Codewhale home containing built-in bundles.
45 /// Deliberately *not* inside `plugins/`: that root is scanned as
46 /// [`super::types::PluginScope::User`], and a bundle found twice is a
47 /// duplicate-root diagnostic rather than a plugin.
48 const BUILTIN_DIR_NAME: &str = "builtin-plugins";
49 const SNAPSHOTS_DIR_NAME: &str = "snapshots";
50
51 /// Publication marker, outside the plugin itself. It is checked along with
52 /// every embedded byte and directory entry, never used as proof by itself.
53 const STAMP_NAME: &str = ".stamp";
54
55 const COMPUTER_USE: &str = "computer-use";
56
57 macro_rules! bundle_file {
58 ($relative:literal) => {
59 (
60 $relative,
61 include_bytes!(concat!("../../plugins/computer-use/", $relative)),
62 )
63 };
64 }
65
66 /// The runtime tree of `crates/tui/plugins/computer-use`, relative path → contents.
67 ///
68 /// Development-only files (`tests/`, `scripts/smoke.mjs`,
69 /// `README.md`) are deliberately absent. The package manifest, lockfile and
70 /// Docker context are runtime inputs for creating an isolated desktop.
71 const COMPUTER_USE_FILES: &[(&str, &[u8])] = &[
72 bundle_file!("LICENSE"),
73 bundle_file!("package.json"),
74 bundle_file!("package-lock.json"),
75 bundle_file!(".dockerignore"),
76 bundle_file!("docker/Dockerfile"),
77 bundle_file!("docker/entrypoint.sh"),
78 bundle_file!("docker/agent-exec.sh"),
79 bundle_file!("plugin.json"),
80 bundle_file!("mcp.json"),
81 bundle_file!("commands/computer.md"),
82 bundle_file!("skills/computer-use/SKILL.md"),
83 bundle_file!("skills/computer-use/references/quick-reference.md"),
84 bundle_file!("skills/computer-use/references/refusal-codes.md"),
85 bundle_file!("skills/recording/SKILL.md"),
86 bundle_file!("agent.mjs"),
87 bundle_file!("app/daemon.mjs"),
88 bundle_file!("app/background-check.mjs"),
89 bundle_file!("app/install-macos.mjs"),
90 bundle_file!("app/updates.mjs"),
91 bundle_file!("mcp/server.mjs"),
92 bundle_file!("mcp/turn-hold.mjs"),
93 bundle_file!("src/app-handler.mjs"),
94 bundle_file!("src/app-script-policy.mjs"),
95 bundle_file!("src/app-socket.mjs"),
96 bundle_file!("src/browser-cdp.mjs"),
97 bundle_file!("src/consent.mjs"),
98 bundle_file!("src/spawn.mjs"),
99 bundle_file!("src/exec.mjs"),
100 bundle_file!("src/lease.mjs"),
101 bundle_file!("src/png-size.mjs"),
102 bundle_file!("src/recordings.mjs"),
103 bundle_file!("src/registry.mjs"),
104 bundle_file!("src/remote-runtime.mjs"),
105 bundle_file!("src/sprite-task.mjs"),
106 bundle_file!("src/ssh-args.mjs"),
107 bundle_file!("src/tools.mjs"),
108 bundle_file!("src/trajectory.mjs"),
109 bundle_file!("src/transport.mjs"),
110 bundle_file!("src/backends/darwin.mjs"),
111 bundle_file!("src/backends/darwin-accessibility.m"),
112 bundle_file!("src/backends/darwin-recording.h"),
113 bundle_file!("src/backends/darwin-ocr.h"),
114 bundle_file!("src/backends/harmonyos.mjs"),
115 bundle_file!("src/backends/linux.mjs"),
116 bundle_file!("src/backends/win32.mjs"),
117 #[cfg(target_os = "macos")]
118 (
119 "bin/darwin/accessibility",
120 include_bytes!(concat!(env!("OUT_DIR"), "/computer-use-accessibility")),
121 ),
122 ];
123
124 /// Digest of one bundle's entire contents, including its file names, so a
125 /// renamed or removed file is as much a change as an edited one.
126 fn digest(files: &[(&str, &[u8])]) -> String {
127 let mut hasher = Sha256::new();
128 for (relative, contents) in files {
129 hasher.update((relative.len() as u64).to_le_bytes());
130 hasher.update(relative.as_bytes());
131 hasher.update((contents.len() as u64).to_le_bytes());
132 hasher.update(contents);
133 }
134 super::manifest::hex_digest(hasher.finalize())
135 }
136
137 /// Discovery roots holding the built-in bundles, writing them out if what is
138 /// on disk is absent. Existing snapshots must exactly match this build. An
139 /// empty list is the honest answer when materialization fails: discovery finds no
140 /// built-in plugin, rather than a broken one.
141 ///
142 /// Deliberately not memoized. The result is derived from `$CODEWHALE_HOME`,
143 /// and caching a home-derived path process-wide would pin whichever caller ran
144 /// first — which is wrong the moment the home differs between callers, as it
145 /// does across tests in one process. Reuse verifies the full embedded tree;
146 /// a matching stamp cannot bless changed bytes or a redirected path.
147 #[must_use]
148 pub fn materialized_dirs() -> Vec<PathBuf> {
149 match materialize() {
150 Ok(Some(root)) => vec![root],
151 Ok(None) => Vec::new(),
152 Err(error) => {
153 tracing::warn!(
154 target: "plugins",
155 %error,
156 "built-in plugin bundles could not be written; they will not be discovered"
157 );
158 Vec::new()
159 }
160 }
161 }
162
163 /// `Ok(None)` when there is no Codewhale home to write into yet.
164 ///
165 /// Startup runs this for *every* command, `doctor` and `setup status`
166 /// included, and those are contractually read-only: they must not bring a
167 /// home directory into existence as a side effect of inventorying plugins
168 /// (`crates/tui/tests/integration/diagnostic_read_only.rs`). Materializing
169 /// into an existing home only keeps that promise, and costs nothing in
170 /// practice — the home exists from the moment Codewhale is configured or run.
171 fn materialize() -> io::Result<Option<PathBuf>> {
172 let home = materialization_home()?;
173 materialize_at_home(&home)
174 }
175
176 /// Startup materializes into any existing home, so an unsealed test would
177 /// write the developer's real `~/.codewhale/builtin-plugins`; it gets a
178 /// private home instead.
179 #[cfg(test)]
180 fn materialization_home() -> io::Result<PathBuf> {
181 match crate::test_support::unsealed_state_dir(".") {
182 Some(home) => Ok(home),
183 None => codewhale_config::codewhale_home().map_err(io::Error::other),
184 }
185 }
186
187 #[cfg(not(test))]
188 fn materialization_home() -> io::Result<PathBuf> {
189 codewhale_config::codewhale_home().map_err(io::Error::other)
190 }
191
192 fn materialize_at_home(home: &Path) -> io::Result<Option<PathBuf>> {
193 // The user-selected home may be an alias (the shared home resolver retains
194 // it verbatim). Resolve it once before appending any Codewhale-owned paths,
195 // so retargeting the alias cannot redirect this snapshot's discovery root.
196 // Descendant links must still be rejected, never canonicalized away.
197 let home = match home.canonicalize() {
198 Ok(home) => home,
199 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
200 Err(error) => return Err(error),
201 };
202 match fs::symlink_metadata(&home) {
203 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
204 Err(error) => return Err(error),
205 Ok(metadata) if !metadata.is_dir() || metadata_is_link_or_reparse(&metadata) => {
206 return Err(invalid_bundle("Codewhale home must be a real directory"));
207 }
208 Ok(_) => {}
209 }
210 let root = home.join(BUILTIN_DIR_NAME);
211 reject_symlink(&root)?;
212 match fs::create_dir(&root) {
213 Ok(()) => {}
214 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
215 Err(error) => return Err(error),
216 }
217 reject_symlink(&root)?;
218 // Keep the old mutable root intact for older binaries. New snapshots live
219 // in an owner-only namespace that those binaries neither scan nor replace.
220 let snapshots = root.join(SNAPSHOTS_DIR_NAME);
221 reject_symlink(&snapshots)?;
222 super::registry::ensure_private_plugin_state_directory(&snapshots).map_err(io::Error::other)?;
223 write_bundle(&snapshots, COMPUTER_USE, COMPUTER_USE_FILES).map(Some)
224 }
225
226 /// Return a discovery root containing exactly this build's bundle. Publication
227 /// never replaces an existing entry, including an empty or damaged directory.
228 /// Concurrent publishers of identical bytes converge after verifying the winner;
229 /// different builds retain different source paths and therefore trust identities.
230 fn write_bundle(root: &Path, name: &str, files: &[(&str, &[u8])]) -> io::Result<PathBuf> {
231 reject_symlink(root)?;
232 if !super::agent_plugin::is_standard_plugin_name(name) || files.is_empty() {
233 return Err(invalid_bundle(
234 "invalid embedded plugin name or empty bundle",
235 ));
236 }
237 let want = digest(files);
238 let destination = root.join(format!("{name}-{want}"));
239 let mut expected = BTreeMap::from([(PathBuf::from(STAMP_NAME), want.as_bytes())]);
240 for (relative, contents) in files {
241 let path = Path::new(relative);
242 if path.as_os_str().is_empty()
243 || path
244 .components()
245 .any(|part| !matches!(part, Component::Normal(_)))
246 || expected
247 .insert(Path::new(name).join(path), *contents)
248 .is_some()
249 {
250 return Err(invalid_bundle("invalid or duplicate embedded bundle path"));
251 }
252 }
253 if snapshot_exists(&destination)? {
254 verify_snapshot(&destination, &expected)?;
255 return Ok(destination);
256 }
257
258 let staging = tempfile::Builder::new()
259 .prefix(&format!(".staging-{name}-"))
260 .tempdir_in(root)?;
261 for (relative, contents) in files {
262 let path = staging.path().join(name).join(relative);
263 if let Some(parent) = path.parent() {
264 fs::create_dir_all(parent)?;
265 }
266 fs::write(&path, contents)?;
267 #[cfg(unix)]
268 if *relative == "bin/darwin/accessibility" {
269 use std::os::unix::fs::PermissionsExt as _;
270 fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
271 }
272 }
273 fs::write(staging.path().join(STAMP_NAME), &want)?;
274 verify_snapshot(staging.path(), &expected)?;
275 match publish_snapshot(staging.path(), &destination) {
276 Ok(()) => {
277 // Only this operation's private temporary directory is ever cleaned
278 // up. Its old path no longer belongs to us after publication.
279 let _ = staging.keep();
280 }
281 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
282 // A competing publisher won. Do not remove its directory or assume
283 // it is complete merely because its name/stamp matches our digest.
284 }
285 Err(error) => return Err(error),
286 }
287 verify_snapshot(&destination, &expected)?;
288 Ok(destination)
289 }
290
291 fn snapshot_exists(path: &Path) -> io::Result<bool> {
292 match fs::symlink_metadata(path) {
293 Ok(_) => Ok(true),
294 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
295 Err(error) => Err(error),
296 }
297 }
298
299 fn invalid_bundle(message: &str) -> io::Error {
300 io::Error::new(io::ErrorKind::InvalidData, message)
301 }
302
303 /// Verify only the bounded embedded inventory. An unexpected file, directory,
304 /// link, executable bit, missing byte or forged stamp rejects the whole snapshot.
305 fn verify_snapshot(root: &Path, files: &BTreeMap<PathBuf, &[u8]>) -> io::Result<()> {
306 let mut directories = BTreeSet::from([PathBuf::new()]);
307 for relative in files.keys() {
308 directories.extend(relative.ancestors().skip(1).map(Path::to_path_buf));
309 }
310 for relative in &directories {
311 // Joining the empty root marker adds a trailing separator on Unix;
312 // lstat("link/") follows that link before inspecting its target.
313 let directory = if relative.as_os_str().is_empty() {
314 root.to_path_buf()
315 } else {
316 root.join(relative)
317 };
318 let metadata = fs::symlink_metadata(&directory)?;
319 if !metadata.is_dir() || metadata_is_link_or_reparse(&metadata) {
320 return Err(invalid_bundle(
321 "built-in snapshot directory is not a real directory",
322 ));
323 }
324 for entry in fs::read_dir(&directory)? {
325 let entry = entry?;
326 let child = relative.join(entry.file_name());
327 if !files.contains_key(&child) && !directories.contains(&child) {
328 return Err(invalid_bundle(
329 "built-in snapshot contains unexpected content",
330 ));
331 }
332 }
333 }
334 for (relative, contents) in files {
335 let path = root.join(relative);
336 let mut file = super::registry::open_existing_regular_file(&path, false)
337 .map_err(io::Error::other)?
338 .ok_or_else(|| invalid_bundle("built-in snapshot file is missing"))?;
339 let metadata = file.metadata()?;
340 if metadata.len() != contents.len() as u64 {
341 return Err(invalid_bundle("built-in snapshot content changed"));
342 }
343 #[cfg(unix)]
344 {
345 use std::os::unix::fs::PermissionsExt as _;
346 let executable = relative.ends_with("bin/darwin/accessibility");
347 if (metadata.permissions().mode() & 0o111 != 0) != executable {
348 return Err(invalid_bundle(
349 "built-in snapshot executable permissions changed",
350 ));
351 }
352 }
353 let mut buffer = vec![0; 64 * 1024];
354 for chunk in contents.chunks(buffer.len()) {
355 file.read_exact(&mut buffer[..chunk.len()])?;
356 if &buffer[..chunk.len()] != chunk {
357 return Err(invalid_bundle("built-in snapshot content changed"));
358 }
359 }
360 if file.read(&mut buffer[..1])? != 0 {
361 return Err(invalid_bundle(
362 "built-in snapshot content changed during verification",
363 ));
364 }
365 }
366 Ok(())
367 }
368
369 /// Atomic no-replace directory publication. A check followed by ordinary Unix
370 /// rename is insufficient: rename is allowed to replace an existing empty dir.
371 #[cfg(any(target_os = "macos", target_os = "linux"))]
372 fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
373 use std::ffi::CString;
374 use std::os::unix::ffi::OsStrExt as _;
375
376 let source = CString::new(source.as_os_str().as_bytes())?;
377 let destination = CString::new(destination.as_os_str().as_bytes())?;
378 // SAFETY: the nul-terminated paths remain alive for the syscall. Exclusive
379 // rename never follows/replaces the destination entry, even if it is a link.
380 #[cfg(target_os = "macos")]
381 let result =
382 unsafe { libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL) };
383 #[cfg(target_os = "linux")]
384 let result = unsafe {
385 // Static musl may lack the libc wrapper; use the same kernel operation.
386 libc::syscall(
387 libc::SYS_renameat2,
388 libc::AT_FDCWD,
389 source.as_ptr(),
390 libc::AT_FDCWD,
391 destination.as_ptr(),
392 libc::RENAME_NOREPLACE,
393 )
394 };
395 if result == 0 {
396 Ok(())
397 } else {
398 Err(io::Error::last_os_error())
399 }
400 }
401
402 #[cfg(windows)]
403 fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
404 use std::os::windows::ffi::OsStrExt as _;
405 use windows::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW};
406 use windows::core::PCWSTR;
407
408 let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
409 let destination: Vec<u16> = destination
410 .as_os_str()
411 .encode_wide()
412 .chain(Some(0))
413 .collect();
414 // SAFETY: both paths are nul-terminated and live through the call. Omitting
415 // MOVEFILE_REPLACE_EXISTING preserves every existing destination entry.
416 unsafe {
417 MoveFileExW(
418 PCWSTR(source.as_ptr()),
419 PCWSTR(destination.as_ptr()),
420 MOVEFILE_WRITE_THROUGH,
421 )
422 }
423 .map_err(|_| io::Error::last_os_error())
424 }
425
426 #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
427 fn publish_snapshot(_source: &Path, _destination: &Path) -> io::Result<()> {
428 Err(io::Error::new(
429 io::ErrorKind::Unsupported,
430 "atomic built-in snapshot publication is unsupported on this platform",
431 ))
432 }
433
434 /// Refuse to write through a symbolic link or reparse point, the same rule
435 /// [`super::discovery`] applies when it scans a plugin root.
436 fn reject_symlink(path: &Path) -> io::Result<()> {
437 match fs::symlink_metadata(path) {
438 Ok(metadata) if metadata_is_link_or_reparse(&metadata) => Err(io::Error::new(
439 io::ErrorKind::InvalidInput,
440 format!(
441 "built-in plugin path may not be a symbolic link or reparse point: {}",
442 path.display()
443 ),
444 )),
445 Ok(_) => Ok(()),
446 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
447 Err(error) => Err(error),
448 }
449 }
450
451 #[cfg(test)]
452 #[path = "builtin_tests.rs"]
453 mod snapshot_tests;
454
455 #[cfg(test)]
456 mod tests {
457 use super::*;
458
459 use crate::plugins::types::{PluginScope, PluginTrustStatus};
460
461 /// The vendored tree and the embed list are two views of one bundle.
462 /// This pins them together so a refreshed bundle can never leave a
463 /// runtime file out of `COMPUTER_USE_FILES` — the materialized server
464 /// would crash at import the first time it needed the missing module —
465 /// and an embed entry can never outlive its file. Development-only
466 /// files stay out of the binary by the documented policy on
467 /// `COMPUTER_USE_FILES`.
468 #[test]
469 fn computer_use_embed_list_matches_the_vendored_runtime_tree() {
470 const DEV_ONLY: &[&str] = &["README.md", "scripts/smoke.mjs"];
471
472 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins/computer-use");
473 let mut on_disk: Vec<String> = Vec::new();
474 let mut stack = vec![root.clone()];
475 while let Some(dir) = stack.pop() {
476 for entry in fs::read_dir(&dir).unwrap() {
477 let entry = entry.unwrap();
478 let path = entry.path();
479 if path.is_dir() {
480 stack.push(path);
481 continue;
482 }
483 let relative = path
484 .strip_prefix(&root)
485 .unwrap()
486 .to_string_lossy()
487 .replace('\\', "/");
488 if relative.starts_with("tests/") || DEV_ONLY.contains(&relative.as_str()) {
489 continue;
490 }
491 on_disk.push(relative);
492 }
493 }
494 on_disk.sort();
495
496 let mut embedded: Vec<&str> = COMPUTER_USE_FILES
497 .iter()
498 .map(|(relative, _)| *relative)
499 // Built from the vendored native sources, never committed as an artifact.
500 .filter(|relative| *relative != "bin/darwin/accessibility")
501 .collect();
502 embedded.sort_unstable();
503
504 let expected: Vec<&str> = on_disk.iter().map(String::as_str).collect();
505 assert_eq!(
506 embedded, expected,
507 "COMPUTER_USE_FILES and crates/tui/plugins/computer-use disagree — sync the embed \
508 list with the vendored runtime tree"
509 );
510 }
511
512 #[test]
513 fn computer_use_is_discovered_but_never_auto_enabled() {
514 let _lock = crate::test_support::lock_test_env();
515 let tmp = tempfile::tempdir().unwrap();
516 let home = tmp.path().join("home");
517 let workspace = tmp.path().join("workspace");
518 fs::create_dir_all(&workspace).unwrap();
519 fs::create_dir_all(&home).unwrap();
520 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
521
522 let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
523 .registry_for_workspace(&workspace);
524 let plugin = registry
525 .get(COMPUTER_USE)
526 .expect("the built-in computer-use bundle must be discovered");
527
528 assert_eq!(plugin.scope, PluginScope::Builtin);
529 // Computer use can drive the desktop. Shipping it is not consenting to
530 // it: the user reviews and enables it like any other bundle.
531 assert!(!plugin.enabled);
532 assert_eq!(plugin.trust_status, PluginTrustStatus::NeverReviewed);
533 assert!(!home.join("plugins/state.json").exists(), "read-only");
534 }
535
536 /// A stock macOS install has neither a cloned plugin nor clang. The
537 /// reviewed runtime snapshot must carry an executable native helper.
538 #[cfg(target_os = "macos")]
539 #[test]
540 fn reviewed_computer_use_carries_a_runnable_native_helper() {
541 use std::os::unix::fs::PermissionsExt as _;
542
543 let _lock = crate::test_support::lock_test_env();
544 let tmp = tempfile::tempdir().unwrap();
545 let home = tmp.path().join("home");
546 let workspace = tmp.path().join("workspace");
547 fs::create_dir_all(&workspace).unwrap();
548 fs::create_dir_all(&home).unwrap();
549 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
550 let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
551 .registry_for_workspace(&workspace);
552 let registry = std::sync::Arc::get_mut(&mut registry).unwrap();
553 registry.trust(COMPUTER_USE).unwrap();
554 registry.enable(COMPUTER_USE).unwrap();
555 let plugin = registry.get(COMPUTER_USE).unwrap();
556 let staged = plugin.staged_root.as_ref().unwrap();
557 let helper = staged.join("bin/darwin/accessibility");
558 assert_eq!(
559 fs::metadata(&helper).unwrap().permissions().mode() & 0o777,
560 0o500
561 );
562 let result = std::process::Command::new(&helper)
563 .arg(r#"{"tool":"permissions","args":{}}"#)
564 .env("PATH", "")
565 .output()
566 .unwrap();
567 assert!(
568 result.status.success(),
569 "{}",
570 String::from_utf8_lossy(&result.stderr)
571 );
572 let reply: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
573 assert!(reply.get("trusted").is_some());
574 let capabilities = std::process::Command::new(&helper)
575 .arg(r#"{"tool":"input_capabilities","args":{}}"#)
576 .env("PATH", "")
577 .output()
578 .unwrap();
579 assert!(capabilities.status.success());
580 let capabilities: serde_json::Value = serde_json::from_slice(&capabilities.stdout).unwrap();
581 assert_eq!(capabilities["background_focus_guard"], 1);
582 // This refusal precedes app resolution and input; no desktop target
583 // or Accessibility grant is needed to qualify the embedded guard.
584 let refused = std::process::Command::new(&helper)
585 .arg(r#"{"tool":"bg_key","args":{"foreground_input":false}}"#)
586 .env("PATH", "")
587 .output()
588 .unwrap();
589 assert!(!refused.status.success());
590 assert!(String::from_utf8_lossy(&refused.stderr).contains("background_focus_required"));
591 }
592
593 #[test]
594 fn build_snapshots_preserve_live_authority_and_require_independent_review() {
595 use crate::plugins::discovery::{DiscoveryConfig, discover_with_config};
596 use crate::plugins::registry::verify_plugin_authority;
597
598 let temp = tempfile::tempdir().unwrap();
599 let cache = temp.path().join("cache");
600 let workspace = temp.path().join("workspace");
601 fs::create_dir(&cache).unwrap();
602 fs::create_dir(&workspace).unwrap();
603 let first: &[(&str, &[u8])] = &[
604 ("plugin.json", br#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"fixture","version":"1.0.0"}"#),
605 ("body.txt", b"first bundle"),
606 ];
607 let second: &[(&str, &[u8])] = &[
608 ("plugin.json", br#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"fixture","version":"1.0.0"}"#),
609 ("body.txt", b"other bundle"),
610 ];
611 let mut config = DiscoveryConfig {
612 workspace: workspace.clone(),
613 user_plugins_dir: temp.path().join("plugins"),
614 workspace_plugins_dir: workspace.join(".codewhale/plugins"),
615 builtin_plugin_dirs: vec![cache.clone()],
616 state_path: temp.path().join("plugins/state.json"),
617 };
618 // An older binary's source and path-bound receipt survive the layout
619 // transition. Neither is an authority for the new physical source.
620 let legacy = cache.join("fixture");
621 fs::create_dir(&legacy).unwrap();
622 for (path, contents) in first {
623 fs::write(legacy.join(path), contents).unwrap();
624 }
625 let mut old = discover_with_config(&config);
626 old.trust("fixture").unwrap();
627 old.enable("fixture").unwrap();
628 let old_id = old.get("fixture").unwrap().id.clone();
629 let old_authority = old.authority_for("fixture").unwrap();
630 let old_state = fs::read(&config.state_path).unwrap();
631
632 let first_root = write_bundle(&cache, "fixture", first).unwrap();
633 config.builtin_plugin_dirs = vec![first_root.clone()];
634 let mut current = discover_with_config(&config);
635 let plugin = current.get("fixture").unwrap();
636 assert_eq!(plugin.scope, PluginScope::Builtin);
637 assert_ne!(plugin.id, old_id);
638 assert_eq!(plugin.trust_status, PluginTrustStatus::NeverReviewed);
639 assert!(!plugin.enabled);
640 assert_eq!(fs::read(&config.state_path).unwrap(), old_state);
641 verify_plugin_authority(&old_authority).unwrap();
642
643 current.trust("fixture").unwrap();
644 current.enable("fixture").unwrap();
645 let first_id = current.get("fixture").unwrap().id.clone();
646 let first_authority = current.authority_for("fixture").unwrap();
647 let first_catalog = current.live_catalog_stamp();
648 let state_before_materialization = fs::read(&config.state_path).unwrap();
649 let second_root = write_bundle(&cache, "fixture", second).unwrap();
650 assert_ne!(first_root, second_root);
651 assert_eq!(write_bundle(&cache, "fixture", first).unwrap(), first_root);
652 assert_eq!(
653 fs::read(&config.state_path).unwrap(),
654 state_before_materialization
655 );
656 assert_eq!(current.live_catalog_stamp(), first_catalog);
657 verify_plugin_authority(&first_authority).unwrap();
658 verify_plugin_authority(&old_authority).unwrap();
659
660 // Rediscovery uses the process's frozen root even after another build
661 // publishes next to it; same embedded bytes retain identity and trust.
662 let reloaded = current.rediscover_for_workspace(&workspace);
663 let plugin = reloaded.get("fixture").unwrap();
664 assert_eq!(plugin.id, first_id);
665 assert!(plugin.active());
666 config.builtin_plugin_dirs = vec![second_root];
667 let mut next = discover_with_config(&config);
668 let plugin = next.get("fixture").unwrap();
669 assert_ne!(plugin.id, first_id);
670 assert_eq!(plugin.trust_status, PluginTrustStatus::NeverReviewed);
671 assert!(!plugin.enabled);
672 next.trust("fixture").unwrap();
673 next.enable("fixture").unwrap();
674 let next_authority = next.authority_for("fixture").unwrap();
675 verify_plugin_authority(&first_authority).unwrap();
676 verify_plugin_authority(&next_authority).unwrap();
677
678 // A matching publisher stamp cannot launder a changed reviewed source.
679 fs::write(first_root.join("fixture/body.txt"), b"other bundle").unwrap();
680 assert!(write_bundle(&cache, "fixture", first).is_err());
681 assert!(verify_plugin_authority(&first_authority).is_err());
682 verify_plugin_authority(&next_authority).unwrap();
683 verify_plugin_authority(&old_authority).unwrap();
684 next.revoke_trust("fixture").unwrap();
685 assert!(verify_plugin_authority(&next_authority).is_err());
686 }
687
688 #[test]
689 fn an_upgrade_carries_builtin_review_unless_capabilities_change_or_trust_was_revoked() {
690 use crate::plugins::context::{HostEnvironment, PluginDiscoveryContext};
691 use crate::plugins::discovery::DiscoveryConfig;
692 use crate::plugins::registry::verify_plugin_authority;
693
694 const MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"fixture","version":"1.0.0"}"#;
695 const SKILL: &[u8] = b"---\nname: extra\ndescription: An added skill.\n---\nBody.\n";
696 let builds: [&[(&str, &[u8])]; 5] = [
697 &[("plugin.json", MANIFEST), ("body.txt", b"v1")],
698 &[("plugin.json", MANIFEST), ("body.txt", b"v2")],
699 &[
700 ("plugin.json", MANIFEST),
701 ("body.txt", b"v3"),
702 ("skills/extra/SKILL.md", SKILL),
703 ],
704 &[
705 ("plugin.json", MANIFEST),
706 ("body.txt", b"v4"),
707 ("skills/extra/SKILL.md", SKILL),
708 ],
709 &[
710 ("plugin.json", MANIFEST),
711 ("body.txt", b"v5"),
712 ("skills/extra/SKILL.md", SKILL),
713 ],
714 ];
715 let temp = tempfile::tempdir().unwrap();
716 let cache = temp.path().join("cache");
717 let workspace = temp.path().join("workspace");
718 fs::create_dir(&cache).unwrap();
719 fs::create_dir(&workspace).unwrap();
720 let registry_for = |files: &[(&str, &[u8])]| {
721 let config = DiscoveryConfig {
722 workspace: workspace.clone(),
723 user_plugins_dir: temp.path().join("plugins"),
724 workspace_plugins_dir: workspace.join(".codewhale/plugins"),
725 builtin_plugin_dirs: vec![write_bundle(&cache, "fixture", files).unwrap()],
726 state_path: temp.path().join("plugins/state.json"),
727 };
728 let context = PluginDiscoveryContext::from_config_and_environment(
729 &config,
730 HostEnvironment::default(),
731 );
732 (*context.registry_for_workspace(&workspace)).clone()
733 };
734
735 // A first install has nothing to carry: it waits for review.
736 let mut v1 = registry_for(builds[0]);
737 let plugin = v1.get("fixture").unwrap();
738 assert_eq!(plugin.trust_status, PluginTrustStatus::NeverReviewed);
739 assert!(!plugin.enabled);
740 v1.trust("fixture").unwrap();
741 v1.enable("fixture").unwrap();
742 let v1_id = v1.get("fixture").unwrap().id.clone();
743 let v1_authority = v1.authority_for("fixture").unwrap();
744
745 // New bytes, same capabilities: the review and enablement carry, the
746 // new build is live, and the older build keeps its own authority.
747 let v2 = registry_for(builds[1]);
748 let plugin = v2.get("fixture").unwrap();
749 assert_ne!(plugin.id, v1_id);
750 assert_eq!(plugin.trust_status, PluginTrustStatus::Trusted);
751 assert!(plugin.enabled);
752 assert!(plugin.active());
753 verify_plugin_authority(&v2.authority_for("fixture").unwrap()).unwrap();
754 verify_plugin_authority(&v1_authority).unwrap();
755 // Carrying is once per build: rediscovery changes nothing.
756 let state = fs::read(temp.path().join("plugins/state.json")).unwrap();
757 let again = registry_for(builds[1]);
758 assert!(again.get("fixture").unwrap().active());
759 assert_eq!(
760 fs::read(temp.path().join("plugins/state.json")).unwrap(),
761 state
762 );
763
764 // Changed capabilities never carry silently: review the changes.
765 let v3 = registry_for(builds[2]);
766 let plugin = v3.get("fixture").unwrap();
767 assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
768 assert!(!plugin.enabled);
769
770 // A revocation anywhere in the line blocks carrying.
771 let mut v3 = v3;
772 v3.revoke_trust("fixture").unwrap();
773 let mut v4 = registry_for(builds[3]);
774 let plugin = v4.get("fixture").unwrap();
775 assert_eq!(plugin.trust_status, PluginTrustStatus::NeverReviewed);
776 assert!(!plugin.enabled);
777
778 // A revocation blocks only until the next review: once the user
779 // reviews and enables a later build, upgrades carry that review again.
780 v4.trust("fixture").unwrap();
781 v4.enable("fixture").unwrap();
782 let v5 = registry_for(builds[4]);
783 let plugin = v5.get("fixture").unwrap();
784 assert_eq!(plugin.trust_status, PluginTrustStatus::Trusted);
785 assert!(plugin.active());
786 }
787
788 #[test]
789 fn a_home_that_does_not_exist_yet_is_never_created() {
790 let _lock = crate::test_support::lock_test_env();
791 let tmp = tempfile::tempdir().unwrap();
792 let home = tmp.path().join("absent-home");
793 let _guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
794
795 // Read-only diagnostics run this on every startup; conjuring the home
796 // here would break their contract.
797 assert!(materialized_dirs().is_empty());
798 assert!(!home.exists());
799 }
800 }
801
801 lines RUST