Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/engine", "crates/runvil-internal/lua", "crates/runvil-internal/parser", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/progress", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/harness-internal/runner"]
members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/engine", "crates/runvil-internal/lua", "crates/runvil-internal/parser", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/progress", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/harness-internal/runner", "crates/harness-internal/plugin-loader", "crates/harness-internal/test-plugin"]
# crates/shared-ui is not a Rust crate: it is the TypeScript+CSS package
# the Gateway config UI consumes (the Workshop uses its fork in
# crates/workshop/look), so the crates/* glob skips it.
Expand Down Expand Up @@ -47,6 +47,7 @@ gateway-api-discovery = { path = "crates/gateway-api-discovery" }
harness = { path = "crates/harness" }
harness-gateway-client = { path = "crates/harness-gateway-client" }
harness-runner = { path = "crates/harness-internal/runner" }
harness-plugin-loader = { path = "crates/harness-internal/plugin-loader" }
plugin-user-input = { path = "crates/plugin-user-input" }
plugin-web = { path = "crates/plugin-web" }
plugin-mcp = { path = "crates/plugin-mcp" }
Expand Down
11 changes: 8 additions & 3 deletions crates/build-xtask/src/harness_bans-tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,20 @@ fn a_facade_directory_without_a_manifest_is_reported() {
}

#[test]
fn the_harness_crates_are_the_one_container_crate_and_the_facade() {
fn the_harness_crates_are_the_three_container_crates_and_the_facade() {
let root = crate::product::test_support::workspace_root();
let internal = root.join("crates").join("harness-internal");
let covered = harness_crates(&internal, &root.join("crates").join("harness"));
let expected = [internal.join("runner"), root.join("crates").join("harness")];
let expected = [
internal.join("plugin-loader"),
internal.join("runner"),
internal.join("test-plugin"),
root.join("crates").join("harness"),
];
assert_eq!(
covered.len(),
expected.len(),
"the harness container holds one crate beside the facade; covered: {covered:?}"
"the harness container holds three crates beside the facade; covered: {covered:?}"
);
for dir in &expected {
assert!(
Expand Down
2 changes: 1 addition & 1 deletion crates/build-xtask/src/harness_bans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! examples drive runs on tokio, as a real Host does.
//!
//! The check reads declared dependencies, not the resolved graph. It binds
//! the facade and both crates in the container, the set its workspace test
//! the facade and every crate in the container, the set its workspace test
//! pins.

use std::fs;
Expand Down
6 changes: 4 additions & 2 deletions crates/build-xtask/src/unsafe_allowlist-tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! The unsafe allowlist over this workspace, plus fixtures that each way of
//! relaxing `unsafe_code` outside the four owned crates fails at its line,
//! relaxing `unsafe_code` outside the six owned crates fails at its line,
//! text that only spells the attribute passes, and an empty scan fails.

use std::path::Path;
Expand Down Expand Up @@ -39,13 +39,15 @@ fn the_workspace_relaxes_unsafe_code_only_in_its_owned_crates() {
}

#[test]
fn the_four_owned_crates_may_relax_unsafe_code_and_text_that_spells_it_passes() {
fn the_six_owned_crates_may_relax_unsafe_code_and_text_that_spells_it_passes() {
let root = tempfile::TempDir::new().expect("tempdir");
for dir in [
"gateway/app",
"gateway/stt/whisper-ffi",
"gateway-api-discovery",
"workshop/desktop",
"harness-internal/plugin-loader",
"harness-internal/test-plugin",
] {
write_crate(root.path(), dir, &[("lib.rs", RELAXES)]);
}
Expand Down
6 changes: 4 additions & 2 deletions crates/build-xtask/src/unsafe_allowlist.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! The unsafe allowlist: the workspace lint table denies `unsafe_code`,
//! and only the four crates that own an unsafe boundary may relax it. Any
//! and only the six crates that own an unsafe boundary may relax it. Any
//! other `.rs` file that expects or allows `unsafe_code`, directly or
//! through `cfg_attr` at any depth, is a violation. Runs as part of
//! `cargo test -p build-xtask` and `cargo xtask tidy`.
Expand All @@ -18,11 +18,13 @@ use syn::{Meta, Token};

/// The crate directories, relative to the workspace root, that own an
/// unsafe boundary and may relax `unsafe_code` in the module that holds it.
const OWNED: [&str; 4] = [
const OWNED: [&str; 6] = [
"crates/gateway/app",
"crates/gateway/stt/whisper-ffi",
"crates/gateway-api-discovery",
"crates/workshop/desktop",
"crates/harness-internal/plugin-loader",
"crates/harness-internal/test-plugin",
];

/// Runs the allowlist over every `.rs` file in every workspace crate
Expand Down
19 changes: 19 additions & 0 deletions crates/harness-internal/plugin-loader/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "harness-plugin-loader"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
# Opens a Plugin's dynamic library and resolves its `runvil_plugin` symbol.
libloading.workspace = true
# The C contract's Rust mirror (`runvil_plugin::abi`), the Pack registry the
# inserts take, and `PackId`, which validates a Plugin's name.
runvil-plugin.workspace = true
# `LoadError`, why one Plugin did not load.
thiserror.workspace = true

[lints]
workspace = true
13 changes: 13 additions & 0 deletions crates/harness-internal/plugin-loader/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Includes the file-size check's source instead of depending on
//! `build-ceiling`, because Harness crates may name no `build-*` crate.

#[expect(
unreachable_pub,
reason = "the check's public items are private to this build script"
)]
#[path = "../../build-ceiling/src/lib.rs"]
mod build_ceiling;

fn main() -> Result<(), build_ceiling::Violations> {
build_ceiling::check()
}
210 changes: 210 additions & 0 deletions crates/harness-internal/plugin-loader/src/fakes-tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! Fake entry points for the unit tests: plain `extern "C" fn`s written
//! against `runvil_plugin::abi`, and the helpers that call the `RvHost`
//! functions the way a Plugin does. Each test defines its own fake with
//! `fake!`, so its statics belong to it alone.

use std::ffi::c_void;
use std::ptr;
use std::sync::Arc;

use runvil_plugin::PackRegistry;
pub(crate) use runvil_plugin::abi::{
PluginEntry, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_ERR_UNSUPPORTED, RV_OK, RV_PLUGIN_LOAD,
RV_PLUGIN_UNLOAD, RvCall, RvHost, RvPluginInfo, RvStatus, RvStr, RvVerb,
};

use crate::host::HOST;
use crate::plugin::{Plugin, PluginFailure, insert_plugin};

/// A status no verb returns, for a fake that fails with its own code.
pub(crate) const ODD_STATUS: RvStatus = 77;

/// Defines `mod $name` holding a fake entry point and its statics. Its
/// `RV_PLUGIN_LOAD` runs `$load`, which sees the call as `$call` and
/// returns the load's status. Its `RV_PLUGIN_UNLOAD` counts itself in
/// `UNLOADS`, stores the instance pointer it was sent in `UNLOADED`,
/// records whether the call carried the contract's fields in
/// `UNLOAD_WELL_FORMED`, and stores the status of a `describe_plugin` it
/// then tries in `UNLOAD_STATUS`. `LAST_CALL` is the address of the call
/// it got last. The second form adds the items a test needs the load body
/// to share with it, such as a `pub(super) static`.
macro_rules! fake {
($name:ident, |$call:ident| $load:expr) => {
$crate::fakes::fake!($name, {}, |$call| $load);
};
($name:ident, { $($items:item)* }, |$call:ident| $load:expr) => {
mod $name {
use std::ffi::c_void;
use std::ptr;
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicUsize, Ordering};

use $crate::fakes::*;

/// How many RV_PLUGIN_UNLOAD calls this fake got.
pub(super) static UNLOADS: AtomicUsize = AtomicUsize::new(0);
/// The instance pointer its last RV_PLUGIN_UNLOAD carried.
pub(super) static UNLOADED: AtomicPtr<c_void> = AtomicPtr::new(ptr::null_mut());
/// Whether its last RV_PLUGIN_UNLOAD carried the contract's fields.
pub(super) static UNLOAD_WELL_FORMED: AtomicBool = AtomicBool::new(false);
/// The status a `describe_plugin` during RV_PLUGIN_UNLOAD got,
/// plus one, so zero means it never tried.
pub(super) static UNLOAD_STATUS: AtomicUsize = AtomicUsize::new(0);
/// The address of the last call the fake was sent, which a
/// test reads to hand an `RvHost` function a stale call.
pub(super) static LAST_CALL: AtomicPtr<c_void> = AtomicPtr::new(ptr::null_mut());

$($items)*

pub(super) extern "C" fn entry($call: *mut RvCall) -> RvStatus {
LAST_CALL.store($call.cast(), Ordering::SeqCst);
match verb($call) {
RV_PLUGIN_LOAD => $load,
RV_PLUGIN_UNLOAD => {
UNLOADS.fetch_add(1, Ordering::SeqCst);
UNLOADED.store(plugin($call), Ordering::SeqCst);
UNLOAD_WELL_FORMED
.store(contract_holds($call, RV_PLUGIN_UNLOAD), Ordering::SeqCst);
let status = describe($call, "acme-url", "1.0.0");
UNLOAD_STATUS.store(status as usize + 1, Ordering::SeqCst);
RV_OK
}
_ => RV_ERR_UNSUPPORTED,
}
}
}
};
}
pub(crate) use fake;

/// Inserts the linked Plugin `entry` into a fresh Pack registry.
pub(crate) fn insert(entry: PluginEntry) -> Result<Arc<Plugin>, PluginFailure> {
insert_plugin(&mut PackRegistry::new(), entry)
}

/// Inserts `entry`, which must load.
pub(crate) fn loaded(entry: PluginEntry) -> Arc<Plugin> {
insert(entry).expect("the Plugin loads")
}

/// Inserts `entry`, which must fail to load.
pub(crate) fn failure(entry: PluginEntry) -> PluginFailure {
insert(entry).expect_err("the Plugin fails to load")
}

/// Whether `call` carries the fields the contract gives a call that sends
/// `verb` in version 0.1, and its table says the contract's version. A
/// load call carries no instance yet; an unload call carries one.
pub(crate) fn contract_holds(call: *mut RvCall, verb: RvVerb) -> bool {
// SAFETY: a fake runs only inside an entry point call, where `call` is
// the loader's live `RvCall` and its table is the loader's static.
let (call, table) = unsafe { (&*call, &*(*call).host) };
let is_load = verb == RV_PLUGIN_LOAD;
call.size == u32::try_from(size_of::<RvCall>()).unwrap_or(0)
&& call.verb == verb
&& ptr::eq(call.host, &raw const HOST)
&& call.plugin.is_null() == is_load
&& call.pack_run.is_null()
&& call.args.is_null()
&& table.size == u32::try_from(size_of::<RvHost>()).unwrap_or(0)
&& table.api_major == 0
&& table.api_minor == 1
&& table.reserved == 0
}

/// The verb a call carries.
pub(crate) fn verb(call: *mut RvCall) -> RvVerb {
// SAFETY: as in `contract_holds`.
unsafe { (*call).verb }
}

/// The instance pointer a call carries.
pub(crate) fn plugin(call: *mut RvCall) -> *mut c_void {
// SAFETY: as in `contract_holds`.
unsafe { (*call).plugin }
}

/// Sets the instance pointer, as RV_PLUGIN_LOAD may.
pub(crate) fn set_plugin(call: *mut RvCall, plugin: *mut c_void) {
// SAFETY: as in `contract_holds`, and nothing else reads the call
// meanwhile.
unsafe { (*call).plugin = plugin };
}

/// An address that is never null, which a fake hands out as its instance.
pub(crate) fn instance(tag: usize) -> *mut c_void {
ptr::without_provenance_mut(tag)
}

/// The `RvHost` table a call carries.
pub(crate) fn host(call: *mut RvCall) -> &'static RvHost {
// SAFETY: as in `contract_holds`.
unsafe { &*(*call).host }
}

/// A string for the C side, borrowed from `text`.
pub(crate) fn rv_str(text: &str) -> RvStr {
RvStr {
ptr: text.as_ptr().cast(),
len: text.len(),
}
}

/// A string for the C side, borrowed from `bytes`.
pub(crate) fn rv_str_bytes(bytes: &[u8]) -> RvStr {
RvStr {
ptr: bytes.as_ptr().cast(),
len: bytes.len(),
}
}

/// A well-formed `RvPluginInfo`.
pub(crate) fn info(name: RvStr, version: RvStr) -> RvPluginInfo {
RvPluginInfo {
size: u32::try_from(size_of::<RvPluginInfo>()).expect("the info's size fits a u32"),
reserved: 0,
name,
version,
}
}

/// Calls the call's `describe_plugin` with `info`.
pub(crate) fn describe_info(call: *mut RvCall, info: &RvPluginInfo) -> RvStatus {
describe_as(call, call, info)
}

/// Calls the `describe_plugin` of `table_from`'s table, handing it `as_call`.
pub(crate) fn describe_as(
table_from: *mut RvCall,
as_call: *const RvCall,
info: &RvPluginInfo,
) -> RvStatus {
// SAFETY: `info` is a live `RvPluginInfo` whose strings outlive the
// call, and the function never dereferences `as_call`.
unsafe { (host(table_from).describe_plugin)(as_call, info) }
}

/// Describes the Plugin with `name` and `version`.
pub(crate) fn describe(call: *mut RvCall, name: &str, version: &str) -> RvStatus {
describe_info(call, &info(rv_str(name), rv_str(version)))
}

/// Calls the `refuse` of `table_from`'s table, handing it `as_call`.
pub(crate) fn refuse_as(
table_from: *mut RvCall,
as_call: *const RvCall,
reason: RvStr,
) -> RvStatus {
// SAFETY: `reason` outlives the call, and the function never
// dereferences `as_call`.
unsafe { (host(table_from).refuse)(as_call, reason) }
}

/// Calls the call's `refuse` with `reason`.
pub(crate) fn refuse_str(call: *mut RvCall, reason: RvStr) -> RvStatus {
refuse_as(call, call, reason)
}

/// Refuses the load with `reason`.
pub(crate) fn refuse(call: *mut RvCall, reason: &str) -> RvStatus {
refuse_str(call, rv_str(reason))
}
Loading
Loading