| 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 |