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