zeroclaw/src/hardware/loader.rs
ehu shubham shaw 71e89801b5
feat(hardware): add RPi GPIO, Aardvark I2C/SPI/GPIO, and hardware plugin system (#4125)
* feat(hardware): add RPi GPIO, Aardvark I2C/SPI/GPIO, and hardware plugin system

Extends the hardware subsystem with three clusters of functionality,
all feature-gated (hardware / peripheral-rpi) with no impact on default builds.

Raspberry Pi native support:
- src/hardware/rpi.rs: board self-discovery (model, serial, revision),
  sysfs GPIO pin read/write, and ACT LED control
- scripts/99-act-led.rules: udev rule for non-root ACT LED access
- scripts/deploy-rpi.sh, scripts/rpi-config.toml, scripts/zeroclaw.service:
  one-shot deployment helper and systemd service template

Total Phase Aardvark USB adapter (I2C / SPI / GPIO):
- crates/aardvark-sys/: new workspace crate with FFI bindings loaded at
  runtime via libloading; graceful stub fallback when .so is absent or
  arch mismatches (Rosetta 2 detection)
- src/hardware/aardvark.rs: AardvarkTransport implementing Transport trait
- src/hardware/aardvark_tools.rs: agent tools i2c_scan, i2c_read,
  i2c_write, spi_transfer, gpio_aardvark
- src/hardware/datasheet.rs: datasheet search/download for detected devices
- docs/aardvark-integration.md, examples/hardware/aardvark/: guide + examples

Hardware plugin / ToolRegistry system:
- src/hardware/tool_registry.rs: ToolRegistry for hardware module tool sets
- src/hardware/loader.rs, src/hardware/manifest.rs: manifest-driven loader
- src/hardware/subprocess.rs: subprocess execution helper for board I/O
- src/gateway/hardware_context.rs: POST /api/hardware/reload endpoint
- src/hardware/mod.rs: exports all new modules; merge_hardware_tools and
  load_hardware_context_prompt helpers

Integration hooks (minimal surface):
- src/hardware/device.rs: DeviceKind::Aardvark, DeviceRuntime::Aardvark,
  has_aardvark / resolve_aardvark_device on DeviceRegistry
- src/hardware/transport.rs: TransportKind::Aardvark
- src/peripherals/mod.rs: gate create_board_info_tools behind hardware feature
- src/agent/loop_.rs: TOOL_CHOICE_OVERRIDE task-local for Anthropic provider
- src/providers/anthropic.rs: read TOOL_CHOICE_OVERRIDE; add tool_choice field
- Cargo.toml: add aardvark-sys to workspace and as dependency
- firmware/zeroclaw-nucleo/: update Cargo.toml and Cargo.lock

Non-goals:
- No changes to agent orchestration, channels, providers, or security policy
- No new config keys outside existing [hardware] / [peripherals] sections
- No CI workflow changes

Risk: Low. All new paths are feature-gated; aardvark.so loads at runtime
only when present. No schema migrations or persistent state introduced.

Rollback: revert this single commit.

* fix(hardware): resolve clippy and rustfmt CI failures

- struct_excessive_bools: allow on DeviceCapabilities (7 bool fields needed)
- unnecessary_debug_formatting: use .display() instead of {:?} for paths
- stable_sort_primitive: replace .sort() with .sort_unstable() on &str slices

* fix(hardware): add missing serial/uf2/pico modules declared in mod.rs

cargo fmt was exiting with code 1 because mod.rs declared pub mod serial,
uf2, pico_flash, pico_code but those files were missing from the branch.
Also apply auto-formatting to loader.rs.

* fix(hardware): apply rustfmt 1.92.0 formatting (matches CI toolchain)

* docs(scripts): add RPi deployment and interaction guide

* push

* feat(firmware): add initial Pico firmware and serial device handling

- Introduced main.py for ZeroClaw Pico firmware with a placeholder for MicroPython implementation.
- Added binary UF2 file for Pico deployment.
- Implemented serial device enumeration and validation in the hardware module, enhancing security by restricting allowed serial paths.
- Updated related modules to integrate new serial device functionality.

---------

Co-authored-by: ehushubhamshaw <eshaw1@wpi.edu>
2026-03-21 04:17:01 -04:00

328 lines
10 KiB
Rust

//! Plugin manifest loader — scans `~/.zeroclaw/tools/` at startup.
//!
//! Layout expected on disk:
//! ```text
//! ~/.zeroclaw/tools/
//! ├── i2c_scan/
//! │ ├── tool.toml
//! │ └── i2c_scan.py
//! └── pwm_set/
//! ├── tool.toml
//! └── pwm_set
//! ```
//!
//! Rules:
//! - The directory is **created** if it does not exist.
//! - Each subdirectory is scanned for a `tool.toml`.
//! - Manifests that fail to parse or validate are **skipped with a warning**;
//! they must not crash startup.
//! - Non-directory entries at the top level are silently ignored.
use super::manifest::ToolManifest;
use super::subprocess::SubprocessTool;
use crate::tools::traits::Tool;
use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};
/// A successfully loaded plugin, ready for registration.
pub struct LoadedPlugin {
/// Tool name from the manifest (unique key in [`ToolRegistry`]).
pub name: String,
/// Semantic version string from the manifest.
pub version: String,
/// The constructed tool, boxed for dynamic dispatch.
pub tool: Box<dyn Tool>,
}
/// Scan `~/.zeroclaw/tools/` and return all valid plugins.
///
/// - Creates the directory if absent.
/// - Skips broken manifests with a `tracing::warn!` — does not propagate errors.
/// - Returns an empty `Vec` when no plugins are installed.
pub fn scan_plugin_dir() -> Vec<LoadedPlugin> {
let tools_dir = match plugin_tools_dir() {
Ok(p) => p,
Err(e) => {
tracing::warn!("[registry] cannot resolve plugin tools dir: {}", e);
return Vec::new();
}
};
// Create the directory tree if it is missing.
if !tools_dir.exists() {
if let Err(e) = fs::create_dir_all(&tools_dir) {
tracing::warn!(
"[registry] could not create {:?}: {}",
tools_dir.display(),
e
);
return Vec::new();
}
tracing::info!(
"[registry] created plugin directory: {}",
tools_dir.display()
);
}
println!(
"[registry] scanning {}...",
match dirs_home().as_deref().filter(|s| !s.is_empty()) {
Some(home) => tools_dir
.to_str()
.unwrap_or("~/.zeroclaw/tools")
.replace(home, "~"),
None => tools_dir
.to_str()
.unwrap_or("~/.zeroclaw/tools")
.to_string(),
}
);
let mut plugins = Vec::new();
let entries = match fs::read_dir(&tools_dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!("[registry] cannot read tools dir: {}", e);
return Vec::new();
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::warn!("[registry] skipping unreadable dir entry: {}", e);
continue;
}
};
let plugin_dir = entry.path();
// Only descend into subdirectories.
if !plugin_dir.is_dir() {
continue;
}
let manifest_path = plugin_dir.join("tool.toml");
if !manifest_path.exists() {
tracing::debug!(
"[registry] no tool.toml in {:?} — skipping",
plugin_dir.file_name().unwrap_or_default()
);
continue;
}
match load_one_plugin(&plugin_dir, &manifest_path) {
Ok(plugin) => plugins.push(plugin),
Err(e) => {
tracing::warn!(
"[registry] skipping plugin in {:?}: {}",
plugin_dir.file_name().unwrap_or_default(),
e
);
}
}
}
plugins
}
/// Parse and validate a single plugin directory.
///
/// Returns `Err` on any validation failure so the caller can log and continue.
fn load_one_plugin(plugin_dir: &Path, manifest_path: &Path) -> Result<LoadedPlugin> {
let raw = fs::read_to_string(manifest_path)
.map_err(|e| anyhow::anyhow!("cannot read tool.toml: {}", e))?;
let manifest: ToolManifest = toml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("TOML parse error in tool.toml: {}", e))?;
// Validate required fields — fail fast with a descriptive error.
if manifest.tool.name.trim().is_empty() {
anyhow::bail!("manifest missing [tool] name");
}
if manifest.tool.description.trim().is_empty() {
anyhow::bail!("manifest missing [tool] description");
}
if manifest.exec.binary.trim().is_empty() {
anyhow::bail!("manifest missing [exec] binary");
}
// Validate binary path: must exist, be a regular file, and reside within plugin_dir.
let canonical_plugin_dir = plugin_dir.canonicalize().map_err(|e| {
anyhow::anyhow!(
"cannot canonicalize plugin dir {}: {}",
plugin_dir.display(),
e
)
})?;
let raw_binary_path = plugin_dir.join(&manifest.exec.binary);
if !raw_binary_path.exists() {
anyhow::bail!(
"manifest exec binary not found: {}",
raw_binary_path.display()
);
}
let binary_path = raw_binary_path.canonicalize().map_err(|e| {
anyhow::anyhow!(
"cannot canonicalize binary path {}: {}",
raw_binary_path.display(),
e
)
})?;
if !binary_path.starts_with(&canonical_plugin_dir) {
anyhow::bail!(
"manifest exec binary escapes plugin directory: {} is not under {}",
binary_path.display(),
canonical_plugin_dir.display()
);
}
if !binary_path.is_file() {
anyhow::bail!(
"manifest exec binary is not a regular file: {}",
binary_path.display()
);
}
let name = manifest.tool.name.clone();
let version = manifest.tool.version.clone();
let tool: Box<dyn Tool> = Box::new(SubprocessTool::new(manifest, binary_path));
Ok(LoadedPlugin {
name,
version,
tool,
})
}
/// Return the path `~/.zeroclaw/tools/` using the `directories` crate.
pub fn plugin_tools_dir() -> Result<PathBuf> {
use directories::BaseDirs;
let base = BaseDirs::new()
.ok_or_else(|| anyhow::anyhow!("cannot determine the user home directory"))?;
Ok(base.home_dir().join(".zeroclaw").join("tools"))
}
/// Best-effort home dir string for display purposes only.
fn dirs_home() -> Option<String> {
use directories::BaseDirs;
BaseDirs::new().map(|b| b.home_dir().to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_valid_manifest(dir: &Path) {
let toml = r#"
[tool]
name = "test_plugin"
version = "1.0.0"
description = "A deterministic test plugin"
[exec]
binary = "tool.sh"
[[parameters]]
name = "device"
type = "string"
description = "Device alias"
required = true
"#;
fs::write(dir.join("tool.toml"), toml).unwrap();
// Write a dummy binary (content doesn't matter for manifest loading).
fs::write(
dir.join("tool.sh"),
"#!/bin/sh\necho '{\"success\":true,\"output\":\"ok\",\"error\":null}'\n",
)
.unwrap();
}
#[test]
fn load_one_plugin_succeeds_for_valid_manifest() {
let dir = tempfile::tempdir().unwrap();
write_valid_manifest(dir.path());
let manifest_path = dir.path().join("tool.toml");
let plugin = load_one_plugin(dir.path(), &manifest_path).unwrap();
assert_eq!(plugin.name, "test_plugin");
assert_eq!(plugin.version, "1.0.0");
assert_eq!(plugin.tool.name(), "test_plugin");
}
#[test]
fn load_one_plugin_fails_on_missing_name() {
let dir = tempfile::tempdir().unwrap();
let toml = r#"
[tool]
name = ""
version = "1.0.0"
description = "Missing name test"
[exec]
binary = "tool.sh"
"#;
fs::write(dir.path().join("tool.toml"), toml).unwrap();
let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml"));
match result {
Err(e) => assert!(e.to_string().contains("name"), "unexpected error: {}", e),
Ok(_) => panic!("expected an error for missing name"),
}
}
#[test]
fn load_one_plugin_fails_on_parse_error() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("tool.toml"), "not valid toml {{{{").unwrap();
let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml"));
match result {
Err(e) => assert!(
e.to_string().contains("TOML parse error"),
"unexpected error: {}",
e
),
Ok(_) => panic!("expected a parse error"),
}
}
#[test]
fn scan_plugin_dir_skips_broken_manifests_without_panicking() {
// We can't redirect scan_plugin_dir to an arbitrary directory (it
// always uses ~/.zeroclaw/tools), but we can verify load_one_plugin
// behaviour under broken input without affecting the real directory.
let dir = tempfile::tempdir().unwrap();
// Plugin 1: valid
let p1 = dir.path().join("good");
fs::create_dir_all(&p1).unwrap();
write_valid_manifest(&p1);
// Plugin 2: broken TOML
let p2 = dir.path().join("bad");
fs::create_dir_all(&p2).unwrap();
fs::write(p2.join("tool.toml"), "{{broken").unwrap();
// Load manually to simulate what scan_plugin_dir does.
let good = load_one_plugin(&p1, &p1.join("tool.toml"));
let bad = load_one_plugin(&p2, &p2.join("tool.toml"));
assert!(good.is_ok(), "good plugin should load");
assert!(bad.is_err(), "bad plugin should error, not panic");
}
#[test]
fn plugin_tools_dir_returns_path_ending_in_zeroclaw_tools() {
let path = plugin_tools_dir().expect("should resolve");
let display = path.to_string_lossy();
let expected = std::path::Path::new(".zeroclaw").join("tools");
assert!(path.ends_with(&expected), "unexpected path: {}", display);
}
}