From 12fa49b5d51a8528b8d949ae84dac0c882f4ef2b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 19:55:27 +0000 Subject: [PATCH 01/24] chore(vendor): bump tiny* submodules to latest commits Updated the pinned commits for all fourteen vendored tiny* submodules to their current upstream revisions. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinyconnectors | 2 +- vendor/tinydocs | 2 +- vendor/tinyflows | 2 +- vendor/tinyhosts | 2 +- vendor/tinyhumans-sdk | 2 +- vendor/tinyjuice | 2 +- vendor/tinymcp | 2 +- vendor/tinymemory | 2 +- vendor/tinysearch | 2 +- vendor/tinysecurity | 2 +- vendor/tinyskills | 2 +- vendor/tinystoragedrivers | 2 +- vendor/tinyvoice | 2 +- vendor/tinywallet | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/vendor/tinyconnectors b/vendor/tinyconnectors index e66c8d3819..95b847a385 160000 --- a/vendor/tinyconnectors +++ b/vendor/tinyconnectors @@ -1 +1 @@ -Subproject commit e66c8d38193af7b2f8a8e5790ee29051a9183b68 +Subproject commit 95b847a38551fe63155943489c2d258160972fb9 diff --git a/vendor/tinydocs b/vendor/tinydocs index 14f8240b80..6b8aeb740c 160000 --- a/vendor/tinydocs +++ b/vendor/tinydocs @@ -1 +1 @@ -Subproject commit 14f8240b802587b081e6673154998cc485c25fe9 +Subproject commit 6b8aeb740c7b97f7be0fb6010bb9949b6c3e9af7 diff --git a/vendor/tinyflows b/vendor/tinyflows index 674929ae82..a435b5160e 160000 --- a/vendor/tinyflows +++ b/vendor/tinyflows @@ -1 +1 @@ -Subproject commit 674929ae827811eb809d6a32295817fae269dabb +Subproject commit a435b5160e8460e19aa2e2bddb2f8247aa733b96 diff --git a/vendor/tinyhosts b/vendor/tinyhosts index 8d210af4f8..bd32159770 160000 --- a/vendor/tinyhosts +++ b/vendor/tinyhosts @@ -1 +1 @@ -Subproject commit 8d210af4f80b882f65a3123a3c3ade9e92793058 +Subproject commit bd3215977011cac7507b5c3b35ed3536fb2ef957 diff --git a/vendor/tinyhumans-sdk b/vendor/tinyhumans-sdk index af3c8840f5..e7f38bf439 160000 --- a/vendor/tinyhumans-sdk +++ b/vendor/tinyhumans-sdk @@ -1 +1 @@ -Subproject commit af3c8840f593a4bc7f5d92d77d12810cbba77022 +Subproject commit e7f38bf43938679212f2b4b1df5897878a9e4cfd diff --git a/vendor/tinyjuice b/vendor/tinyjuice index ceb4f293cf..dea615de77 160000 --- a/vendor/tinyjuice +++ b/vendor/tinyjuice @@ -1 +1 @@ -Subproject commit ceb4f293cf17d17cdcdbc166cea311152398140b +Subproject commit dea615de77d9a5adea2dd41f3655497e5d33f702 diff --git a/vendor/tinymcp b/vendor/tinymcp index d42d345204..418f9c079b 160000 --- a/vendor/tinymcp +++ b/vendor/tinymcp @@ -1 +1 @@ -Subproject commit d42d3452045bd8dd01c3b79f6aa0d10c25d83d6d +Subproject commit 418f9c079b90e4466eacc207aebcc9f486bbf47f diff --git a/vendor/tinymemory b/vendor/tinymemory index 4ee5d0ce3a..011af8da41 160000 --- a/vendor/tinymemory +++ b/vendor/tinymemory @@ -1 +1 @@ -Subproject commit 4ee5d0ce3a197a5f552ebb5f3c7cce208be51080 +Subproject commit 011af8da4185f373f42c1c1db15df3e2310d1820 diff --git a/vendor/tinysearch b/vendor/tinysearch index 96d6922627..d86c27237c 160000 --- a/vendor/tinysearch +++ b/vendor/tinysearch @@ -1 +1 @@ -Subproject commit 96d692262740f0b5e06052b2dc17cdbdb8d3b815 +Subproject commit d86c27237ce73ac577845fda7648a3a9cd1e7a99 diff --git a/vendor/tinysecurity b/vendor/tinysecurity index b53eceb948..e08f0178d0 160000 --- a/vendor/tinysecurity +++ b/vendor/tinysecurity @@ -1 +1 @@ -Subproject commit b53eceb948bd8dedce2176af6f0ec7bb1bd74151 +Subproject commit e08f0178d0b296ae0295ea91bbc6fca85bceb272 diff --git a/vendor/tinyskills b/vendor/tinyskills index ff099338fe..84a91a3115 160000 --- a/vendor/tinyskills +++ b/vendor/tinyskills @@ -1 +1 @@ -Subproject commit ff099338fe89cc391477b00bdc208ce030ba4c3a +Subproject commit 84a91a31154434fc3429dd8da823702f96e995a5 diff --git a/vendor/tinystoragedrivers b/vendor/tinystoragedrivers index 2d23a6a600..5e8c837898 160000 --- a/vendor/tinystoragedrivers +++ b/vendor/tinystoragedrivers @@ -1 +1 @@ -Subproject commit 2d23a6a6009fa52d1e7aa18de589bdd109a54b6a +Subproject commit 5e8c8378983a60c8de0a014a77cdba40c242b455 diff --git a/vendor/tinyvoice b/vendor/tinyvoice index 85ce60318c..4778ca5999 160000 --- a/vendor/tinyvoice +++ b/vendor/tinyvoice @@ -1 +1 @@ -Subproject commit 85ce60318cece232d5dab2306f93362cfb87ac0d +Subproject commit 4778ca59998b17e0938c3f2900eca55ac38b95af diff --git a/vendor/tinywallet b/vendor/tinywallet index 30e92e630c..cc94d96b51 160000 --- a/vendor/tinywallet +++ b/vendor/tinywallet @@ -1 +1 @@ -Subproject commit 30e92e630c5130a486e0dd8bbac64330a48f3470 +Subproject commit cc94d96b51f34d7faaf0c7b86a42221043385d9a From da5dc4340909e6041d2e867bf333dfea1d2571ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 19:57:30 +0000 Subject: [PATCH 02/24] chore(vendor): update tinyconnectors submodule Bump the vendored tinyconnectors submodule to the latest revision. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinyconnectors | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyconnectors b/vendor/tinyconnectors index 95b847a385..e66c8d3819 160000 --- a/vendor/tinyconnectors +++ b/vendor/tinyconnectors @@ -1 +1 @@ -Subproject commit 95b847a38551fe63155943489c2d258160972fb9 +Subproject commit e66c8d38193af7b2f8a8e5790ee29051a9183b68 From 90202220fb9077cb1ab3c1b02d405b6ffd013b59 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 19:59:18 +0000 Subject: [PATCH 03/24] chore(vendor): bump tinydocs submodule to 14f8240 Update the vendored tinydocs submodule to the latest commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinydocs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinydocs b/vendor/tinydocs index 6b8aeb740c..14f8240b80 160000 --- a/vendor/tinydocs +++ b/vendor/tinydocs @@ -1 +1 @@ -Subproject commit 6b8aeb740c7b97f7be0fb6010bb9949b6c3e9af7 +Subproject commit 14f8240b802587b081e6673154998cc485c25fe9 From cc5279653775192f8cc8ea5aff492a00d74cccc1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 19:59:59 +0000 Subject: [PATCH 04/24] chore(vendor): bump tinyflows submodule Update the vendored tinyflows submodule to the latest commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/modules/ops/callback_load.rs | 110 ++++++++++++++++++ vendor/tinyflows | 2 +- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 crates/openhuman-core/src/modules/ops/callback_load.rs diff --git a/crates/openhuman-core/src/modules/ops/callback_load.rs b/crates/openhuman-core/src/modules/ops/callback_load.rs new file mode 100644 index 0000000000..a135658916 --- /dev/null +++ b/crates/openhuman-core/src/modules/ops/callback_load.rs @@ -0,0 +1,110 @@ +//! Loading a module that takes host callbacks, from whichever source serves it. +//! +//! TinyBus binds host callbacks to a module before its `init` runs, and only +//! on paths that keep the admitted identity: an explicit local file (a `path` +//! override, a source build, or a staged `OPENHUMAN_MODULE_PATH` release) and +//! the pinned release chain itself. The release chain tries the installer +//! bundle, then the release cache, then a download into the cache when +//! `modules.allow_download` is on, and every candidate passes the registry's +//! archive SHA-256 and, where the record pins one, its library SHA-256 before +//! a binding is offered to it. +//! +//! `Resolver::ensure_loaded_with_host_callback_factory` drives both, preparing +//! a fresh binding for each candidate so a refused candidate revokes only its +//! own. `Resolver::ensure_loaded_with_host_callbacks` takes one binding and +//! accepts only a local-file source, so a release-served module (every shipped +//! host without `OPENHUMAN_MODULE_PATH`) could neither be loaded nor +//! re-checked through it. Every callback-bound load goes through [`load`] and +//! every later check through [`confirm`] instead. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::{Arc, Mutex, PoisonError}; + +use tinybus::module::{HostCallbackBinding, Resolver}; + +/// One prepared candidate: its binding, the module configuration naming it, +/// and the host state that must live as long as the binding is retained. +pub(in crate::modules) type Prepared = (Arc, serde_json::Value, S); + +/// Load `id` with host callbacks, preparing a fresh set with `prepare` for +/// each candidate artifact the resolver tries. +/// +/// Returns the binding the admitted module retains and the state prepared +/// with it; the state of refused candidates is dropped. +/// +/// # Errors +/// +/// The resolver's message when no candidate is admitted, `prepare`'s message +/// when a binding cannot be prepared, or a refusal when the module is already +/// serving with a binding this call did not prepare. +pub(in crate::modules) async fn load( + resolver: &Resolver, + id: &str, + prepare: F, +) -> Result<(Arc, S), String> +where + S: Send + 'static, + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future, String>> + Send + 'static, +{ + let candidates: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let factory_candidates = Arc::clone(&candidates); + let prepare = Arc::new(prepare); + let binding = resolver + .ensure_loaded_with_host_callback_factory(id, move |_attempt| { + let candidates = Arc::clone(&factory_candidates); + let prepare = Arc::clone(&prepare); + async move { + let (binding, module_config, state) = prepare().await?; + candidates + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(binding.unique_name().to_string(), state); + Ok((binding, module_config)) + } + }) + .await?; + let state = candidates + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(binding.unique_name().as_str()) + .ok_or_else(|| { + format!("module '{id}' is serving with a callback binding this load did not prepare") + })?; + Ok((binding, state)) +} + +/// Confirm `id` is still admitted with `expected`, preparing nothing. +/// +/// For a module an earlier [`load`] admitted: the resolver answers from the +/// retained binding without touching any source, so this works whether the +/// module came from a local file or from the release chain. +/// +/// # Errors +/// +/// The resolver's cached failure, or a refusal when the module is not serving +/// with `expected`. +pub(in crate::modules) async fn confirm( + resolver: &Resolver, + id: &str, + expected: &HostCallbackBinding, +) -> Result<(), String> { + let module_id = id.to_owned(); + let retained = resolver + .ensure_loaded_with_host_callback_factory(id, move |_attempt| { + let id = module_id.clone(); + async move { + Err(format!( + "module '{id}' is not serving with its admitted callback binding" + )) + } + }) + .await?; + if retained.unique_name() != expected.unique_name() { + return Err(format!( + "module '{id}' is serving with a different callback binding" + )); + } + Ok(()) +} diff --git a/vendor/tinyflows b/vendor/tinyflows index a435b5160e..674929ae82 160000 --- a/vendor/tinyflows +++ b/vendor/tinyflows @@ -1 +1 @@ -Subproject commit a435b5160e8460e19aa2e2bddb2f8247aa733b96 +Subproject commit 674929ae827811eb809d6a32295817fae269dabb From 5edaecf385d199f8bb172c6c6d148586ef347a8b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:00:27 +0000 Subject: [PATCH 05/24] chore(vendor): bump tinyhosts submodule Update the vendored tinyhosts submodule to a newer revision. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinyhosts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyhosts b/vendor/tinyhosts index bd32159770..8d210af4f8 160000 --- a/vendor/tinyhosts +++ b/vendor/tinyhosts @@ -1 +1 @@ -Subproject commit bd3215977011cac7507b5c3b35ed3536fb2ef957 +Subproject commit 8d210af4f80b882f65a3123a3c3ade9e92793058 From 7c1548d454d61cc66871f486eb61c0167d353ad8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:00:38 +0000 Subject: [PATCH 06/24] chore(vendor): bump tinyhumans-sdk and tinyjuice submodules Update the vendored tinyhumans-sdk and tinyjuice submodule pointers to their latest commits. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinyhumans-sdk | 2 +- vendor/tinyjuice | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinyhumans-sdk b/vendor/tinyhumans-sdk index e7f38bf439..af3c8840f5 160000 --- a/vendor/tinyhumans-sdk +++ b/vendor/tinyhumans-sdk @@ -1 +1 @@ -Subproject commit e7f38bf43938679212f2b4b1df5897878a9e4cfd +Subproject commit af3c8840f593a4bc7f5d92d77d12810cbba77022 diff --git a/vendor/tinyjuice b/vendor/tinyjuice index dea615de77..ceb4f293cf 160000 --- a/vendor/tinyjuice +++ b/vendor/tinyjuice @@ -1 +1 @@ -Subproject commit dea615de77d9a5adea2dd41f3655497e5d33f702 +Subproject commit ceb4f293cf17d17cdcdbc166cea311152398140b From 5b628dbd914d494e089b1b13677c71fd230512fb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:01:16 +0000 Subject: [PATCH 07/24] test(modules): add release tests for ops callback Adds a test module covering the ops callback release path so the behaviour is exercised before further changes land. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/modules/ops_callback_release_tests.rs | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 crates/openhuman-core/src/modules/ops_callback_release_tests.rs diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs new file mode 100644 index 0000000000..d8a2766068 --- /dev/null +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -0,0 +1,287 @@ +//! A callback-bound module served from its pinned release. +//! +//! TinyMCP, TinyEventQueues, TinyChannels, TinySecurity and TinyComputer take +//! host callbacks when they load. On a host without `OPENHUMAN_MODULE_PATH` +//! (every shipped build and embedder) their source is the pinned release, so +//! the callback load has to come from the installer bundle or the release +//! cache, digest-checked, and never require a local-file override. +//! +//! The module here is `tests/fixtures/storage_probe_module`, built by the +//! storage host's cdylib tests and staged as a release: the archive and its +//! extracted library side by side under `////`, +//! the layout TinyBus fills after a verified download. Its record pins the +//! archive and library digests, so a wrong digest is a refusal and not a load. +//! It is loaded through [`ensure_loaded_with_storage_callback`], the path the +//! storage-backed modules take. +//! +//! Each test runs in its own process: the module runtime, the resolution table +//! and the probe's bus name are process-wide, and TinyBus never unloads a +//! library. + +use std::collections::HashMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::{ensure_loaded_with_storage_callback, report, runtime}; +use crate::config::Config; +use tinybus::module::{ + artifact_dir, platform::host_candidates, LinkedRegistry, ModuleSource as ResolverSource, + ReleaseAsset, ReleaseRecord, Resolver, ResolverInputs, +}; + +const ID: &str = "callbackprobe"; +const VERSION: &str = "0.0.1"; +const ARCHIVE: &str = "callbackprobe-module-0.0.1.tar.gz"; +const BUS_NAME: &str = "ai.tinyhumans.test.StorageProbe"; +const OBJECT_PATH: &str = "/probe"; +const WRONG_DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +/// Re-run `test` alone in a fresh process; `true` means this is that process. +fn isolated(test: &str) -> bool { + const ISOLATED: &str = "OPENHUMAN_TEST_ISOLATED_CALLBACK_RELEASE"; + if std::env::var_os(ISOLATED).is_some() { + return true; + } + // Build the fixture here so the child does not race its own cargo build. + let _ = probe_library(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + &format!("modules::ops::callback_release_tests::{test}"), + "--test-threads=1", + ]) + .env(ISOLATED, "1") + .output() + .expect("run the callback release test in a fresh process"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("1 passed"), + "isolated callback release test failed\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + false +} + +fn probe_library() -> &'static Path { + crate::modules::storage_host::tests::cdylib::probe_artifact() +} + +fn library_name() -> String { + probe_library() + .file_name() + .and_then(|name| name.to_str()) + .expect("probe library name") + .to_owned() +} + +/// A private, canonical directory: TinyBus refuses module roots that others +/// can write to or that sit beneath a symlinked ancestor. +fn private_root() -> tempfile::TempDir { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + root +} + +/// The probe library beside a `modules.toml` pinning it, in `directory`. +/// Returns the library's path and SHA-256. +fn stage_library(directory: &Path) -> (PathBuf, String) { + std::fs::create_dir_all(directory).unwrap(); + let library = directory.join(library_name()); + std::fs::copy(probe_library(), &library).unwrap(); + let digest = tinybus::module::sha256_file(&library).unwrap(); + std::fs::write( + directory.join("modules.toml"), + format!("{:?} = {digest:?}\n", library_name()), + ) + .unwrap(); + (library, digest) +} + +/// A verified release under `root`: the archive TinyBus checked and the +/// library it extracted. Returns the archive's and the library's SHA-256. +fn stage_release(root: &Path) -> (String, String) { + let directory = artifact_dir(root, ID, VERSION, &host_candidates()[0]).unwrap(); + let (library, library_digest) = stage_library(&directory); + let archive = directory.join(ARCHIVE); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + std::fs::File::create(&archive).unwrap(), + flate2::Compression::default(), + )); + builder + .append_path_with_name(&library, library_name()) + .unwrap(); + builder + .append_path_with_name(directory.join("modules.toml"), "modules.toml") + .unwrap(); + builder.into_inner().unwrap().finish().unwrap().flush().unwrap(); + (tinybus::module::sha256_file(&archive).unwrap(), library_digest) +} + +/// The registry record pinning `archive_sha256` and `library_sha256`. +fn record(archive_sha256: &str, library_sha256: &str) -> &'static ReleaseRecord { + let host_key: &'static str = Box::leak(host_candidates()[0].clone().into_boxed_str()); + let sha256: &'static str = Box::leak(archive_sha256.to_owned().into_boxed_str()); + let library_sha256: &'static str = Box::leak(library_sha256.to_owned().into_boxed_str()); + let assets: &'static [ReleaseAsset<'static>] = Box::leak(Box::new([ReleaseAsset { + host_key, + archive: ARCHIVE, + sha256, + library_sha256: Some(library_sha256), + }])); + Box::leak(Box::new(ReleaseRecord { + id: ID, + bus_name: BUS_NAME, + object_path: OBJECT_PATH, + version: VERSION, + release_url: "https://github.com/tinyhumansai/openhuman/releases/tag/v0.0.1", + assets, + })) +} + +/// A resolver over `record` alone, offline, shaped like [`super::resolver`]'s. +fn resolver( + record: &'static ReleaseRecord, + install_dir: &Path, + bundled_dir: Option<&Path>, + sources: HashMap, +) -> Resolver { + Resolver::new(ResolverInputs { + enabled: true, + install_dir: Some(install_dir.to_path_buf()), + allow_download: false, + bundled_dir: bundled_dir.map(Path::to_path_buf), + sources, + linked: Arc::new(LinkedRegistry::new()), + search_paths: None, + search_skip: Vec::new(), + gate: None, + on_resolved: None, + records: vec![record], + terminal_marker: tinytools::status::MODULE_FAULT_MARKER.to_string(), + runtime: Arc::new(|| Box::pin(runtime::runtime())), + module_config: Arc::new(|_| serde_json::Value::Null), + async_module_config: None, + reporter: report::reporter(), + }) +} + +/// Whether the probe is admitted on the module bus and retains a callback +/// binding. +async fn serving_with_callbacks() -> bool { + let host = runtime::runtime().await.unwrap().host(); + host.list() + .iter() + .any(|info| info.manifest.bus_name.as_str() == BUS_NAME) + && host.retained_host_callback_binding(BUS_NAME).is_some() +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_callback_module_in_the_installer_bundle_loads_with_callbacks() { + if !isolated("a_callback_module_in_the_installer_bundle_loads_with_callbacks") { + return; + } + let bundled = private_root(); + let cache = private_root(); + let (archive, library) = stage_release(bundled.path()); + let resolver = resolver( + record(&archive, &library), + cache.path(), + Some(bundled.path()), + HashMap::new(), + ); + + ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .expect("a bundled release loads with its storage callback"); + assert!(serving_with_callbacks().await); + // A later load confirms the retained binding instead of re-presenting it. + ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .expect("a later load reuses the admitted binding"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_callback_module_in_the_release_cache_loads_with_callbacks() { + if !isolated("a_callback_module_in_the_release_cache_loads_with_callbacks") { + return; + } + let cache = private_root(); + let (archive, library) = stage_release(cache.path()); + let resolver = resolver(record(&archive, &library), cache.path(), None, HashMap::new()); + + ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .expect("a cached release loads with its storage callback"); + assert!(serving_with_callbacks().await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bundled_archive_that_does_not_match_its_pin_is_refused() { + if !isolated("a_bundled_archive_that_does_not_match_its_pin_is_refused") { + return; + } + let bundled = private_root(); + let cache = private_root(); + let (_archive, library) = stage_release(bundled.path()); + let resolver = resolver( + record(WRONG_DIGEST, &library), + cache.path(), + Some(bundled.path()), + HashMap::new(), + ); + + let error = ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .unwrap_err(); + assert!(error.contains("installer bundle"), "{error}"); + assert!(!serving_with_callbacks().await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_cached_library_that_does_not_match_its_pin_is_refused() { + if !isolated("a_cached_library_that_does_not_match_its_pin_is_refused") { + return; + } + let cache = private_root(); + let (archive, _library) = stage_release(cache.path()); + let resolver = resolver(record(&archive, WRONG_DIGEST), cache.path(), None, HashMap::new()); + + let error = ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .unwrap_err(); + assert!(error.contains("is unavailable"), "{error}"); + assert!(!error.contains("local-file"), "{error}"); + assert!(!serving_with_callbacks().await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_local_file_override_wins_over_the_release() { + if !isolated("a_local_file_override_wins_over_the_release") { + return; + } + // The cached release does not match its pin, so a load can only succeed + // from the override. `ops::resolver` turns a `path` override, a source + // build and an `OPENHUMAN_MODULE_PATH` artifact into this source. + let cache = private_root(); + let (archive, library) = stage_release(cache.path()); + let _ = archive; + let local = private_root(); + let (path, _) = stage_library(local.path()); + let sources = HashMap::from([( + ID.to_owned(), + ResolverSource::LocalFile { + path: path.to_string_lossy().into_owned(), + }, + )]); + let resolver = resolver(record(WRONG_DIGEST, &library), cache.path(), None, sources); + + ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .expect("the override loads with its storage callback"); + assert!(serving_with_callbacks().await); +} From e9511e95100e698dd56a0eb37605584775931158 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:01:28 +0000 Subject: [PATCH 08/24] test(modules): wire up callback release tests and share storage probe helper Register the callback release test module so its cases run, and widen the visibility of the storage host test module and probe artifact helper so other module tests can reuse them. Also tidy a redundant binding in the override test. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/ops.rs | 4 ++++ .../openhuman-core/src/modules/ops_callback_release_tests.rs | 3 +-- crates/openhuman-core/src/modules/storage_host.rs | 2 +- .../openhuman-core/src/modules/storage_host_cdylib_tests.rs | 2 +- crates/openhuman-core/src/modules/storage_host_tests.rs | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops.rs b/crates/openhuman-core/src/modules/ops.rs index 6a476f0d6c..d1d8438f10 100644 --- a/crates/openhuman-core/src/modules/ops.rs +++ b/crates/openhuman-core/src/modules/ops.rs @@ -712,3 +712,7 @@ mod client_tests; #[cfg(test)] #[path = "ops_resolver_tests.rs"] mod resolver_tests; + +#[cfg(test)] +#[path = "ops_callback_release_tests.rs"] +mod callback_release_tests; diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index d8a2766068..9de65f1788 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -268,8 +268,7 @@ async fn a_local_file_override_wins_over_the_release() { // from the override. `ops::resolver` turns a `path` override, a source // build and an `OPENHUMAN_MODULE_PATH` artifact into this source. let cache = private_root(); - let (archive, library) = stage_release(cache.path()); - let _ = archive; + let (_archive, library) = stage_release(cache.path()); let local = private_root(); let (path, _) = stage_library(local.path()); let sources = HashMap::from([( diff --git a/crates/openhuman-core/src/modules/storage_host.rs b/crates/openhuman-core/src/modules/storage_host.rs index 5883da4ccb..ef28dee488 100644 --- a/crates/openhuman-core/src/modules/storage_host.rs +++ b/crates/openhuman-core/src/modules/storage_host.rs @@ -740,7 +740,7 @@ mod endpoint; #[cfg(test)] #[path = "storage_host_tests.rs"] -mod tests; +pub(in crate::modules) mod tests; #[cfg(test)] #[path = "storage_host_ctx_tests.rs"] diff --git a/crates/openhuman-core/src/modules/storage_host_cdylib_tests.rs b/crates/openhuman-core/src/modules/storage_host_cdylib_tests.rs index 8a7c7bc2f7..4b54623ac4 100644 --- a/crates/openhuman-core/src/modules/storage_host_cdylib_tests.rs +++ b/crates/openhuman-core/src/modules/storage_host_cdylib_tests.rs @@ -55,7 +55,7 @@ fn repo_root() -> PathBuf { /// The built probe library: `OPENHUMAN_STORAGE_PROBE_MODULE` when set, else a /// build of the fixture under the checkout's own `target/storage-probe`. -fn probe_artifact() -> &'static Path { +pub(in crate::modules) fn probe_artifact() -> &'static Path { static BUILT: OnceLock = OnceLock::new(); BUILT.get_or_init(|| { if let Some(path) = std::env::var_os("OPENHUMAN_STORAGE_PROBE_MODULE") { diff --git a/crates/openhuman-core/src/modules/storage_host_tests.rs b/crates/openhuman-core/src/modules/storage_host_tests.rs index 0bfda4afe0..593e1fd028 100644 --- a/crates/openhuman-core/src/modules/storage_host_tests.rs +++ b/crates/openhuman-core/src/modules/storage_host_tests.rs @@ -699,7 +699,7 @@ mod bus; mod queues; #[path = "storage_host_cdylib_tests.rs"] -mod cdylib; +pub(in crate::modules) mod cdylib; #[cfg(test)] #[path = "storage_host_stream_tests.rs"] mod streams; From 9e596d943969c0a0770933b1217cf797b9fc2bed Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:04:43 +0000 Subject: [PATCH 09/24] chore(vendor): bump tinymcp and tinymemory submodules Updated the tinymcp and tinymemory vendored submodules to their latest commits to pick up upstream changes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinymcp | 2 +- vendor/tinymemory | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tinymcp b/vendor/tinymcp index 418f9c079b..d42d345204 160000 --- a/vendor/tinymcp +++ b/vendor/tinymcp @@ -1 +1 @@ -Subproject commit 418f9c079b90e4466eacc207aebcc9f486bbf47f +Subproject commit d42d3452045bd8dd01c3b79f6aa0d10c25d83d6d diff --git a/vendor/tinymemory b/vendor/tinymemory index 011af8da41..4ee5d0ce3a 160000 --- a/vendor/tinymemory +++ b/vendor/tinymemory @@ -1 +1 @@ -Subproject commit 011af8da4185f373f42c1c1db15df3e2310d1820 +Subproject commit 4ee5d0ce3a197a5f552ebb5f3c7cce208be51080 From 5b5ad6980dabb43eded96703b32548dff3666a2a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:11:08 +0000 Subject: [PATCH 10/24] chore(vendor): bump tiny* submodules to latest commits Updated the pinned commits for tinysearch, tinysecurity, tinyskills, tinystoragedrivers, tinyvoice and tinywallet to pull in their latest upstream changes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinysearch | 2 +- vendor/tinysecurity | 2 +- vendor/tinyskills | 2 +- vendor/tinystoragedrivers | 2 +- vendor/tinyvoice | 2 +- vendor/tinywallet | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/vendor/tinysearch b/vendor/tinysearch index d86c27237c..96d6922627 160000 --- a/vendor/tinysearch +++ b/vendor/tinysearch @@ -1 +1 @@ -Subproject commit d86c27237ce73ac577845fda7648a3a9cd1e7a99 +Subproject commit 96d692262740f0b5e06052b2dc17cdbdb8d3b815 diff --git a/vendor/tinysecurity b/vendor/tinysecurity index e08f0178d0..b53eceb948 160000 --- a/vendor/tinysecurity +++ b/vendor/tinysecurity @@ -1 +1 @@ -Subproject commit e08f0178d0b296ae0295ea91bbc6fca85bceb272 +Subproject commit b53eceb948bd8dedce2176af6f0ec7bb1bd74151 diff --git a/vendor/tinyskills b/vendor/tinyskills index 84a91a3115..ff099338fe 160000 --- a/vendor/tinyskills +++ b/vendor/tinyskills @@ -1 +1 @@ -Subproject commit 84a91a31154434fc3429dd8da823702f96e995a5 +Subproject commit ff099338fe89cc391477b00bdc208ce030ba4c3a diff --git a/vendor/tinystoragedrivers b/vendor/tinystoragedrivers index 5e8c837898..2d23a6a600 160000 --- a/vendor/tinystoragedrivers +++ b/vendor/tinystoragedrivers @@ -1 +1 @@ -Subproject commit 5e8c8378983a60c8de0a014a77cdba40c242b455 +Subproject commit 2d23a6a6009fa52d1e7aa18de589bdd109a54b6a diff --git a/vendor/tinyvoice b/vendor/tinyvoice index 4778ca5999..85ce60318c 160000 --- a/vendor/tinyvoice +++ b/vendor/tinyvoice @@ -1 +1 @@ -Subproject commit 4778ca59998b17e0938c3f2900eca55ac38b95af +Subproject commit 85ce60318cece232d5dab2306f93362cfb87ac0d diff --git a/vendor/tinywallet b/vendor/tinywallet index cc94d96b51..30e92e630c 160000 --- a/vendor/tinywallet +++ b/vendor/tinywallet @@ -1 +1 @@ -Subproject commit cc94d96b51f34d7faaf0c7b86a42221043385d9a +Subproject commit 30e92e630c5130a486e0dd8bbac64330a48f3470 From e9e1801c787181d04446476f399d9f1ddc0b78bb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:14:53 +0000 Subject: [PATCH 11/24] test(ops): verify retained callback binding on module reload The serving_with_callbacks helper now takes the resolver and checks that a loaded module answers with the same retained host callback binding its load prepared, rather than only checking that some binding exists. This makes the release tests assert that reloads confirm the existing binding instead of re-presenting it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/modules/ops_callback_release_tests.rs | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index 9de65f1788..3f218d3d35 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -170,14 +170,34 @@ fn resolver( }) } -/// Whether the probe is admitted on the module bus and retains a callback -/// binding. -async fn serving_with_callbacks() -> bool { +/// Whether the probe is admitted on the module bus and retains the storage +/// callback binding its load prepared. +async fn serving_with_callbacks(resolver: &Resolver) -> bool { + let Some(prepared) = super::STORAGE_CALLBACK_LOADS + .lock() + .await + .get(ID) + .map(|(binding, _served)| Arc::clone(binding)) + else { + return false; + }; let host = runtime::runtime().await.unwrap().host(); - host.list() + if !host + .list() .iter() .any(|info| info.manifest.bus_name.as_str() == BUS_NAME) - && host.retained_host_callback_binding(BUS_NAME).is_some() + { + return false; + } + // A loaded module answers with its retained binding and prepares nothing. + resolver + .ensure_loaded_with_host_callback_factory(ID, |_attempt| async { + Err::<(Arc, serde_json::Value), _>( + "nothing to prepare for a loaded module".to_owned(), + ) + }) + .await + .is_ok_and(|retained| retained.unique_name() == prepared.unique_name()) } #[tokio::test(flavor = "multi_thread")] @@ -198,7 +218,7 @@ async fn a_callback_module_in_the_installer_bundle_loads_with_callbacks() { ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await .expect("a bundled release loads with its storage callback"); - assert!(serving_with_callbacks().await); + assert!(serving_with_callbacks(&resolver).await); // A later load confirms the retained binding instead of re-presenting it. ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await @@ -217,7 +237,7 @@ async fn a_callback_module_in_the_release_cache_loads_with_callbacks() { ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await .expect("a cached release loads with its storage callback"); - assert!(serving_with_callbacks().await); + assert!(serving_with_callbacks(&resolver).await); } #[tokio::test(flavor = "multi_thread")] @@ -239,7 +259,7 @@ async fn a_bundled_archive_that_does_not_match_its_pin_is_refused() { .await .unwrap_err(); assert!(error.contains("installer bundle"), "{error}"); - assert!(!serving_with_callbacks().await); + assert!(!serving_with_callbacks(&resolver).await); } #[tokio::test(flavor = "multi_thread")] @@ -256,7 +276,7 @@ async fn a_cached_library_that_does_not_match_its_pin_is_refused() { .unwrap_err(); assert!(error.contains("is unavailable"), "{error}"); assert!(!error.contains("local-file"), "{error}"); - assert!(!serving_with_callbacks().await); + assert!(!serving_with_callbacks(&resolver).await); } #[tokio::test(flavor = "multi_thread")] @@ -282,5 +302,5 @@ async fn a_local_file_override_wins_over_the_release() { ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await .expect("the override loads with its storage callback"); - assert!(serving_with_callbacks().await); + assert!(serving_with_callbacks(&resolver).await); } From 62ee083c0a5d881e29e90d11845880a0f2792c26 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:19:21 +0000 Subject: [PATCH 12/24] test(ops_callback_release): align probe object path with sdk derivation The test fixture's object path now matches the path the module SDK derives from the bus name, with a doc comment explaining the relationship. This keeps the callback release test consistent with actual SDK behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../openhuman-core/src/modules/ops_callback_release_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index 3f218d3d35..47385e590d 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -34,7 +34,8 @@ const ID: &str = "callbackprobe"; const VERSION: &str = "0.0.1"; const ARCHIVE: &str = "callbackprobe-module-0.0.1.tar.gz"; const BUS_NAME: &str = "ai.tinyhumans.test.StorageProbe"; -const OBJECT_PATH: &str = "/probe"; +/// The module SDK derives the object path from the bus name. +const OBJECT_PATH: &str = "/ai/tinyhumans/test/StorageProbe"; const WRONG_DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000"; /// Re-run `test` alone in a fresh process; `true` means this is that process. From 8e65efe0a2275bfc5147f148059371c8c30e826a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:19:38 +0000 Subject: [PATCH 13/24] refactor(modules): extract shared callback load path into callback_load Moves the storage and computer callback load sequences into a new callback_load module so both paths share the same confirm and load logic. The resolver comment now documents that a staged local artifact takes precedence over the pinned release, with callback loads falling back to the installer bundle or digest-verified cache. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/ops.rs | 68 ++++++++++++++---------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops.rs b/crates/openhuman-core/src/modules/ops.rs index d1d8438f10..5ec21b6e33 100644 --- a/crates/openhuman-core/src/modules/ops.rs +++ b/crates/openhuman-core/src/modules/ops.rs @@ -39,6 +39,7 @@ use tinybus::module::{ }; use tinybus::Proxy; +mod callback_load; mod list; pub use list::list; @@ -155,10 +156,13 @@ pub fn resolver(config: &Config) -> Resolver { } } ModuleSource::Release if requires_verified_local_callbacks(record.id) => { - // These modules require a per-module storage callback, which - // TinyBus can bind only on its verified local-file path. - // Route a staged release through that rung; the callback-aware - // client below supplies the unique destination at load time. + // A staged artifact (`OPENHUMAN_MODULE_SOURCE_DIR` or + // `OPENHUMAN_MODULE_PATH`) wins over the release, as a local + // file: the search-path rung would admit it without the + // callbacks these modules need. With none staged the source + // stays the pinned release, which the callback loads in + // `callback_load` take from the installer bundle or the + // digest-verified release cache. if let Some(path) = source::local_artifact(record.id) { sources.insert( record.id.to_string(), @@ -265,25 +269,29 @@ async fn ensure_loaded_with_storage_callback( // already serving. return Ok(()); } - let mut module_config = module_config(config, id).await; - module_config["storage_callback"] = serde_json::json!(binding.unique_name().as_str()); - return resolver - .ensure_loaded_with_host_callbacks(id, std::sync::Arc::clone(binding), module_config) - .await; + return callback_load::confirm(resolver, id, binding).await; } let runtime = resolver .runtime() .await .map_err(|error| error.to_string())?; - let (binding, served) = super::storage_host::prepare_module_callbacks(runtime) - .await - .map_err(|error| error.to_string())?; - let mut module_config = module_config(config, id).await; - module_config["storage_callback"] = serde_json::json!(binding.unique_name().as_str()); - resolver - .ensure_loaded_with_host_callbacks(id, std::sync::Arc::clone(&binding), module_config) - .await?; + let config = config.clone(); + let module_id = id.to_owned(); + let (binding, served) = callback_load::load(resolver, id, move || { + let config = config.clone(); + let id = module_id.clone(); + async move { + let (binding, served) = super::storage_host::prepare_module_callbacks(runtime) + .await + .map_err(|error| error.to_string())?; + let mut module_config = module_config(&config, &id).await; + module_config["storage_callback"] = + serde_json::json!(binding.unique_name().as_str()); + Ok((binding, module_config, served)) + } + }) + .await?; loads.insert(id.to_owned(), (binding, served)); Ok(()) } @@ -353,23 +361,27 @@ async fn ensure_loaded_with_computer_callback( ) -> Result<(), String> { let mut loads = COMPUTER_CALLBACK_LOADS.lock().await; if let Some(binding) = loads.get(id) { - let module_config = module_config_with_consequence_callback(config, id, binding).await; - return resolver - .ensure_loaded_with_host_callbacks(id, std::sync::Arc::clone(binding), module_config) - .await; + return callback_load::confirm(resolver, id, binding).await; } let runtime = resolver .runtime() .await .map_err(|error| error.to_string())?; - let binding = super::browser_egress_host::prepare_consequence_callback(runtime) - .await - .map_err(|error| error.to_string())?; - let module_config = module_config_with_consequence_callback(config, id, &binding).await; - resolver - .ensure_loaded_with_host_callbacks(id, std::sync::Arc::clone(&binding), module_config) - .await?; + let config = config.clone(); + let module_id = id.to_owned(); + let (binding, ()) = callback_load::load(resolver, id, move || { + let config = config.clone(); + let id = module_id.clone(); + async move { + let binding = super::browser_egress_host::prepare_consequence_callback(runtime) + .await + .map_err(|error| error.to_string())?; + let module_config = module_config_with_consequence_callback(&config, &id, &binding).await; + Ok((binding, module_config, ())) + } + }) + .await?; loads.insert(id.to_owned(), binding); Ok(()) } From ac8a980b51d0ac923676ba20addd7bc5136c96b5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:21:40 +0000 Subject: [PATCH 14/24] fix(modules): confirm admitted host callback binding instead of re-presenting it Host callback loading now verifies that an already-admitted module still holds its binding rather than presenting the binding again, since a spent binding is refused and the single-binding path only accepts local-file sources, which broke every later call for host-served releases. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/ops.rs | 8 +++--- .../src/modules/ops_callback_release_tests.rs | 27 ++++++++++++++++--- .../src/modules/security/host_callbacks.rs | 13 ++++----- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops.rs b/crates/openhuman-core/src/modules/ops.rs index 5ec21b6e33..345e535729 100644 --- a/crates/openhuman-core/src/modules/ops.rs +++ b/crates/openhuman-core/src/modules/ops.rs @@ -39,7 +39,7 @@ use tinybus::module::{ }; use tinybus::Proxy; -mod callback_load; +pub(super) mod callback_load; mod list; pub use list::list; @@ -286,8 +286,7 @@ async fn ensure_loaded_with_storage_callback( .await .map_err(|error| error.to_string())?; let mut module_config = module_config(&config, &id).await; - module_config["storage_callback"] = - serde_json::json!(binding.unique_name().as_str()); + module_config["storage_callback"] = serde_json::json!(binding.unique_name().as_str()); Ok((binding, module_config, served)) } }) @@ -377,7 +376,8 @@ async fn ensure_loaded_with_computer_callback( let binding = super::browser_egress_host::prepare_consequence_callback(runtime) .await .map_err(|error| error.to_string())?; - let module_config = module_config_with_consequence_callback(&config, &id, &binding).await; + let module_config = + module_config_with_consequence_callback(&config, &id, &binding).await; Ok((binding, module_config, ())) } }) diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index 47385e590d..d5e11b7076 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -119,8 +119,17 @@ fn stage_release(root: &Path) -> (String, String) { builder .append_path_with_name(directory.join("modules.toml"), "modules.toml") .unwrap(); - builder.into_inner().unwrap().finish().unwrap().flush().unwrap(); - (tinybus::module::sha256_file(&archive).unwrap(), library_digest) + builder + .into_inner() + .unwrap() + .finish() + .unwrap() + .flush() + .unwrap(); + ( + tinybus::module::sha256_file(&archive).unwrap(), + library_digest, + ) } /// The registry record pinning `archive_sha256` and `library_sha256`. @@ -233,7 +242,12 @@ async fn a_callback_module_in_the_release_cache_loads_with_callbacks() { } let cache = private_root(); let (archive, library) = stage_release(cache.path()); - let resolver = resolver(record(&archive, &library), cache.path(), None, HashMap::new()); + let resolver = resolver( + record(&archive, &library), + cache.path(), + None, + HashMap::new(), + ); ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await @@ -270,7 +284,12 @@ async fn a_cached_library_that_does_not_match_its_pin_is_refused() { } let cache = private_root(); let (archive, _library) = stage_release(cache.path()); - let resolver = resolver(record(&archive, WRONG_DIGEST), cache.path(), None, HashMap::new()); + let resolver = resolver( + record(&archive, WRONG_DIGEST), + cache.path(), + None, + HashMap::new(), + ); let error = ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await diff --git a/crates/openhuman-core/src/modules/security/host_callbacks.rs b/crates/openhuman-core/src/modules/security/host_callbacks.rs index 27538a73f8..3b7c234b6a 100644 --- a/crates/openhuman-core/src/modules/security/host_callbacks.rs +++ b/crates/openhuman-core/src/modules/security/host_callbacks.rs @@ -193,14 +193,11 @@ pub(in crate::modules) async fn ensure_loaded( if state.key_path != key_path { return Err("security secret scope changed after module admission".into()); } - let module_config = state.module_config(config)?; - resolver - .ensure_loaded_with_host_callbacks( - id, - std::sync::Arc::clone(&state.binding), - module_config, - ) - .await?; + // Confirm the admitted module still retains this binding. Re-presenting + // it would be refused (a binding is spent once a module takes it), and + // the single-binding path accepts only a local-file source, so it + // failed every later call on a host served from the release. + super::super::ops::callback_load::confirm(resolver, id, &state.binding).await?; register_scope(config, state).await?; return Ok(()); } From 1288f9785ae9a4e0af86faa53189aade788e46f9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:28:22 +0000 Subject: [PATCH 15/24] test(modules): cover callback binding confirmation for release-served modules Add a test asserting that a release-served callback module confirms its admitted binding and rejects a different one, and tighten the existing pin-mismatch tests to check the specific refusal messages. Also note in the module docs that TinySecurity and TinyComputer share the callback_load helpers. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/modules/ops_callback_release_tests.rs | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index d5e11b7076..c77a76541b 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -12,7 +12,8 @@ //! the layout TinyBus fills after a verified download. Its record pins the //! archive and library digests, so a wrong digest is a refusal and not a load. //! It is loaded through [`ensure_loaded_with_storage_callback`], the path the -//! storage-backed modules take. +//! storage-backed modules take; TinySecurity and TinyComputer share its +//! `callback_load` helpers. //! //! Each test runs in its own process: the module runtime, the resolution table //! and the probe's bus name are process-wide, and TinyBus never unloads a @@ -255,6 +256,45 @@ async fn a_callback_module_in_the_release_cache_loads_with_callbacks() { assert!(serving_with_callbacks(&resolver).await); } +#[tokio::test(flavor = "multi_thread")] +async fn a_release_served_callback_module_is_confirmed_by_its_admitted_binding() { + if !isolated("a_release_served_callback_module_is_confirmed_by_its_admitted_binding") { + return; + } + // Every later TinySecurity and TinyComputer load re-checks the binding its + // first load admitted. On a release-served module that check must answer + // from the retained binding, never ask for a local-file source. + let cache = private_root(); + let (archive, library) = stage_release(cache.path()); + let resolver = resolver( + record(&archive, &library), + cache.path(), + None, + HashMap::new(), + ); + ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .expect("a cached release loads with its storage callback"); + let admitted = super::STORAGE_CALLBACK_LOADS + .lock() + .await + .get(ID) + .map(|(binding, _served)| Arc::clone(binding)) + .expect("the admitted binding is kept"); + + super::callback_load::confirm(&resolver, ID, &admitted) + .await + .expect("the admitted binding is confirmed"); + let runtime = runtime::runtime().await.unwrap(); + let (other, _served) = crate::modules::storage_host::prepare_module_callbacks(runtime) + .await + .unwrap(); + let error = super::callback_load::confirm(&resolver, ID, &other) + .await + .unwrap_err(); + assert!(error.contains("different callback binding"), "{error}"); +} + #[tokio::test(flavor = "multi_thread")] async fn a_bundled_archive_that_does_not_match_its_pin_is_refused() { if !isolated("a_bundled_archive_that_does_not_match_its_pin_is_refused") { @@ -273,7 +313,10 @@ async fn a_bundled_archive_that_does_not_match_its_pin_is_refused() { let error = ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await .unwrap_err(); + // A bundled archive that fails its pin is a broken installation: refused, + // never replaced by the release cache or a download. assert!(error.contains("installer bundle"), "{error}"); + assert!(error.contains("not cached"), "{error}"); assert!(!serving_with_callbacks(&resolver).await); } @@ -294,8 +337,10 @@ async fn a_cached_library_that_does_not_match_its_pin_is_refused() { let error = ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) .await .unwrap_err(); - assert!(error.contains("is unavailable"), "{error}"); - assert!(!error.contains("local-file"), "{error}"); + assert!( + error.contains("extracted library hash does not match its release checksum"), + "{error}" + ); assert!(!serving_with_callbacks(&resolver).await); } From f91de139f13cbea39d752a5b9620231ab39689d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:28:34 +0000 Subject: [PATCH 16/24] docs(modules): document callback module load path in ops table The ops.rs row now notes that modules taking host callbacks load through ops/callback_load.rs, describing the override, bundle, verified cache, and download fallback order so readers can find where that resolution lives. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/modules/README.md b/crates/openhuman-core/src/modules/README.md index 0221166e45..bbccfe9456 100644 --- a/crates/openhuman-core/src/modules/README.md +++ b/crates/openhuman-core/src/modules/README.md @@ -288,7 +288,7 @@ download, its `dlopen`, or its resident memory. | [`registry.rs`](registry.rs) | The compiled-in `ALL` table and `find(id)`. Records live in [`registry/`](registry/README.md), one `records_*.rs` file per module family. | | [`types.rs`](types.rs) | `ModuleRecord`, `PlatformAsset`, `LoadPolicy`, `ModuleSource`, `ModuleState`, `ModuleStatus`. | | [`host.rs`](host.rs) | The module bus: the dedicated runtime, `ModuleRuntime`, `runtime()`, `is_started()`. | -| [`ops.rs`](ops.rs) | Resolution (`ensure_loaded*`, `resolve`, `load_cached`, `load_local`), per-module configuration, overrides, the bundled and install directories, and status reporting (`list`, `state_of`). | +| [`ops.rs`](ops.rs) | Resolution (`ensure_loaded*`, `resolve`, `load_cached`, `load_local`), per-module configuration, overrides, the bundled and install directories, and status reporting (`list`, `state_of`). Modules that take host callbacks (TinySecurity, TinyMCP, TinyEventQueues, TinyChannels, TinyComputer) load through [`ops/callback_load.rs`](ops/callback_load.rs): a local-file override or staged artifact first, else the pinned release from the bundle, then the digest-verified release cache, then a download into it. | | [`boot.rs`](boot.rs) | Startup loading: search-path artifacts, then eager records. | | [`schemas.rs`](schemas.rs) | The `modules` RPC namespace and its handlers. | From 33a360cb650a592b8b2007b6dbb5d1154108fd47 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:54:47 +0000 Subject: [PATCH 17/24] test(modules): cover callback load surviving a caller timeout Add a regression test asserting that when a caller's wait on a callback module load times out, the admitted binding's host state is not stranded and the next caller finds the module serving. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/modules/ops_callback_release_tests.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index c77a76541b..dc0baf0b40 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -295,6 +295,31 @@ async fn a_release_served_callback_module_is_confirmed_by_its_admitted_binding() assert!(error.contains("different callback binding"), "{error}"); } +#[tokio::test(flavor = "multi_thread")] +async fn a_callback_load_whose_caller_gave_up_is_found_by_the_next_caller() { + if !isolated("a_callback_load_whose_caller_gave_up_is_found_by_the_next_caller") { + return; + } + // `ensure_loaded_within` bounds only the wait. A caller that gives up + // must not strand the admitted binding's host state: TinyBus keeps + // resolving, and the next caller has to find the module serving. + let cache = private_root(); + let (archive, library) = stage_release(cache.path()); + let resolver = resolver(record(&archive, &library), cache.path(), None, HashMap::new()); + + let gave_up = tokio::time::timeout( + std::time::Duration::ZERO, + ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID), + ) + .await; + assert!(gave_up.is_err(), "the first caller stops waiting"); + + ensure_loaded_with_storage_callback(&resolver, &Config::default(), ID) + .await + .expect("the next caller finds the load the first one started"); + assert!(serving_with_callbacks(&resolver).await); +} + #[tokio::test(flavor = "multi_thread")] async fn a_bundled_archive_that_does_not_match_its_pin_is_refused() { if !isolated("a_bundled_archive_that_does_not_match_its_pin_is_refused") { From 01f4cf442c27e01aa39896986214f66044499824 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:57:21 +0000 Subject: [PATCH 18/24] fix(modules): keep callback loads running when the caller stops waiting Callback-bound module loads now run on the module runtime via a new detached helper, so a caller that times out or drops its future no longer cancels the load and strands the binding's host state in the process-wide cache. The next caller would otherwise find the module serving with a binding nobody holds state for. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/ops.rs | 33 ++++++++++++++++- .../src/modules/ops/callback_load.rs | 35 ++++++++++++++++++- .../src/modules/ops_callback_release_tests.rs | 7 +++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops.rs b/crates/openhuman-core/src/modules/ops.rs index 345e535729..f1740973d9 100644 --- a/crates/openhuman-core/src/modules/ops.rs +++ b/crates/openhuman-core/src/modules/ops.rs @@ -255,10 +255,24 @@ type StorageCallbackLoads = tokio::sync::Mutex< static STORAGE_CALLBACK_LOADS: std::sync::LazyLock = std::sync::LazyLock::new(|| tokio::sync::Mutex::new(std::collections::HashMap::new())); +/// Load a storage-backed module with its storage callback, on the module +/// runtime so a caller that stops waiting cannot strand the binding. async fn ensure_loaded_with_storage_callback( resolver: &Resolver, config: &Config, id: &str, +) -> Result<(), String> { + let (task_resolver, config, id) = (resolver.clone(), config.clone(), id.to_owned()); + callback_load::detached(resolver, async move { + load_with_storage_callback(&task_resolver, &config, &id).await + }) + .await +} + +async fn load_with_storage_callback( + resolver: &Resolver, + config: &Config, + id: &str, ) -> Result<(), String> { let mut loads = STORAGE_CALLBACK_LOADS.lock().await; if let Some((binding, _served)) = loads.get(id) { @@ -349,7 +363,11 @@ async fn ensure_loaded_with_security_callback( config: &Config, id: &str, ) -> Result<(), String> { - super::security::host_callbacks::ensure_loaded(resolver, config, id).await + let (task_resolver, config, id) = (resolver.clone(), config.clone(), id.to_owned()); + callback_load::detached(resolver, async move { + super::security::host_callbacks::ensure_loaded(&task_resolver, &config, &id).await + }) + .await } #[cfg(feature = "security-module")] @@ -357,6 +375,19 @@ async fn ensure_loaded_with_computer_callback( resolver: &Resolver, config: &Config, id: &str, +) -> Result<(), String> { + let (task_resolver, config, id) = (resolver.clone(), config.clone(), id.to_owned()); + callback_load::detached(resolver, async move { + load_with_computer_callback(&task_resolver, &config, &id).await + }) + .await +} + +#[cfg(feature = "security-module")] +async fn load_with_computer_callback( + resolver: &Resolver, + config: &Config, + id: &str, ) -> Result<(), String> { let mut loads = COMPUTER_CALLBACK_LOADS.lock().await; if let Some(binding) = loads.get(id) { diff --git a/crates/openhuman-core/src/modules/ops/callback_load.rs b/crates/openhuman-core/src/modules/ops/callback_load.rs index a135658916..c36494ec94 100644 --- a/crates/openhuman-core/src/modules/ops/callback_load.rs +++ b/crates/openhuman-core/src/modules/ops/callback_load.rs @@ -15,7 +15,9 @@ //! accepts only a local-file source, so a release-served module (every shipped //! host without `OPENHUMAN_MODULE_PATH`) could neither be loaded nor //! re-checked through it. Every callback-bound load goes through [`load`] and -//! every later check through [`confirm`] instead. +//! every later check through [`confirm`] instead, each run through +//! [`detached`] so a caller that stops waiting cannot strand the binding's +//! host state. use std::collections::HashMap; use std::future::Future; @@ -23,6 +25,37 @@ use std::sync::{Arc, Mutex, PoisonError}; use tinybus::module::{HostCallbackBinding, Resolver}; +/// Run a callback load on the module runtime and wait for it. +/// +/// TinyBus finishes a resolution it has started even when every caller stops +/// waiting (`ensure_loaded_within` bounds only the wait). A callback load also +/// publishes the admitted binding's host state, a storage endpoint or a +/// consequence host, into a process-wide cache, and that publication must not +/// be cancelled with the caller either: the next caller would find the module +/// serving with a binding nobody holds the state for. Dropping the returned +/// future drops only the wait; `work` runs to completion. +/// +/// # Errors +/// +/// `work`'s error, the module bus failing to start, or the task panicking. +pub(in crate::modules) async fn detached( + resolver: &Resolver, + work: Fut, +) -> Result +where + T: Send + 'static, + Fut: Future> + Send + 'static, +{ + let runtime = resolver + .runtime() + .await + .map_err(|error| error.to_string())?; + runtime + .spawn(work) + .await + .map_err(|error| format!("the module callback load did not finish: {error}"))? +} + /// One prepared candidate: its binding, the module configuration naming it, /// and the host state that must live as long as the binding is retained. pub(in crate::modules) type Prepared = (Arc, serde_json::Value, S); diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index dc0baf0b40..c889090729 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -305,7 +305,12 @@ async fn a_callback_load_whose_caller_gave_up_is_found_by_the_next_caller() { // resolving, and the next caller has to find the module serving. let cache = private_root(); let (archive, library) = stage_release(cache.path()); - let resolver = resolver(record(&archive, &library), cache.path(), None, HashMap::new()); + let resolver = resolver( + record(&archive, &library), + cache.path(), + None, + HashMap::new(), + ); let gave_up = tokio::time::timeout( std::time::Duration::ZERO, From bdc0ddeb013886d5ad833b07bd605c55ad12e7ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 20:57:44 +0000 Subject: [PATCH 19/24] test(ops): start runtime before simulating an abandoned callback load The test now brings up the module bus before triggering the load, since boot starts it in the core and its absence was not the thing the caller was meant to give up on. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../openhuman-core/src/modules/ops_callback_release_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index c889090729..9b83aa0fee 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -311,6 +311,9 @@ async fn a_callback_load_whose_caller_gave_up_is_found_by_the_next_caller() { None, HashMap::new(), ); + // The module bus is up before any module load in the core (boot starts + // it); starting it is not what this caller gives up on. + runtime::runtime().await.unwrap(); let gave_up = tokio::time::timeout( std::time::Duration::ZERO, From de764d22cc6bda12313a97ef89dcacd12b9864cc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 3 Nov 2026 21:12:16 +0000 Subject: [PATCH 20/24] docs(modules): document callback module load paths in the README The ops.rs entry now explains that callback modules load on the module runtime, with `load` for the storage and computer first loads and `confirm` for later ones, and notes that TinySecurity prepares its own per-candidate state over the same resolver. The shared resolution order for overrides, bundle, cache, and download is spelled out for each path. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/modules/README.md b/crates/openhuman-core/src/modules/README.md index bbccfe9456..84442b41c2 100644 --- a/crates/openhuman-core/src/modules/README.md +++ b/crates/openhuman-core/src/modules/README.md @@ -288,7 +288,7 @@ download, its `dlopen`, or its resident memory. | [`registry.rs`](registry.rs) | The compiled-in `ALL` table and `find(id)`. Records live in [`registry/`](registry/README.md), one `records_*.rs` file per module family. | | [`types.rs`](types.rs) | `ModuleRecord`, `PlatformAsset`, `LoadPolicy`, `ModuleSource`, `ModuleState`, `ModuleStatus`. | | [`host.rs`](host.rs) | The module bus: the dedicated runtime, `ModuleRuntime`, `runtime()`, `is_started()`. | -| [`ops.rs`](ops.rs) | Resolution (`ensure_loaded*`, `resolve`, `load_cached`, `load_local`), per-module configuration, overrides, the bundled and install directories, and status reporting (`list`, `state_of`). Modules that take host callbacks (TinySecurity, TinyMCP, TinyEventQueues, TinyChannels, TinyComputer) load through [`ops/callback_load.rs`](ops/callback_load.rs): a local-file override or staged artifact first, else the pinned release from the bundle, then the digest-verified release cache, then a download into it. | +| [`ops.rs`](ops.rs) | Resolution (`ensure_loaded*`, `resolve`, `load_cached`, `load_local`), per-module configuration, overrides, the bundled and install directories, and status reporting (`list`, `state_of`). Modules that take host callbacks (TinySecurity, TinyMCP, TinyEventQueues, TinyChannels, TinyComputer) load on the module runtime through [`ops/callback_load.rs`](ops/callback_load.rs): `load` for the storage and computer first loads, `confirm` for every later load; TinySecurity's first load prepares its own per-candidate state in [`security/host_callbacks.rs`](security/host_callbacks.rs) over the same `Resolver::ensure_loaded_with_host_callback_factory`. Each takes a local-file override or staged artifact first, else the pinned release from the bundle, then the digest-verified release cache, then a download into it. | | [`boot.rs`](boot.rs) | Startup loading: search-path artifacts, then eager records. | | [`schemas.rs`](schemas.rs) | The `modules` RPC namespace and its handlers. | From 4a4daebe473d0904c1525e5e752e714a86aebf03 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 4 Nov 2026 01:23:09 +0000 Subject: [PATCH 21/24] test(modules): cover detached callback load inheriting caller context Adds a test asserting that work spawned on the module runtime during a detached callback load runs under the caller's context rather than the process default, since the first load reads ambient profile, agent, and storage scope. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/modules/ops_callback_release_tests.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index 9b83aa0fee..71e85a23dd 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -402,3 +402,34 @@ async fn a_local_file_override_wins_over_the_release() { .expect("the override loads with its storage callback"); assert!(serving_with_callbacks(&resolver).await); } + +#[tokio::test(flavor = "multi_thread")] +async fn a_detached_callback_load_runs_under_the_callers_context() { + use crate::core::runtime::{ContextOverlay, CoreContext, DomainSet}; + + // TinySecurity's first load prepares its secret scope inside the detached + // work, and that reads the ambient context (profile, agent, storage + // scope). The task on the module runtime must act for its caller, not + // for the process default. + let caller = CoreContext::for_test(DomainSet::full(), None).derive_with( + ContextOverlay::new(Config::default(), DomainSet::full(), Default::default()) + .session_agent("callback-load-caller"), + ); + let cache = private_root(); + let resolver = resolver( + record(WRONG_DIGEST, WRONG_DIGEST), + cache.path(), + None, + HashMap::new(), + ); + + let seen = CoreContext::scope( + caller, + super::callback_load::detached(&resolver, async { + Ok(CoreContext::current().and_then(|ctx| ctx.session_agent().map(str::to_owned))) + }), + ) + .await + .expect("the detached work runs"); + assert_eq!(seen.as_deref(), Some("callback-load-caller")); +} From fa76601b85ac8dd939f1a7169bf2a101065c4616 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 4 Nov 2026 01:30:49 +0000 Subject: [PATCH 22/24] fix(modules): keep callback loads alive when the caller gives up early Callback loads now spawn on the module bus before awaiting its runtime, so a caller that times out while host callbacks are still installing no longer cancels the load, and the work runs under the caller's propagated CoreContext so TinySecurity's first load can resolve its secret scope. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/modules/ops/callback_load.rs | 26 ++++++++++++++----- .../src/modules/ops_callback_release_tests.rs | 5 ++-- crates/openhuman-core/src/modules/runtime.rs | 11 ++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops/callback_load.rs b/crates/openhuman-core/src/modules/ops/callback_load.rs index c36494ec94..be8ee3b6c1 100644 --- a/crates/openhuman-core/src/modules/ops/callback_load.rs +++ b/crates/openhuman-core/src/modules/ops/callback_load.rs @@ -23,6 +23,7 @@ use std::collections::HashMap; use std::future::Future; use std::sync::{Arc, Mutex, PoisonError}; +use crate::core::runtime::CoreContext; use tinybus::module::{HostCallbackBinding, Resolver}; /// Run a callback load on the module runtime and wait for it. @@ -35,6 +36,13 @@ use tinybus::module::{HostCallbackBinding, Resolver}; /// serving with a binding nobody holds the state for. Dropping the returned /// future drops only the wait; `work` runs to completion. /// +/// The task is scheduled before anything is awaited, so a caller that gives +/// up while the module bus is still installing its host callbacks does not +/// lose the load either: the task awaits them itself. It runs under the +/// caller's [`CoreContext`], captured here: TinySecurity's first load prepares +/// its secret scope inside `work`, and that resolves the caller's profile, +/// agent and storage scope from the ambient context. +/// /// # Errors /// /// `work`'s error, the module bus failing to start, or the task panicking. @@ -46,13 +54,17 @@ where T: Send + 'static, Fut: Future> + Send + 'static, { - let runtime = resolver - .runtime() - .await - .map_err(|error| error.to_string())?; - runtime - .spawn(work) - .await + let work = CoreContext::propagate(work); + let resolver = resolver.clone(); + let task = crate::modules::runtime::spawn_on_bus(async move { + resolver + .runtime() + .await + .map_err(|error| error.to_string())?; + work.await + }) + .map_err(|error| error.to_string())?; + task.await .map_err(|error| format!("the module callback load did not finish: {error}"))? } diff --git a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs index 71e85a23dd..f9cd9a0a43 100644 --- a/crates/openhuman-core/src/modules/ops_callback_release_tests.rs +++ b/crates/openhuman-core/src/modules/ops_callback_release_tests.rs @@ -311,9 +311,8 @@ async fn a_callback_load_whose_caller_gave_up_is_found_by_the_next_caller() { None, HashMap::new(), ); - // The module bus is up before any module load in the core (boot starts - // it); starting it is not what this caller gives up on. - runtime::runtime().await.unwrap(); + // The bus is not started yet: the caller gives up before its host + // callbacks are installed, which must not cancel the load either. let gave_up = tokio::time::timeout( std::time::Duration::ZERO, diff --git a/crates/openhuman-core/src/modules/runtime.rs b/crates/openhuman-core/src/modules/runtime.rs index 0118e9b1d5..a8cf153261 100644 --- a/crates/openhuman-core/src/modules/runtime.rs +++ b/crates/openhuman-core/src/modules/runtime.rs @@ -80,6 +80,17 @@ where Ok(runtime_now()?.spawn(future)) } +/// Run `future` on the module bus's own executor, starting the bus if needed +/// but without awaiting its host callbacks. The task outlives whichever caller +/// spawned it; work that needs the callbacks awaits [`runtime`] inside it. +pub(crate) fn spawn_on_bus(future: F) -> tinybus::Result> +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + Ok(runtime_now()?.spawn(future)) +} + fn runtime_now() -> tinybus::Result<&'static ModuleRuntime> { let started = RUNTIME.get_or_init(|| { let names = RuntimeNames { From dab18235f1016ccfc33865962075ce4aa7265ebe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 4 Nov 2026 01:31:06 +0000 Subject: [PATCH 23/24] refactor(modules): move callback load helpers into callback_load module Relocated the storage and computer callback load functions out of ops.rs into the callback_load submodule so the callback loading logic lives alongside its supporting helpers. Behaviour is unchanged; the call sites now delegate to the moved functions. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/ops.rs | 78 +------------------ .../src/modules/ops/callback_load.rs | 78 +++++++++++++++++++ 2 files changed, 80 insertions(+), 76 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops.rs b/crates/openhuman-core/src/modules/ops.rs index f1740973d9..1b2e305314 100644 --- a/crates/openhuman-core/src/modules/ops.rs +++ b/crates/openhuman-core/src/modules/ops.rs @@ -264,51 +264,11 @@ async fn ensure_loaded_with_storage_callback( ) -> Result<(), String> { let (task_resolver, config, id) = (resolver.clone(), config.clone(), id.to_owned()); callback_load::detached(resolver, async move { - load_with_storage_callback(&task_resolver, &config, &id).await + callback_load::load_with_storage_callback(&task_resolver, &config, &id).await }) .await } -async fn load_with_storage_callback( - resolver: &Resolver, - config: &Config, - id: &str, -) -> Result<(), String> { - let mut loads = STORAGE_CALLBACK_LOADS.lock().await; - if let Some((binding, _served)) = loads.get(id) { - if matches!(state_of(id), ModuleState::Ready) { - // Already serving with this binding: a binding is spent once the - // module takes it, so re-presenting it would be refused. The module - // is process-wide, so every later workspace's client finds it - // already serving. - return Ok(()); - } - return callback_load::confirm(resolver, id, binding).await; - } - - let runtime = resolver - .runtime() - .await - .map_err(|error| error.to_string())?; - let config = config.clone(); - let module_id = id.to_owned(); - let (binding, served) = callback_load::load(resolver, id, move || { - let config = config.clone(); - let id = module_id.clone(); - async move { - let (binding, served) = super::storage_host::prepare_module_callbacks(runtime) - .await - .map_err(|error| error.to_string())?; - let mut module_config = module_config(&config, &id).await; - module_config["storage_callback"] = serde_json::json!(binding.unique_name().as_str()); - Ok((binding, module_config, served)) - } - }) - .await?; - loads.insert(id.to_owned(), (binding, served)); - Ok(()) -} - #[cfg(feature = "security-module")] type ComputerCallbackLoads = tokio::sync::Mutex< std::collections::HashMap>, @@ -378,45 +338,11 @@ async fn ensure_loaded_with_computer_callback( ) -> Result<(), String> { let (task_resolver, config, id) = (resolver.clone(), config.clone(), id.to_owned()); callback_load::detached(resolver, async move { - load_with_computer_callback(&task_resolver, &config, &id).await + callback_load::load_with_computer_callback(&task_resolver, &config, &id).await }) .await } -#[cfg(feature = "security-module")] -async fn load_with_computer_callback( - resolver: &Resolver, - config: &Config, - id: &str, -) -> Result<(), String> { - let mut loads = COMPUTER_CALLBACK_LOADS.lock().await; - if let Some(binding) = loads.get(id) { - return callback_load::confirm(resolver, id, binding).await; - } - - let runtime = resolver - .runtime() - .await - .map_err(|error| error.to_string())?; - let config = config.clone(); - let module_id = id.to_owned(); - let (binding, ()) = callback_load::load(resolver, id, move || { - let config = config.clone(); - let id = module_id.clone(); - async move { - let binding = super::browser_egress_host::prepare_consequence_callback(runtime) - .await - .map_err(|error| error.to_string())?; - let module_config = - module_config_with_consequence_callback(&config, &id, &binding).await; - Ok((binding, module_config, ())) - } - }) - .await?; - loads.insert(id.to_owned(), binding); - Ok(()) -} - #[cfg(feature = "security-module")] async fn module_config_with_consequence_callback( config: &Config, diff --git a/crates/openhuman-core/src/modules/ops/callback_load.rs b/crates/openhuman-core/src/modules/ops/callback_load.rs index be8ee3b6c1..b605317fe3 100644 --- a/crates/openhuman-core/src/modules/ops/callback_load.rs +++ b/crates/openhuman-core/src/modules/ops/callback_load.rs @@ -24,6 +24,8 @@ use std::future::Future; use std::sync::{Arc, Mutex, PoisonError}; use crate::core::runtime::CoreContext; +use crate::config::Config; +use crate::modules::types::ModuleState; use tinybus::module::{HostCallbackBinding, Resolver}; /// Run a callback load on the module runtime and wait for it. @@ -153,3 +155,79 @@ pub(in crate::modules) async fn confirm( } Ok(()) } + +/// The body [`super::ensure_loaded_with_storage_callback`] runs detached. +pub(super) async fn load_with_storage_callback( + resolver: &Resolver, + config: &Config, + id: &str, +) -> Result<(), String> { + let mut loads = super::STORAGE_CALLBACK_LOADS.lock().await; + if let Some((binding, _served)) = loads.get(id) { + if matches!(super::state_of(id), ModuleState::Ready) { + // Already serving with this binding: a binding is spent once the + // module takes it, so re-presenting it would be refused. The module + // is process-wide, so every later workspace's client finds it + // already serving. + return Ok(()); + } + return confirm(resolver, id, binding).await; + } + + let runtime = resolver + .runtime() + .await + .map_err(|error| error.to_string())?; + let config = config.clone(); + let module_id = id.to_owned(); + let (binding, served) = load(resolver, id, move || { + let config = config.clone(); + let id = module_id.clone(); + async move { + let (binding, served) = crate::modules::storage_host::prepare_module_callbacks(runtime) + .await + .map_err(|error| error.to_string())?; + let mut module_config = super::module_config(&config, &id).await; + module_config["storage_callback"] = serde_json::json!(binding.unique_name().as_str()); + Ok((binding, module_config, served)) + } + }) + .await?; + loads.insert(id.to_owned(), (binding, served)); + Ok(()) +} + +#[cfg(feature = "security-module")] +/// The body [`super::ensure_loaded_with_computer_callback`] runs detached. +pub(super) async fn load_with_computer_callback( + resolver: &Resolver, + config: &Config, + id: &str, +) -> Result<(), String> { + let mut loads = super::COMPUTER_CALLBACK_LOADS.lock().await; + if let Some(binding) = loads.get(id) { + return confirm(resolver, id, binding).await; + } + + let runtime = resolver + .runtime() + .await + .map_err(|error| error.to_string())?; + let config = config.clone(); + let module_id = id.to_owned(); + let (binding, ()) = load(resolver, id, move || { + let config = config.clone(); + let id = module_id.clone(); + async move { + let binding = crate::modules::browser_egress_host::prepare_consequence_callback(runtime) + .await + .map_err(|error| error.to_string())?; + let module_config = + super::module_config_with_consequence_callback(&config, &id, &binding).await; + Ok((binding, module_config, ())) + } + }) + .await?; + loads.insert(id.to_owned(), binding); + Ok(()) +} From be853a3de1d20b744a7b1d406a6debe040f36c8e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 4 Nov 2026 01:34:26 +0000 Subject: [PATCH 24/24] chore(core): reorder imports and reformat callback binding Reordered the imports in callback_load.rs and reflowed the consequence callback binding expression to satisfy rustfmt. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/openhuman-core/src/modules/ops/callback_load.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/openhuman-core/src/modules/ops/callback_load.rs b/crates/openhuman-core/src/modules/ops/callback_load.rs index b605317fe3..a235b96879 100644 --- a/crates/openhuman-core/src/modules/ops/callback_load.rs +++ b/crates/openhuman-core/src/modules/ops/callback_load.rs @@ -23,8 +23,8 @@ use std::collections::HashMap; use std::future::Future; use std::sync::{Arc, Mutex, PoisonError}; -use crate::core::runtime::CoreContext; use crate::config::Config; +use crate::core::runtime::CoreContext; use crate::modules::types::ModuleState; use tinybus::module::{HostCallbackBinding, Resolver}; @@ -219,9 +219,10 @@ pub(super) async fn load_with_computer_callback( let config = config.clone(); let id = module_id.clone(); async move { - let binding = crate::modules::browser_egress_host::prepare_consequence_callback(runtime) - .await - .map_err(|error| error.to_string())?; + let binding = + crate::modules::browser_egress_host::prepare_consequence_callback(runtime) + .await + .map_err(|error| error.to_string())?; let module_config = super::module_config_with_consequence_callback(&config, &id, &binding).await; Ok((binding, module_config, ()))