diff --git a/.github/ci-paths-filter.yml b/.github/ci-paths-filter.yml index 44e4f24a086..898531753b1 100644 --- a/.github/ci-paths-filter.yml +++ b/.github/ci-paths-filter.yml @@ -143,6 +143,8 @@ scripts: - '.github/workflows/ci-*.yml' - '.github/ci-paths-filter.yml' - 'scripts/ci/self-hosted/**' + - 'scripts/ci/check-module-boundaries.mjs' + - 'scripts/ci/module-boundaries.json' - 'scripts/mock-api/**' - 'scripts/*.mjs' - 'scripts/__tests__/**' diff --git a/AGENTS.md b/AGENTS.md index 601877e41b4..19847121b15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -521,6 +521,8 @@ builds after changing a gate. Use `scripts/assert-shed.sh` or ## Loadable modules and bus contracts +Hosts must interface with loadable components through their minimal `*-bus` contracts. Never import, re-export, link, or call their implementation libraries directly, including through wrapper crates or indirect dependencies. Execute component behavior through the compiled TinyBus module. If the required types or operations are missing, extend the contract and implementation in the owning repository and raise a PR against its canonical upstream first; then consume the released module and update OpenHuman’s gitlink and adapter. Do not introduce a host-side implementation workaround. + ### Submodule ownership OpenHuman is the host and orchestrator for these components. It composes them, @@ -601,7 +603,8 @@ Rules: - Never redeclare a contract type in OpenHuman. - Call members through contract constants, not string literals. - Contract crates stay synchronous and free of I/O and runtime dependencies. -- Shared wire behavior belongs in the contract. Runtime, config, and security +- Shared wire vocabulary belongs in the contract. Component algorithms execute + in the compiled module. Runtime, config, and security policy stay in the host. - Test the handwritten registry metadata against each contract's bus name and object path. diff --git a/Cargo.lock b/Cargo.lock index 2dc4f0b99ab..7279f793746 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,35 +1225,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cookie" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.7.0" @@ -4700,7 +4671,6 @@ dependencies = [ "tinychannels-runtime", "tinycomputer-accessibility", "tinycomputer-bus", - "tinyconnectors", "tinyconnectors-bus", "tinydocs-bus", "tinyflows", @@ -4709,7 +4679,7 @@ dependencies = [ "tinyflows-drivers", "tinyflows-schedule", "tinyflows-sqlite", - "tinyhosts", + "tinyhosts-bus", "tinyinference-core", "tinyinference-embeddings", "tinyinference-llm", @@ -6805,17 +6775,6 @@ dependencies = [ "socketioxide-core", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "spki" version = "0.7.3" @@ -7570,43 +7529,12 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tinyconnectors" -version = "0.13.2" -dependencies = [ - "async-trait", - "chrono", - "fs4", - "getrandom 0.4.3", - "percent-encoding", - "serde", - "serde_json", - "thiserror 2.0.20", - "tinyconnectors-bus", - "tinyconnectors-sync", - "tokio", - "tracing", - "ureq", - "url", -] - [[package]] name = "tinyconnectors-bus" -version = "0.13.2" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "tinyconnectors-sync" -version = "0.13.2" +version = "0.14.0" dependencies = [ - "async-trait", "serde", "serde_json", - "thiserror 2.0.20", - "tinyconnectors-bus", ] [[package]] @@ -7709,21 +7637,12 @@ dependencies = [ ] [[package]] -name = "tinyhosts" -version = "0.2.3" +name = "tinyhosts-bus" +version = "0.3.0" dependencies = [ - "anyhow", - "async-trait", - "base64 0.23.1", - "hex", - "percent-encoding", - "reqwest 0.12.28", "serde", "serde_json", - "sha1 0.11.0", "thiserror 2.0.20", - "tinytools", - "tracing", ] [[package]] @@ -8900,16 +8819,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", - "cookie_store", "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "socks", "ureq-proto", "utf8-zero", "webpki-roots 1.0.9", diff --git a/crates/openhuman-app/Cargo.lock b/crates/openhuman-app/Cargo.lock index d4535f388be..eca5367a945 100644 --- a/crates/openhuman-app/Cargo.lock +++ b/crates/openhuman-app/Cargo.lock @@ -1053,29 +1053,10 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ - "percent-encoding", "time", "version_check", ] -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "indexmap 2.14.2", - "log", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.7.0" @@ -1694,15 +1675,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dom_query" version = "0.27.0" @@ -3648,12 +3620,6 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -4658,7 +4624,6 @@ dependencies = [ "tinychannels-runtime", "tinycomputer-accessibility", "tinycomputer-bus", - "tinyconnectors", "tinyconnectors-bus", "tinydocs-bus", "tinyflows", @@ -4667,7 +4632,7 @@ dependencies = [ "tinyflows-drivers", "tinyflows-schedule", "tinyflows-sqlite", - "tinyhosts", + "tinyhosts-bus", "tinyinference-core", "tinyinference-embeddings", "tinyinference-llm", @@ -6685,17 +6650,6 @@ dependencies = [ "socketioxide-core", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "softbuffer" version = "0.4.8" @@ -7876,43 +7830,12 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tinyconnectors" -version = "0.13.2" -dependencies = [ - "async-trait", - "chrono", - "fs4", - "getrandom 0.4.3", - "percent-encoding", - "serde", - "serde_json", - "thiserror 2.0.20", - "tinyconnectors-bus", - "tinyconnectors-sync", - "tokio", - "tracing", - "ureq", - "url", -] - [[package]] name = "tinyconnectors-bus" -version = "0.13.2" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "tinyconnectors-sync" -version = "0.13.2" +version = "0.14.0" dependencies = [ - "async-trait", "serde", "serde_json", - "thiserror 2.0.20", - "tinyconnectors-bus", ] [[package]] @@ -8015,21 +7938,12 @@ dependencies = [ ] [[package]] -name = "tinyhosts" -version = "0.2.3" +name = "tinyhosts-bus" +version = "0.3.0" dependencies = [ - "anyhow", - "async-trait", - "base64 0.23.1", - "hex", - "percent-encoding", - "reqwest 0.12.28", "serde", "serde_json", - "sha1 0.11.0", "thiserror 2.0.20", - "tinytools", - "tracing", ] [[package]] @@ -9243,16 +9157,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", - "cookie_store", "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "socks", "ureq-proto", "utf8-zero", "webpki-roots 1.0.9", diff --git a/crates/openhuman-core/Cargo.toml b/crates/openhuman-core/Cargo.toml index cbf7cbcca3e..d5c934f65b1 100644 --- a/crates/openhuman-core/Cargo.toml +++ b/crates/openhuman-core/Cargo.toml @@ -230,14 +230,7 @@ tinymemory-integrations = { path = "../../vendor/tinymemory/crates/tinymemory-in # of it. # tinyconnectors-bus = { path = "../../vendor/tinyconnectors/crates/tinyconnectors-bus" } -# tinyconnectors — the connector module's implementation crate, linked as a -# plain Rust library (never as the loaded module) for its pure, host-agnostic -# helpers: failure classification, action-argument preparation, the daily -# trigger archive, calendar defaults and the task-recency window. -# `default-features = false` drops the `module` feature, so no TinyBus adapter or -# ABI export is compiled into the host (and no second copy of `tinybus` enters -# the graph). Live connector calls still go over the bus to the loaded module. -tinyconnectors = { path = "../../vendor/tinyconnectors/crates/tinyconnectors", default-features = false } + # tinymcp — the Model Context Protocol client, extracted out of this tree @@ -320,7 +313,9 @@ tinychannels = { version = "0.1", features = ["relay-websocket"], optional = tru # never loads a module. The kernel-floor ratchet catches exactly that, and did. tinybus = { path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros", "uds"] } tinysearch-bus = { path = "../../vendor/tinysearch/crates/tinysearch-bus" } -tinycomputer-bus = { path = "../../vendor/tinycomputer/crates/tinycomputer-bus", optional = true } +# Registry vocabulary is available even when the loader is disabled, so the +# pre-core client can identify and report an unavailable module safely. +tinycomputer-bus = { path = "../../vendor/tinycomputer/crates/tinycomputer-bus" } # Desktop accessibility middleware, linked as a plain library (not through the # tinycomputer module): the voice pipeline needs focus lookup, paste validation, # permission checks and the Globe key synchronously and in-process. Deps are @@ -550,22 +545,11 @@ tinyjuice-bus = { path = "../../vendor/tinyjuice/crates/tinyjuice-bus" } # feeds them each original over the bus: see `inference/tokenjuice/repl_tools.rs`. tinyjuice = { path = "../../vendor/tinyjuice", default-features = false, features = ["tinytools", "host"] } -# TinyHosts — the unified hosting API: one `Host` trait over a hosting provider, -# and the `launch` flow that puts a Next.js application, its database, its -# environment and its domains on it in the one order that works. -# -# The provider vocabulary stops inside the crate. What OpenHuman gets is the -# model — sites, deployments, databases, domains, analytics — so which provider -# a user pastes a key for is a config value here rather than a code path. -# -# Taken with the `vercel` provider only, which is the one that exists. -# -# Vendored as a path dependency like `tinyhumans-sdk`: the crate is not -# published to crates.io, so there is no `[patch.crates-io]` entry for it. -# After cloning: `git submodule update --init vendor/tinyhosts`. -# +# Transport-free host contract for the compiled TinyHosts provider module. +# Provider operations, deployment sequencing, and bounded source preparation +# stay in the owner repository; OpenHuman adds only the configured adapter. # Optional: exclusive to the default-OFF `hosting` feature. -tinyhosts = { path = "../../vendor/tinyhosts", default-features = false, features = ["vercel", "tools"], optional = true } +tinyhosts-bus = { path = "../../vendor/tinyhosts/crates/tinyhosts-bus", optional = true } # TinyWallet — host-agnostic multi-chain wallet primitives. Owns the address # formats themselves: parsing, validation, and the conversions between their @@ -809,7 +793,7 @@ documents = ["modules", "dep:tinydocs-bus", "tinymemory-integrations/documents-o # still only registered once a credential actually resolves — from # `[hosting].api_key` or the provider's environment variable — because a tool # that cannot work is worse than a tool that is not there. -hosting = ["dep:tinyhosts"] +hosting = ["modules", "dep:tinyhosts-bus"] # TinyMemes: remixes the final reply with the user's register, regional slang # and, sometimes, a reaction meme. Compiled into product builds; at runtime it # is still off until `OPENHUMAN_TINYMEMES` (or a build-time @@ -831,7 +815,7 @@ tinymemes = ["dep:tinymemes"] # A loaded module is trusted in-process code with this process's privileges, and # tinybus never unloads one. See `crates/openhuman-core/src/modules/` before adding an entry # to the registry. -modules = ["tinybus/modules", "dep:tinycomputer-bus"] +modules = ["tinybus/modules"] # Voice + audio_toolkit domains: STT/TTS providers, the standalone dictation # server, always-on listening, and podcast audio generation/email delivery. # Default-ON — the desktop app always ships with voice. Slim / headless builds @@ -952,7 +936,7 @@ media = ["tinyagents-harness/media"] # runtime `DomainSet::flows` flag (#4796): the feature narrows the compile-time # surface, `DomainSet` gates it at runtime. # NOTE: this gate does NOT drop `tinyagents` itself — 26+ domains consume it. -flows = ["dep:tinyflows", "dep:tinyflows-catalog", "dep:tinyflows-copilot", "tinyflows-drivers/catalog", "tinyflows-sqlite/catalog"] +flows = ["modules", "dep:tinyflows", "dep:tinyflows-catalog", "dep:tinyflows-copilot", "tinyflows-drivers/catalog", "tinyflows-sqlite/catalog"] # Skills domains: `openhuman::skills` (metadata/discovery/install), # `openhuman::skill_runtime` (SKILL.md execution + the skill_executor agent), # and `openhuman::skill_registry` (remote catalogs + the skill_setup agent). diff --git a/crates/openhuman-core/src/agent/harness/task_recency_context.rs b/crates/openhuman-core/src/agent/harness/task_recency_context.rs index ee9134b36ad..1a4d4f136c4 100644 --- a/crates/openhuman-core/src/agent/harness/task_recency_context.rs +++ b/crates/openhuman-core/src/agent/harness/task_recency_context.rs @@ -6,7 +6,7 @@ //! concept. `CURRENT_AGENT_SANDBOX_MODE` carries the calling agent's sandbox //! mode; [`TASK_RECENCY_WINDOW`] carries an optional "only data newer than //! `now - window`" hint that the `composio_execute` handler applies to a -//! curated set of task-fetch slugs (see `tinyconnectors::execute::apply_window_args`). +//! curated set of task-fetch slugs (see `tinyconnectors-bus::PrepareArguments`). //! //! Why a task-local instead of a `Tool::execute` argument: the tool trait is //! invoked from many call sites (CLI, JSON-RPC, tests, agent loops). A diff --git a/crates/openhuman-core/src/config/schema/hosting.rs b/crates/openhuman-core/src/config/schema/hosting.rs index abdfc38a9a3..4c6607c810c 100644 --- a/crates/openhuman-core/src/config/schema/hosting.rs +++ b/crates/openhuman-core/src/config/schema/hosting.rs @@ -2,7 +2,7 @@ //! //! Where the credential for a hosting provider comes from, and whether the //! `hosting_*` agent tools are offered at all. Read by -//! [`crate::hosting::credentials`] when it resolves an account, and +//! [`crate::hosting::Account`] when it resolves an account, and //! by the tool registry when it decides whether to register the tools. //! //! The key may be left empty here: an empty key falls back to the provider's own @@ -26,7 +26,7 @@ pub struct HostingConfig { pub provider: String, /// The provider API key. Empty means "read it from the environment", which - /// is [`tinyhosts::ProviderKind::credentials_from_env`]'s search order. + /// is the `TINYHOSTS_VERCEL_TOKEN`, then `VERCEL_TOKEN` environment search. #[serde(default)] pub api_key: String, diff --git a/crates/openhuman-core/src/core/observability_availability.rs b/crates/openhuman-core/src/core/observability_availability.rs index 7f24b710abc..ba022a0dfec 100644 --- a/crates/openhuman-core/src/core/observability_availability.rs +++ b/crates/openhuman-core/src/core/observability_availability.rs @@ -44,7 +44,10 @@ pub fn is_backend_unavailable_message(msg: &str) -> bool { /// events (TAURI-RUST-117K / -118J / -117Y / -113J / -113T / -113D / -113N / /// -113Q / -113X). /// -/// Anchors, all produced by module loading and nothing else: +/// Anchors, produced by module loading or the shared module client: +/// +/// - `MODULE_CALL_REPORTED:` — the shared client already emitted a sanitized +/// report; product callers must not report its returned error again; /// /// - [`crate::tools::status::MODULE_FAULT_MARKER`] — every terminal load /// failure from `modules::ops` and tinybus' `load_first_admitted` carries it; @@ -53,12 +56,36 @@ pub fn is_backend_unavailable_message(msg: &str) -> bool { /// - `the memory module failed to load` — the memory facade's rendering of the /// same cached failure. /// +/// Connector presentation strings also identify already-reported bus failures; +/// they retain their established wording without an internal marker. +/// /// A bare `could not be loaded` is deliberately not enough: config, update /// policy and workflows use it for failures that must keep paging. pub fn is_module_unavailable_message(msg: &str) -> bool { let lower = msg.to_ascii_lowercase(); - msg.contains(crate::tools::status::MODULE_FAULT_MARKER) + // Connector provider errors preserve their established product wording. + // Recognize only Composio provenance; a generic Execute failure can belong + // to another module whose adapter has not migrated to this reporting path. + let connector_output = msg.starts_with("[composio:error:") + || msg.starts_with("Composio v3 ") + || msg.starts_with("Failed to decode Composio v3 ") + || [ + tinyconnectors_bus::names::methods::LIST_CONNECTIONS_DIRECT, + tinyconnectors_bus::names::methods::LIST_TOOLS_DIRECT, + ] + .iter() + .any(|member| { + msg.strip_prefix(member) + .is_some_and(|tail| tail.starts_with(": ai.tinyhumans.tinybus.Error.Failed: ")) + }) + || (msg.starts_with("[composio] ") + && msg.contains(": ai.tinyhumans.tinybus.Error.Failed: ")); + connector_output + || msg.contains("MODULE_CALL_REPORTED:") + || msg.contains(crate::tools::status::MODULE_FAULT_MARKER) || (lower.contains("module '") && lower.contains("could not be loaded")) + || (lower.contains("module '") + && lower.contains("is unavailable: modules are disabled in configuration")) || lower.contains("the memory module failed to load") } diff --git a/crates/openhuman-core/src/core/observability_error_classification_user_state_tests.rs b/crates/openhuman-core/src/core/observability_error_classification_user_state_tests.rs index 376b26933fd..06a6b55641f 100644 --- a/crates/openhuman-core/src/core/observability_error_classification_user_state_tests.rs +++ b/crates/openhuman-core/src/core/observability_error_classification_user_state_tests.rs @@ -703,3 +703,23 @@ fn unrelated_could_not_be_loaded_errors_still_reach_sentry() { ); } } + +#[test] +fn connector_provider_output_is_already_reported_without_affecting_other_modules() { + for message in [ + "[composio:error:auth] reconnect the provider", + "Composio v3 connected_accounts failed: HTTP 401: Invalid API key", + "ListConnectionsDirect: ai.tinyhumans.tinybus.Error.Failed: Composio v3 connected_accounts failed: HTTP 401: Invalid API key", + ] { + assert_eq!(expected_error_kind(message), Some(ExpectedErrorKind::ModuleUnavailable)); + } + for message in [ + "Execute: ai.tinyhumans.tinybus.Error.Failed: private module failure", + "unrelated operation mentioned [composio:error:auth] in its payload", + ] { + assert_ne!( + expected_error_kind(message), + Some(ExpectedErrorKind::ModuleUnavailable) + ); + } +} diff --git a/crates/openhuman-core/src/core/runtime/builder.rs b/crates/openhuman-core/src/core/runtime/builder.rs index 2ca7430def6..4eb461c739b 100644 --- a/crates/openhuman-core/src/core/runtime/builder.rs +++ b/crates/openhuman-core/src/core/runtime/builder.rs @@ -491,6 +491,9 @@ impl CoreRuntime { /// to stop either: the user runs Ollama / LM Studio / MLX themselves and /// OpenHuman never spawns it. pub async fn exit_cleanup(&self) { + if let Err(error) = crate::integrations::composio::trigger_history::close_global().await { + log::warn!("[composio][history] archive shutdown failed: {error}"); + } log::debug!("[core] shutdown: exit cleanup done (no owned local runtime to stop)"); } diff --git a/crates/openhuman-core/src/flows/tinyflows/caps/ops.rs b/crates/openhuman-core/src/flows/tinyflows/caps/ops.rs index 176bb393e37..9e57be32476 100644 --- a/crates/openhuman-core/src/flows/tinyflows/caps/ops.rs +++ b/crates/openhuman-core/src/flows/tinyflows/caps/ops.rs @@ -415,7 +415,15 @@ pub(crate) async fn preflight_composio_args( // (1) Static rules — the same validation the Composio dispatch runs, hoisted // ahead of it. Only the `Err` matters here; the normalized arguments it // returns are recomputed (and used) at dispatch. - if let Err(e) = tinyconnectors::execute::prepare_execute_arguments(slug, Some(args.clone())) { + if let Err(e) = crate::integrations::composio::processing::prepare( + config, + slug, + Some(args.clone()), + None, + None, + ) + .await + { tracing::warn!(target: "flows", %slug, error = %e, "[flows] preflight: static arg rule rejected the call — failing before dispatch"); return Err(EngineError::Capability(format!("tool_call `{slug}`: {e}"))); } diff --git a/crates/openhuman-core/src/hosting/README.md b/crates/openhuman-core/src/hosting/README.md index 53327fe813b..d0a4e852a31 100644 --- a/crates/openhuman-core/src/hosting/README.md +++ b/crates/openhuman-core/src/hosting/README.md @@ -1,163 +1,48 @@ -# hosting - -Puts a workspace on the internet. This domain is the thin seam between -OpenHuman and [`tinyhosts`](../../../../vendor/tinyhosts), the unified hosting -API. TinyHosts owns everything about a provider (today, Vercel): its REST -endpoints, the upload-then-build deployment protocol, how a marketplace -database is provisioned and connected, and the order a launch runs in. This -module owns the OpenHuman side: where the credential comes from, which -workspace the agent deploys from, and whether the tools are offered at all. - -The whole domain is one file, [`mod.rs`](./mod.rs), around a single type, `Account`. - -## How it works - -At tool-registry build time, `tools::ops` (in [`src/tools/ops.rs`](../tools/ops.rs)) asks this -module for an account. If one resolves, the account's ten tools are appended -to the agent's tool list. If none resolves, nothing is registered. - -```text - tools::ops (registry build, #[cfg(feature = "hosting")]) - | - v - Account::from_config(&Config) - | - |-- [hosting].enabled == false ----------------> Ok(None) (no tools) - | - |-- ProviderKind::from_str(provider) (bad slug -> Err, logged at warn) - | - |-- [hosting].api_key set? - | yes -> Credentials::new(key) [+ with_team(team)] - | no -> provider.credentials_from_env() - | not found ---------------------> Ok(None) (no tools) - | - v - tinyhosts::connect(provider, credentials) -> Arc - | - v - Account { host, workspace_dir } - | - v - account.tools() = tinyhosts::tools::hosting_tools(&host, &workspace_dir) -``` - -`from_config` separates "absent" from "wrong". Hosting switched off, or -switched on with no key anywhere, is the ordinary state of a host nobody has -connected a provider to, so it returns `Ok(None)` and logs at `debug`. An -unknown provider slug, a blank configured key, or a client that fails to build -is a misconfiguration: it returns an error, which `tools::ops` logs at `warn` -and then skips. Startup does not fail, since nothing else in the process -depends on hosting. - -The reason absent credentials mean absent tools: a tool that is present and -cannot work is worse than one that is missing, because a model will retry it. - -Embedders that hold their own credential use `Account::connect(provider, -api_key, team, workspace_dir)` instead. It takes the provider slug and key as -plain strings, so a host (OpenCompany keeps one hosting key per company in that -company's secret store) can reach the tools without naming `tinyhosts` in its -own dependency graph. - -Configuration lives in `HostingConfig` ([`src/config/schema/hosting.rs`](../config/schema/hosting.rs)): -`enabled`, `provider` (default `vercel`), `api_key` (empty means "read the -provider's environment variable"), and `team` (empty means the personal -account). Its `Debug` impl redacts the key. - -## Layout - -| Path | What it does | -| --- | --- | -| `mod.rs` | `Account`: `from_config` credential resolution, `connect` for embedders, the shared `Arc`, `tools()`, and the `resolve_in_workspace` re-export. | - -## Key types and entry points - -- `Account` (`mod.rs`) is one hosting account plus the workspace it deploys - from. Its `Debug` prints the provider slug and workspace, never the client. -- `Account::from_config` resolves an account from `[hosting]` and the - environment, as described above. -- `Account::connect` builds an account from credentials an embedder resolved. -- `Account::tools` returns the ten `hosting_*` tools as - `Vec>`. -- `resolve_in_workspace` is re-exported from `tinyhosts::tools`. It decides - which directory an agent may deploy. - -## Agent tools - -All ten are implemented in [`vendor/tinyhosts/src/tools/`](../../../../vendor/tinyhosts/src/tools/). Tools with an -external effect return `true` from `Tool::external_effect`, which the agent -harness reads to route the call through approval. - -| Tool | Effect | -| --- | --- | -| `hosting_launch_site` | Deploys a workspace directory as a live site, optionally provisioning a database and wiring it in, setting environment variables, and attaching domains. External effect. | -| `hosting_deployment_status` | Whether a build has finished. Read-only. | -| `hosting_list_deployments` | A site's recent deployments, newest first, with status and target. Read-only. | -| `hosting_deployment_logs` | A deployment's build and runtime log events, oldest first, trimmed to the most recent. Read-only. | -| `hosting_rollback` | Points production back at an earlier deployment that already built. External effect. | -| `hosting_list_sites` | The sites on the account. Read-only. | -| `hosting_set_env` | Sets environment variables on an existing site. External effect. | -| `hosting_add_domain` | Attaches a custom domain. External effect. | -| `hosting_domain_status` | Whether a site's domains are verified and serving. Read-only. | -| `hosting_analytics` | Traffic over the last N days. Read-only. | - -### Rollback, and why there is no separate promote - -`Host::promote` covers both: a rollback is a promote of an older deployment, -and the crate models it once. The tool is named for the reason an agent -reaches for it, which is recovering a site a deploy broke. - -`hosting_rollback` reads the deployment before promoting it and refuses one -that did not finish building. `hosting_list_deployments` returns failed and -still-building deployments too, because they are the history an agent is -reading, so the id an agent picks is not necessarily one that can serve -traffic. Promoting a failed build would take the site down during an attempt -to bring it back up. - -The tool does not check that the deployment belongs to the named site. A -deployment is looked up by id alone and the provider's response may omit the -project name, so that check would refuse legitimate rollbacks. The provider -enforces it. - -## Boundaries - -- Provider behavior (endpoints, deployment states, database provisioning, - launch ordering, the tool implementations and their argument validation) - belongs to `tinyhosts` ([`vendor/tinyhosts`](../../../../vendor/tinyhosts/), upstream - `tinyhumansai/tinyhosts`). Nothing in this module knows the word - `readyState`, and nothing in `tinyhosts` knows what a workspace is. -- Approval routing belongs to the agent harness, via `external_effect`. -- There are no RPC controllers and no events: no `schemas.rs`, no `bus.rs`. - -## Gotchas - -- Two gates both have to pass. The `hosting` Cargo feature (default-off in the - contributor build, on in `scripts/ci/product-features.txt`) decides whether - `openhuman::hosting` compiles at all. A resolving credential decides whether - the tools register at runtime. -- This process never reads a managed database's secret. The provider injects - the connection string into the site's environment; OpenHuman learns only the - variable names. That is why `hosting_launch_site` reports `DATABASE_URL` - rather than a URL. -- `resolve_in_workspace` refuses absolute paths, `..` escapes, and - non-directories. It is the only place that decides what may leave the - machine, and a deployment uploads every byte under the directory it is given. -- The doc comment in `src/config/schema/hosting.rs` mentions - `crate::hosting::credentials`, which does not exist. Credential resolution is - `Account::from_config`. - -## Tests - -[`hosting_tests.rs`](./hosting_tests.rs) covers the seam only: account resolution from config and -the tool set handed back. The tools themselves, workspace containment, and the -rollback guard are tested in `tinyhosts` against a mock of the provider API. +# Hosting adapter + +OpenHuman exposes TinyHosts provider operations through the compiled TinyHosts +TinyBus module. This domain owns the host-specific policy: resolving the +Vercel credential from `[hosting]` or the documented environment variables, +scoping a deployable path to the configured workspace, and presenting the +model-facing tool declarations. It does not link the provider implementation. + +The lazy module loader is first used when a hosting tool runs. The adapter +queries `Providers`, then sends the existing `Execute` envelope. Credential +bearing requests use confidential TinyBus delivery. The portable request and +tool vocabulary is defined in `vendor/tinyhosts/crates/tinyhosts-bus`; provider +API calls, source preparation, and deployment sequencing live in the released +TinyHosts module. + +All ten names, descriptions, schemas, and approval classifications are read +from the release contract's `TOOL_DECLARATIONS_JSON`. OpenHuman keeps the +workspace containment check and checks that a rollback target is ready before +it asks the module to promote it. The module itself enforces preparation limits +and validates the uploaded snapshot. + +`hosting::Account::from_config` returns no account when hosting is disabled or +no credential resolves. Invalid providers and blank explicit credentials are +configuration errors. `Account::connect` is the embedding seam for hosts that +resolve credentials in their own secret store; it uses the supplied workspace +for module artifact discovery as well as deployable paths. Embedders with a +custom module policy or artifact root can use `Account::connect_with_config`. + +`Account::host()` was removed with the provider implementation dependency. Its +return type exposed TinyHosts' concrete implementation trait, which cannot be +kept in the host crate without restoring that dependency. Callers should use +the stable `Account::tools()` surface or the TinyHosts bus contract directly. + +The ignored `released_artifact_serves_providers_and_confidential_local_preparation` +test exercises the pinned artifact through OpenHuman's configured module loader +and real private TinyBus. It queries `Providers`, then sends a confidential +`PrepareBundle` for a temporary local HTML file and checks the returned typed +snapshot. This operation is local and does not contact a hosting provider. Run +it with the released library at hand: ```bash -cargo test -p openhuman --features hosting hosting:: +OPENHUMAN_TINYHOSTS_TEST_MODULE=/path/to/libtinyhosts.so \ + scripts/ci-cancel-aware.sh cargo +stable test --locked \ + --manifest-path Cargo.toml -p openhuman --no-default-features \ + --features 'hosting,crash-reporting' \ + hosting::test::released_artifact_serves_providers_and_confidential_local_preparation \ + -- --ignored --exact ``` - -## Further reading - -- [Parent module README](../../README.md) -- [Hosting](../../../../gitbooks/features/hosting.md) -- [Cloud deploy](../../../../gitbooks/features/cloud-deploy.md) -- [tinyhosts](../../../../vendor/tinyhosts/README.md) diff --git a/crates/openhuman-core/src/hosting/hosting_tests.rs b/crates/openhuman-core/src/hosting/hosting_tests.rs index a40170f4ff4..2c8cdfe4692 100644 --- a/crates/openhuman-core/src/hosting/hosting_tests.rs +++ b/crates/openhuman-core/src/hosting/hosting_tests.rs @@ -1,12 +1,8 @@ -//! Tests for hosting account resolution and the tool set it exposes. -//! -//! The provider and the tools themselves (workspace containment, argument -//! validation, the rollback guard, the declarations a model sees) are TinyHosts' -//! and are tested there. What is tested here is the seam: whether an account -//! resolves from configuration and hands the crate's tools its host and workspace. +//! Tests for OpenHuman's hosting module adapter and its tool contract. use super::*; use crate::config::Config; +use std::sync::{Arc, Mutex}; fn config_with(workspace: &std::path::Path, enabled: bool, api_key: &str) -> Config { let mut config = Config::default(); @@ -16,6 +12,59 @@ fn config_with(workspace: &std::path::Path, enabled: bool, api_key: &str) -> Con config } +/// A recording adapter fixture for OpenHuman's exact member, tuple, and +/// confidentiality intent. The TinyBus transport is covered separately by +/// the generic client fixtures and the released-module verifier. +fn account_fixture( + workspace_dir: PathBuf, + calls: Arc>>, +) -> Account { + let mut config = Config::default(); + config.workspace_dir = workspace_dir; + config.hosting.enabled = true; + config.hosting.api_key = "fixture-secret".to_owned(); + let account = Account::from_config(&config) + .expect("configuration") + .expect("account"); + let calls_out = Arc::clone(&calls); + let fixture = Arc::new( + move |member: &str, args: serde_json::Value, confidential: bool| { + calls_out + .lock() + .unwrap() + .push((member.to_owned(), args.clone(), confidential)); + if member == tinyhosts_bus::METHODS[1] { + return Ok(serde_json::json!(["vercel"]).to_string()); + } + let request = args + .as_array() + .and_then(|args| args.first()) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow::anyhow!("fixture expected one JSON argument"))?; + let request: serde_json::Value = serde_json::from_str(request)?; + let response = match request.get("operation").and_then(serde_json::Value::as_str) { + Some("prepare_bundle") => serde_json::json!({"result":"prepared_bundle","value":{ + "contract_version":[1,1], + "bundle":[{"path":"index.html","contents":"aGVsbG8="}], + "file_count":1,"total_bytes":5,"skipped_entries":0,"scanned_entries":1 + }}), + Some("launch") => serde_json::json!({"result":"launch","value":{ + "site":{"id":"site-1","name":"demo"},"created_site":true, + "database":null,"database_env_keys":[],"domains":[], + "deployment":{"id":"deploy-1","site":"demo","status":"building","url":"https://demo.example"} + }}), + Some("list_sites") => serde_json::json!({"result":"sites","value":[]}), + _ => return Err(anyhow::anyhow!("unexpected fixture operation")), + }; + Ok(response.to_string()) + }, + ); + Account { + call_fixture: Some(fixture), + ..account + } +} + #[test] fn hosting_off_yields_no_account() { let workspace = tempfile::tempdir().expect("tempdir"); @@ -35,7 +84,7 @@ fn a_configured_key_yields_an_account() { .expect("resolution does not fail") .expect("an account, since a key is configured"); - assert_eq!(account.host().kind().as_str(), "vercel"); + assert_eq!(account.provider, "vercel"); assert_eq!(account.workspace_dir(), workspace.path()); } @@ -97,6 +146,339 @@ fn an_account_exposes_every_hosting_tool() { "hosting_analytics", ] ); + for tool in account.tools() { + assert!(!tool.description().is_empty()); + assert_eq!(tool.parameters_schema()["type"], "object"); + } +} + +#[test] +fn tool_declarations_match_the_released_module_contract() { + let workspace = tempfile::tempdir().expect("tempdir"); + let account = Account::from_config(&config_with(workspace.path(), true, "token")) + .expect("resolution") + .expect("account"); + let tools = account.tools(); + let declarations: Vec = + serde_json::from_str(tinyhosts_bus::TOOL_DECLARATIONS_JSON).expect("contract json"); + assert_eq!(tools.len(), declarations.len()); + for (tool, declaration) in tools.iter().zip(declarations) { + assert_eq!(tool.name(), declaration.name); + assert_eq!(tool.description(), declaration.description); + assert_eq!(tool.parameters_schema(), declaration.parameters_schema); + assert_eq!(tool.external_effect(), declaration.external_effect); + assert_eq!( + tool.permission_level(), + match declaration.permission_level { + tinyhosts_bus::PermissionLevel::ReadOnly => tinytools::PermissionLevel::ReadOnly, + tinyhosts_bus::PermissionLevel::Write => tinytools::PermissionLevel::Write, + } + ); + } +} + +#[test] +fn the_module_registry_pins_the_released_hosts_artifact_set() { + let record = crate::modules::registry::find("tinyhosts").expect("registered module"); + assert_eq!(record.version, "0.3.0"); + assert_eq!(record.assets.len(), 11); + assert!(record.assets.iter().all(|asset| { + asset.archive.starts_with("tinyhosts-0.3.0-") && asset.sha256.len() == 64 + })); + assert_eq!( + record.asset_for("ubuntu-24.04-x86_64").unwrap().sha256, + "368f00ee397649481ff8f61d33eeb8b908cc416d6b2f9d4ef3c81263d6b0460e" + ); +} + +#[cfg(feature = "modules")] +#[tokio::test] +#[ignore = "requires OPENHUMAN_TINYHOSTS_TEST_MODULE pointing to the released v0.3.0 native artifact"] +async fn released_artifact_serves_providers_and_confidential_local_preparation() { + use base64::Engine as _; + + let artifact = std::env::var_os("OPENHUMAN_TINYHOSTS_TEST_MODULE") + .expect("set OPENHUMAN_TINYHOSTS_TEST_MODULE to the released TinyHosts v0.3.0 library"); + let workspace = tempfile::tempdir().expect("workspace"); + let site = workspace.path().join("site"); + std::fs::create_dir(&site).expect("site directory"); + let html = b"module fixture"; + std::fs::write(site.join("index.html"), html).expect("fixture HTML"); + + let mut config = config_with(workspace.path(), true, "fixture-api-key"); + config.modules.allow_download = false; + config + .modules + .overrides + .push(crate::config::schema::ModuleOverride { + id: "tinyhosts".to_owned(), + path: artifact.to_string_lossy().into_owned(), + }); + let account = Account::connect_with_config("vercel", "fixture-api-key", None, config) + .expect("account configuration"); + let workspace = workspace + .path() + .canonicalize() + .expect("canonical workspace"); + let prepared = account + .execute(serde_json::json!({ + "operation": "prepare_bundle", + "directory": { "workspace": workspace, "path": "site" } + })) + .await + .expect("released module Providers and PrepareBundle calls"); + let prepared: tinyhosts_bus::preparation::PreparedBundle = + serde_json::from_value(prepared).expect("typed prepared bundle"); + + assert_eq!(prepared.contract_version, (1, 1)); + assert_eq!(prepared.file_count, 1); + assert_eq!(prepared.total_bytes, html.len() as u64); + assert_eq!(prepared.bundle.len(), 1); + assert_eq!(prepared.bundle[0].path, "index.html"); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(&prepared.bundle[0].contents) + .expect("base64 file content"), + html, + "the native preparation operation returns only the local fixture bytes" + ); +} + +#[tokio::test] +async fn providers_and_launch_use_the_declared_bus_members_and_confidential_execute() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::create_dir(workspace.path().join("app")).unwrap(); + std::fs::write(workspace.path().join("app/index.html"), "hello").unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let account = account_fixture(workspace.path().to_path_buf(), Arc::clone(&calls)); + let tools = account.tools(); + let launch = tools + .iter() + .find(|tool| tool.name() == "hosting_launch_site") + .unwrap(); + let result = launch + .execute(serde_json::json!({"site":"demo","path":"app"})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result + .markdown_formatted + .as_deref() + .unwrap() + .contains("deploy-1")); + + let calls = calls.lock().unwrap(); + assert_eq!( + calls.len(), + 3, + "Providers and two Execute operations are expected" + ); + assert_eq!( + calls[0], + ("Providers".to_owned(), serde_json::Value::Null, false) + ); + assert_eq!(calls[1].0, "Execute"); + assert!(calls[1].2, "PrepareBundle uses confidential Execute"); + assert_eq!(calls[2].0, "Execute"); + assert!( + calls[2].2, + "Launch carries credentials and must be confidential" + ); + let prepared: serde_json::Value = + serde_json::from_str(calls[1].1[0].as_str().unwrap()).unwrap(); + assert_eq!(prepared["operation"], "prepare_bundle"); + assert_eq!(prepared["directory"]["path"], "app"); + assert!(prepared["credentials"]["api_key"].as_str().unwrap() == "fixture-secret"); + let launched: serde_json::Value = + serde_json::from_str(calls[2].1[0].as_str().unwrap()).unwrap(); + assert_eq!(launched["operation"], "launch"); + assert_eq!(launched["plan"]["bundle"][0]["path"], "index.html"); +} + +#[tokio::test] +async fn disabled_module_loader_does_not_fall_back_to_a_linked_provider() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut config = config_with(workspace.path(), true, "fixture-secret"); + config.modules.enabled = false; + let account = Account::from_config(&config).unwrap().unwrap(); + let tools = account.tools(); + let list = tools + .iter() + .find(|tool| tool.name() == "hosting_list_sites") + .unwrap(); + let result = list.execute(serde_json::json!({})).await.unwrap(); + assert!(result.is_error); + assert!(result.content[0] + .render() + .starts_with("MODULE_CALL_REPORTED:")); +} + +#[tokio::test] +async fn connect_with_config_preserves_embedding_module_policy() { + let workspace = tempfile::tempdir().expect("workspace"); + let mut config = Config::default(); + config.workspace_dir = workspace.path().to_path_buf(); + config.modules.enabled = false; + let account = Account::connect_with_config("vercel", "fixture-secret", None, config) + .expect("account uses the supplied host configuration"); + assert_eq!(account.workspace_dir(), workspace.path()); + let list = account + .tools() + .into_iter() + .find(|tool| tool.name() == "hosting_list_sites") + .unwrap(); + let result = list.execute(serde_json::json!({})).await.unwrap(); + assert!(result.is_error); + assert!(result.content[0] + .render() + .starts_with("MODULE_CALL_REPORTED:")); +} + +#[tokio::test] +async fn launch_rejects_a_forbidden_workspace_path_before_any_module_call() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::create_dir(workspace.path().join(".ssh")).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let account = account_fixture(workspace.path().to_path_buf(), Arc::clone(&calls)); + let launch = account + .tools() + .into_iter() + .find(|tool| tool.name() == "hosting_launch_site") + .unwrap(); + + let result = launch + .execute(serde_json::json!({"site":"demo", "path":".ssh"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(calls.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn confidential_execute_failure_is_not_retried_as_an_ordinary_call() { + let workspace = tempfile::tempdir().expect("workspace"); + let config = config_with(workspace.path(), true, "never-render-this-secret"); + let account = Account::from_config(&config).unwrap().unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let calls_out = Arc::clone(&calls); + let fixture = Arc::new( + move |member: &str, args: serde_json::Value, confidential: bool| { + calls_out + .lock() + .unwrap() + .push((member.to_owned(), args, confidential)); + if member == tinyhosts_bus::METHODS[1] { + Ok(serde_json::json!(["vercel"]).to_string()) + } else { + Err(anyhow::anyhow!( + "MODULE_CALL_REPORTED: module transport failed" + )) + } + }, + ); + let account = Account { + call_fixture: Some(fixture), + ..account + }; + let list = account + .tools() + .into_iter() + .find(|tool| tool.name() == "hosting_list_sites") + .unwrap(); + + let result = list.execute(serde_json::json!({})).await.unwrap(); + assert!(result.is_error); + assert!(result.content[0] + .render() + .contains("MODULE_CALL_REPORTED: module execution failed")); + assert!(!result.content[0] + .render() + .contains("never-render-this-secret")); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 2, "one Providers call and one failed Execute"); + assert_eq!(calls[0].0, "Providers"); + assert_eq!(calls[1].0, "Execute"); + assert!( + calls[1].2, + "the failed credential-bearing call remains confidential" + ); +} + +#[cfg(feature = "crash-reporting")] +#[test] +fn an_operation_with_the_wrong_typed_outcome_is_reported_once_without_reply_data() { + let workspace = tempfile::tempdir().expect("workspace"); + let config = config_with(workspace.path(), true, "never-render-this-secret"); + let account = Account::from_config(&config).unwrap().unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let calls_out = Arc::clone(&calls); + let fixture = Arc::new( + move |member: &str, args: serde_json::Value, confidential: bool| { + calls_out + .lock() + .unwrap() + .push((member.to_owned(), args.clone(), confidential)); + if member == tinyhosts_bus::METHODS[1] { + return Ok(serde_json::json!(["vercel"]).to_string()); + } + Ok(serde_json::json!({"result":"launch","value":{ + "site":{"id":"private-id","name":"private-site"}, + "created_site":true, + "database":null, + "database_env_keys":[], + "domains":[], + "deployment":{"id":"private-deployment","site":"private-site","status":"building"} + }}) + .to_string()) + }, + ); + let account = Account { + call_fixture: Some(fixture), + ..account + }; + let list = account + .tools() + .into_iter() + .find(|tool| tool.name() == "hosting_list_sites") + .unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let events = sentry::test::with_captured_events(|| { + runtime.block_on(async { + let result = list.execute(serde_json::json!({})).await.unwrap(); + assert!(result.is_error); + assert!(result.content[0] + .render() + .contains("MODULE_CALL_REPORTED: module execution failed")); + assert!(!result.content[0].render().contains("private-site")); + }); + }); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!( + events[0].tags.get("reason_code").map(String::as_str), + Some("module_fault") + ); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[1].2); +} + +#[test] +fn workspace_resolution_refuses_absolute_traversal_and_symlink_escape() { + let workspace = tempfile::tempdir().expect("workspace"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::create_dir(workspace.path().join("app")).unwrap(); + std::fs::create_dir(workspace.path().join(".ssh")).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap(); + assert!(resolve_in_workspace(workspace.path(), "app").is_ok()); + assert!(resolve_in_workspace(workspace.path(), "../outside").is_err()); + assert!(resolve_in_workspace(workspace.path(), ".ssh").is_err()); + assert!(resolve_in_workspace(workspace.path(), outside.path().to_str().unwrap()).is_err()); + #[cfg(unix)] + assert!(resolve_in_workspace(workspace.path(), "escape").is_err()); } #[test] diff --git a/crates/openhuman-core/src/hosting/mod.rs b/crates/openhuman-core/src/hosting/mod.rs index a4af9235ea3..78e73f26933 100644 --- a/crates/openhuman-core/src/hosting/mod.rs +++ b/crates/openhuman-core/src/hosting/mod.rs @@ -1,156 +1,264 @@ -//! Hosting: putting a workspace on the internet. +//! Hosting tools backed by the compiled TinyHosts module. //! -//! This domain is the seam between OpenHuman and [`tinyhosts`], the unified -//! hosting API. TinyHosts owns everything about a provider — Vercel's endpoints, -//! its deployment protocol, how a marketplace database is provisioned and -//! connected. This module owns everything about *OpenHuman*: where the -//! credential comes from, which directory an agent is allowed to deploy, and how -//! the result is described back to a model. -//! -//! The split is the point. Nothing here knows the word "project" or -//! "readyState", and nothing in `tinyhosts` knows what a workspace is. -//! -//! # What the agent gets -//! -//! Ten tools, in [`tinyhosts::tools`]. `hosting_launch_site` is the one that matters: it -//! turns a directory in the workspace into a live site with a database behind -//! it. `hosting_rollback` is its counterweight — it points production back at -//! an earlier deployment, so an agent that ships a broken site has a way back -//! rather than only a way forward. The rest read or adjust what they produced. -//! -//! # The credential -//! -//! [`Account::from_config`] resolves one from `[hosting].api_key`, falling back -//! to the provider's own environment variables. It returns `Ok(None)` — not an -//! error — when hosting is switched off or no credential resolves, and the -//! registry then does not register the tools at all. A tool that is present and -//! cannot work is worse than one that is absent: a model will retry it. +//! OpenHuman owns credentials, workspace authorization, and tool policy. The +//! provider operations and deployment lifecycle run in the pinned `tinyhosts` +//! TinyBus module through its transport-free `tinyhosts-bus` contract. use std::path::PathBuf; -use std::str::FromStr; use std::sync::Arc; -use tinyhosts::{Credentials, Host, ProviderKind}; - use crate::config::Config; -pub use tinyhosts::tools::resolve_in_workspace; - #[cfg(test)] #[path = "hosting_tests.rs"] mod test; -/// One hosting account the agent may act on, and the workspace it deploys from. +mod tools; + +/// Resolve a path beneath the workspace root, rejecting absolute paths, +/// traversal, escapes through symlinks, and non-directories. +pub use tools::resolve_in_workspace; + +/// One configured hosting account and its authorized workspace. #[derive(Clone)] pub struct Account { - host: Arc, + client: crate::modules::client::ModuleClient, + provider: String, + api_key: String, + team: Option, workspace_dir: PathBuf, + providers_loaded: Arc>, + #[cfg(test)] + call_fixture: + Option anyhow::Result + Send + Sync>>, } impl std::fmt::Debug for Account { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("Account") - .field("provider", &self.host.kind().as_str()) + .field("provider", &self.provider) + .field("api_key", &"") .field("workspace_dir", &self.workspace_dir) .finish() } } impl Account { - /// Resolves the configured hosting account, if there is one. - /// - /// Returns `Ok(None)` when `[hosting].enabled` is false, or when no - /// credential can be found — neither is a failure, it is the ordinary state - /// of a host nobody has connected a hosting provider to. - /// - /// # Errors - /// - /// Returns an error only when the configuration is *wrong* rather than - /// absent: an unknown provider slug, a blank configured key, or a client - /// that cannot be built. + /// Resolve the enabled provider and credential without loading its module. pub fn from_config(config: &Config) -> anyhow::Result> { if !config.hosting.enabled { return Ok(None); } - - let provider = ProviderKind::from_str(&config.hosting.provider)?; - let credentials = if config.hosting.has_api_key() { - let credentials = Credentials::new(config.hosting.api_key.clone())?; - match config.hosting.team() { - Some(team) => credentials.with_team(team), - None => credentials, - } + let provider = config.hosting.provider.trim().to_ascii_lowercase(); + if provider != "vercel" { + anyhow::bail!("unknown hosting provider `{}`", config.hosting.provider); + } + let (api_key, uses_environment_credential) = if config.hosting.has_api_key() { + (config.hosting.api_key.trim().to_owned(), false) } else { - match provider.credentials_from_env() { - Ok(credentials) => credentials, - // No key anywhere. The tools are simply not offered. - Err(error) => { - tracing::debug!( - provider = provider.as_str(), - %error, - "[hosting] enabled but no credential resolved; tools not registered" - ); - return Ok(None); - } - } + ( + ["TINYHOSTS_VERCEL_TOKEN", "VERCEL_TOKEN"] + .into_iter() + .find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .unwrap_or_default(), + true, + ) }; - - let host = tinyhosts::connect(provider, credentials)?; - tracing::info!( - provider = provider.as_str(), - "[hosting] hosting tools enabled" - ); - + if api_key.is_empty() { + tracing::debug!( + provider, + "[hosting] no credential resolved; tools not registered" + ); + return Ok(None); + } Ok(Some(Self { - host: Arc::from(host), + client: crate::modules::client::ModuleClient::new(config.clone()), + provider, + api_key, + team: config + .hosting + .team() + .map(str::to_owned) + .or_else(|| uses_environment_credential.then(env_team).flatten()), workspace_dir: config.workspace_dir.clone(), + providers_loaded: Arc::new(tokio::sync::OnceCell::new()), + #[cfg(test)] + call_fixture: None, })) } - /// Builds an account from credentials an embedder resolved itself. - /// - /// OpenCompany holds one hosting credential per company, in that company's - /// secret store rather than in this process's configuration, and deploys - /// into that company's workspace. This is the seam it uses: the provider - /// slug and key as strings, so an embedder does not have to name - /// `tinyhosts` in its own dependency graph to reach the tools. - /// - /// # Errors - /// - /// Returns an error for an unknown provider slug, a blank key, or a client - /// that cannot be built. + /// Build an account from a credential supplied by an embedding host. pub fn connect( provider: &str, api_key: &str, team: Option<&str>, workspace_dir: PathBuf, ) -> anyhow::Result { - let provider = ProviderKind::from_str(provider)?; - let credentials = Credentials::new(api_key)?; - let credentials = match team { - Some(team) => credentials.with_team(team), - None => credentials, - }; + let mut config = Config::default(); + config.workspace_dir = workspace_dir; + Self::connect_with_config(provider, api_key, team, config) + } + /// Connect using embedding-host configuration, including its module + /// artifact roots and loader policy. + pub fn connect_with_config( + provider: &str, + api_key: &str, + team: Option<&str>, + config: Config, + ) -> anyhow::Result { + if provider.trim().to_ascii_lowercase() != "vercel" { + anyhow::bail!("unknown hosting provider `{provider}`"); + } + if api_key.trim().is_empty() { + anyhow::bail!("hosting API key must not be blank"); + } + let workspace_dir = config.workspace_dir.clone(); Ok(Self { - host: Arc::from(tinyhosts::connect(provider, credentials)?), + client: crate::modules::client::ModuleClient::new(config), + provider: "vercel".to_owned(), + api_key: api_key.trim().to_owned(), + team: team + .map(str::trim) + .filter(|team| !team.is_empty()) + .map(str::to_owned), workspace_dir, + providers_loaded: Arc::new(tokio::sync::OnceCell::new()), + #[cfg(test)] + call_fixture: None, }) } - /// The provider client, shared by every tool. - pub fn host(&self) -> Arc { - Arc::clone(&self.host) - } - - /// The workspace the deployable directories live under. + /// The workspace whose directories can be deployed. pub fn workspace_dir(&self) -> &PathBuf { &self.workspace_dir } - /// The agent tools this account exposes. + /// The model-facing tools from the released TinyHosts declaration bundle. pub fn tools(&self) -> Vec> { - tinyhosts::tools::hosting_tools(&self.host, &self.workspace_dir) + tools::hosting_tools(self.clone()) + } + + async fn execute(&self, operation: serde_json::Value) -> anyhow::Result { + use serde_json::json; + self.providers_loaded + .get_or_try_init(|| async { + let response = self.call_providers().await?; + let providers: Vec = serde_json::from_str(&response).map_err(|_| { + crate::modules::client::ModuleClient::report_malformed_reply("tinyhosts"); + anyhow::anyhow!( + crate::modules::client::ModuleCallError::ModuleFault.to_string() + ) + })?; + if !providers.iter().any(|provider| provider == &self.provider) { + crate::modules::client::ModuleClient::report_unavailable("tinyhosts"); + anyhow::bail!(crate::modules::client::ModuleCallError::Unavailable.to_string()); + } + Ok::<(), anyhow::Error>(()) + }) + .await?; + let mut request = operation; + let object = request + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("hosting operation must be an object"))?; + object.insert("provider".into(), json!(self.provider)); + object.insert( + "credentials".into(), + json!({"api_key": self.api_key, "team": self.team}), + ); + let operation_name = request + .get("operation") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + let request = serde_json::to_string(&request)?; + let outcome: tinyhosts_bus::rpc::Outcome = self + .call_execute_json(&request) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + if !outcome_matches(&operation_name, &outcome) { + crate::modules::client::ModuleClient::report_malformed_reply("tinyhosts"); + return Err(anyhow::anyhow!( + crate::modules::client::ModuleCallError::ModuleFault.to_string() + )); + } + let value = serde_json::to_value(outcome)?; + Ok(value + .get("value") + .cloned() + .unwrap_or(serde_json::Value::Null)) + } + + async fn call_providers(&self) -> anyhow::Result { + #[cfg(test)] + if let Some(fixture) = &self.call_fixture { + return fixture(tinyhosts_bus::METHODS[1], serde_json::Value::Null, false); + } + self.client + .call::("tinyhosts", tinyhosts_bus::METHODS[1], ()) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + async fn call_execute_json( + &self, + request: &str, + ) -> Result { + #[cfg(test)] + if let Some(fixture) = &self.call_fixture { + let response = fixture( + tinyhosts_bus::METHODS[0], + serde_json::json!([request]), + true, + ) + .map_err(|_| crate::modules::client::ModuleCallError::ModuleFault)?; + return serde_json::from_str(&response).map_err(|_| { + crate::modules::client::ModuleClient::report_malformed_reply("tinyhosts"); + crate::modules::client::ModuleCallError::ModuleFault + }); + } + self.client + .call_confidential_json::( + "tinyhosts", + tinyhosts_bus::METHODS[0], + (request,), + ) + .await } } + +fn outcome_matches(operation: &str, outcome: &tinyhosts_bus::rpc::Outcome) -> bool { + use tinyhosts_bus::rpc::Outcome; + match operation { + "prepare_bundle" => matches!(outcome, Outcome::PreparedBundle(_)), + "launch" => matches!(outcome, Outcome::Launch(_)), + "create_site" => matches!(outcome, Outcome::Site(_)), + "find_site" => matches!(outcome, Outcome::Site(_) | Outcome::NoSite), + "list_sites" => matches!(outcome, Outcome::Sites(_)), + "set_env" | "attach_database" | "promote" => matches!(outcome, Outcome::Done), + "list_env" => matches!(outcome, Outcome::Env(_)), + "provision_database" => matches!(outcome, Outcome::Database(_)), + "deploy" | "deployment" => matches!(outcome, Outcome::Deployment(_)), + "list_deployments" => matches!(outcome, Outcome::Deployments(_)), + "deployment_logs" => matches!(outcome, Outcome::DeploymentLogs(_)), + "add_domain" => matches!(outcome, Outcome::Domain(_)), + "list_domains" => matches!(outcome, Outcome::Domains(_)), + "analytics" => matches!(outcome, Outcome::Analytics(_)), + _ => false, + } +} + +fn env_team() -> Option { + ["TINYHOSTS_VERCEL_TEAM_ID", "VERCEL_TEAM_ID"] + .into_iter() + .find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} diff --git a/crates/openhuman-core/src/hosting/tools.rs b/crates/openhuman-core/src/hosting/tools.rs new file mode 100644 index 00000000000..38a3f2e0fe0 --- /dev/null +++ b/crates/openhuman-core/src/hosting/tools.rs @@ -0,0 +1,484 @@ +//! OpenHuman tool adapters over the released TinyHosts bus vocabulary. + +use std::path::{Component, Path, PathBuf}; + +use async_trait::async_trait; +use serde_json::{Value, json}; +use tinyhosts_bus::{PermissionLevel as ContractPermission, ToolDeclaration}; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +use super::Account; + +pub(super) fn hosting_tools(account: Account) -> Vec> { + let declarations: Vec = + serde_json::from_str(tinyhosts_bus::TOOL_DECLARATIONS_JSON) + .expect("released TinyHosts tool declarations are valid"); + declarations + .into_iter() + .map(|declaration| { + Box::new(HostingTool { + account: account.clone(), + declaration, + }) as Box + }) + .collect() +} + +pub fn resolve_in_workspace(workspace_dir: &Path, relative: &str) -> anyhow::Result { + let relative = relative.trim(); + let candidate = Path::new(if relative.is_empty() { "." } else { relative }); + if candidate.is_absolute() + || candidate.components().any(|part| { + matches!( + part, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + anyhow::bail!("path must stay inside the workspace"); + } + let root = workspace_dir + .canonicalize() + .map_err(|_| anyhow::anyhow!("cannot access the workspace directory"))?; + if crate::security::SecurityPolicy::is_always_forbidden(&root) { + anyhow::bail!("workspace path is always forbidden"); + } + let canonical = root + .join(candidate) + .canonicalize() + .map_err(|_| anyhow::anyhow!("cannot access the requested workspace directory"))?; + if !canonical.starts_with(&root) { + anyhow::bail!("path escapes the workspace"); + } + if crate::security::SecurityPolicy::is_always_forbidden(&canonical) { + anyhow::bail!("workspace path is always forbidden"); + } + if !canonical.is_dir() { + anyhow::bail!("requested workspace path is not a directory"); + } + Ok(canonical) +} + +#[derive(Debug)] +struct HostingTool { + account: Account, + declaration: ToolDeclaration, +} + +impl HostingTool { + async fn run(&self, args: Value) -> anyhow::Result { + match self.declaration.name.as_str() { + "hosting_launch_site" => self.launch(args).await, + "hosting_deployment_status" => { + let id = required_str(&args, "deployment_id")?; + self.pretty(operation("deployment", json!({"id": id}))) + .await + } + "hosting_list_deployments" => { + let site = required_str(&args, "site")?; + let limit = bounded(&args, "limit", 20, 1, 100); + self.pretty(operation( + "list_deployments", + json!({"site": site, "limit": limit}), + )) + .await + } + "hosting_deployment_logs" => { + let id = required_str(&args, "deployment_id")?; + let limit = bounded(&args, "limit", 100, 1, 1000) as usize; + let outcome = self + .account + .execute(operation("deployment_logs", json!({"id": id}))) + .await?; + let mut logs = outcome; + if let Some(items) = logs.as_array_mut() { + if items.len() > limit { + *items = items.split_off(items.len() - limit); + } + } + Ok(ToolResult::success(serde_json::to_string_pretty(&logs)?)) + } + "hosting_rollback" => self.rollback(args).await, + "hosting_list_sites" => { + let limit = bounded(&args, "limit", 20, 1, 100); + self.pretty(operation("list_sites", json!({"limit": limit}))) + .await + } + "hosting_set_env" => self.set_env(args).await, + "hosting_add_domain" => self.add_domain(args).await, + "hosting_domain_status" => { + let site = required_str(&args, "site")?; + self.pretty(operation("list_domains", json!({"site": site}))) + .await + } + "hosting_analytics" => self.analytics(args).await, + _ => Ok(ToolResult::error("hosting tool declaration is unsupported")), + } + } + + async fn pretty(&self, request: Value) -> anyhow::Result { + let value = self.account.execute(request).await?; + Ok(ToolResult::success(serde_json::to_string_pretty(&value)?)) + } + + async fn launch(&self, args: Value) -> anyhow::Result { + let site = required_str(&args, "site")?; + let relative = args.get("path").and_then(Value::as_str).unwrap_or("."); + let workspace = self + .account + .workspace_dir() + .canonicalize() + .map_err(|_| anyhow::anyhow!("cannot access the workspace directory"))?; + let source = resolve_in_workspace(&workspace, relative)?; + let relative_source = source + .strip_prefix(&workspace) + .map_err(|_| anyhow::anyhow!("path escapes the workspace"))?; + let prepared = self + .account + .execute(operation( + "prepare_bundle", + json!({ + "directory": {"workspace": workspace, "path": relative_source} + }), + )) + .await?; + let bundle = prepared + .get("bundle") + .cloned() + .ok_or_else(|| anyhow::anyhow!("hosting module returned an invalid prepared bundle"))?; + let mut env = Vec::new(); + if let Some(values) = args.get("env").and_then(Value::as_object) { + for (key, value) in values { + env.push(json!({"key": key, "value": env_string(key, value)?, "targets": [], "secret": false})); + } + } + let database = args.get("database").and_then(Value::as_str).map(str::trim).filter(|name| !name.is_empty()).map(|name| { + json!({"name": name, "kind": args.get("database_kind").and_then(Value::as_str).unwrap_or("postgres")}) + }); + let domains: Vec = args + .get("domains") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect(); + let target = if args + .get("production") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "production" + } else { + "preview" + }; + let plan = json!({ + "site": {"name": site}, "bundle": bundle, "database": database, + "env": env, "domains": domains, "target": target + }); + let result = self + .account + .execute(operation("launch", json!({"plan": plan}))) + .await?; + let text = launch_summary(&result); + Ok(ToolResult::success_with_markdown(result, text)) + } + + async fn set_env(&self, args: Value) -> anyhow::Result { + let site = required_str(&args, "site")?; + let Some(env) = args.get("env").and_then(Value::as_object) else { + return Ok(ToolResult::error("`env` must be an object of variables")); + }; + let targets = if args + .get("production_only") + .and_then(Value::as_bool) + .unwrap_or(false) + { + vec!["production"] + } else { + Vec::new() + }; + let secret = args.get("secret").and_then(Value::as_bool).unwrap_or(false); + let mut vars = Vec::with_capacity(env.len()); + for (key, value) in env { + vars.push(json!({"key": key, "value": env_string(key, value)?, "targets": targets, "secret": secret})); + } + self.account + .execute(operation("set_env", json!({"site": site, "vars": vars}))) + .await?; + Ok(ToolResult::success(format!( + "Set {} on {site}. Redeploy the site for a build-time variable to take effect.", + env.keys().cloned().collect::>().join(", ") + ))) + } + + async fn add_domain(&self, args: Value) -> anyhow::Result { + let site = required_str(&args, "site")?; + let domain = required_str(&args, "domain")?; + let outcome = self + .account + .execute(operation( + "add_domain", + json!({"site": site, "domain": domain}), + )) + .await?; + let name = outcome + .get("name") + .and_then(Value::as_str) + .unwrap_or(&domain); + if outcome + .get("verified") + .and_then(Value::as_bool) + .unwrap_or(false) + { + Ok(ToolResult::success(format!( + "{name} is attached to {site} and verified." + ))) + } else { + Ok(ToolResult::success(format!( + "{name} is attached to {site} but not verified yet: its DNS records still have to point at the provider." + ))) + } + } + + async fn rollback(&self, args: Value) -> anyhow::Result { + let site = required_str(&args, "site")?; + let id = required_str(&args, "deployment_id")?; + let deployment = self + .account + .execute(operation("deployment", json!({"id": id}))) + .await?; + if !rollback_target_is_ready(&deployment) { + let status = rollback_status(&deployment); + return Ok(ToolResult::error(format!( + "Deployment `{id}` is {status}, so it cannot be promoted — only a deployment that finished building can serve traffic. Use hosting_list_deployments to find one that is ready." + ))); + } + self.account + .execute(operation( + "promote", + json!({"site": site, "deployment": id}), + )) + .await?; + Ok(ToolResult::success( + match deployment.get("url").and_then(Value::as_str) { + Some(url) => format!( + "{site} is now serving deployment `{id}` in production ({url}). The change is at the provider's edge; nothing was rebuilt." + ), + None => format!( + "{site} is now serving deployment `{id}` in production. The change is at the provider's edge; nothing was rebuilt." + ), + }, + )) + } + + async fn analytics(&self, args: Value) -> anyhow::Result { + let site = required_str(&args, "site")?; + let days = bounded(&args, "days", 7, 1, 365) as u64; + let until = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) + }); + let breakdown = match args.get("breakdown").and_then(Value::as_str) { + None => None, + Some("country") => Some("country"), + Some("request_path") => Some("request_path"), + Some("device_type") => Some("device_type"), + Some("browser_name") => Some("browser_name"), + Some("os_name") => Some("os_name"), + Some("referrer_hostname") => Some("referrer_hostname"), + Some("route") => Some("route"), + Some(other) => { + return Ok(ToolResult::error(format!( + "`breakdown` must be one of country, request_path, device_type, \ + browser_name, os_name, referrer_hostname, route — not `{other}`" + ))); + } + }; + self.pretty(operation( + "analytics", + json!({"query": { + "site": site, "since_ms": until.saturating_sub(days * 24 * 60 * 60 * 1000), + "until_ms": until, "breakdown": breakdown, "limit": 10 + }}), + )) + .await + } +} + +#[async_trait] +impl Tool for HostingTool { + fn name(&self) -> &str { + &self.declaration.name + } + fn description(&self) -> &str { + &self.declaration.description + } + fn parameters_schema(&self) -> Value { + self.declaration.parameters_schema.clone() + } + fn permission_level(&self) -> PermissionLevel { + match self.declaration.permission_level { + ContractPermission::ReadOnly => PermissionLevel::ReadOnly, + ContractPermission::Write => PermissionLevel::Write, + } + } + fn external_effect(&self) -> bool { + self.declaration.external_effect + } + fn supports_markdown(&self) -> bool { + self.name() == "hosting_launch_site" + } + async fn execute(&self, args: Value) -> anyhow::Result { + match self.run(args).await { + Ok(result) => Ok(result), + Err(error) => Ok(ToolResult::error(error.to_string())), + } + } + async fn execute_with_options( + &self, + args: Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + self.execute(args).await + } +} + +fn operation(name: &str, mut fields: Value) -> Value { + if let Some(object) = fields.as_object_mut() { + object.insert("operation".into(), json!(name)); + } + fields +} + +fn required_str(args: &Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| anyhow::anyhow!("`{key}` is required")) +} + +fn env_string(key: &str, value: &Value) -> anyhow::Result { + match value { + Value::String(value) => Ok(value.clone()), + Value::Number(_) | Value::Bool(_) => Ok(value.to_string()), + _ => anyhow::bail!("`env.{key}` must be a string, number, or boolean"), + } +} + +fn bounded(args: &Value, key: &str, default: u64, min: u64, max: u64) -> u32 { + args.get(key) + .and_then(Value::as_i64) + .map(|value| value.clamp(min as i64, max as i64) as u64) + .or_else(|| args.get(key).and_then(Value::as_u64)) + .unwrap_or(default) + .clamp(min, max) as u32 +} + +fn launch_summary(launch: &Value) -> String { + let site = launch + .pointer("/site/name") + .and_then(Value::as_str) + .unwrap_or("site"); + let created = launch + .get("created_site") + .and_then(Value::as_bool) + .unwrap_or(false); + let deployment = launch + .pointer("/deployment/id") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let status = launch + .pointer("/deployment/status") + .and_then(Value::as_str) + .map(status_debug) + .unwrap_or("unknown"); + let mut lines = vec![format!( + "Site **{site}** ({}), deployment `{deployment}` is {status}.", + if created { + "created" + } else { + "already existed" + } + )]; + match launch.pointer("/deployment/url").and_then(Value::as_str) { + Some(url) => lines.push(format!("It will serve from {url} once the build finishes — poll `hosting_deployment_status` with the deployment id.")), + None => lines.push("The provider has not assigned a URL yet; poll `hosting_deployment_status` with the deployment id.".to_owned()), + } + if let Some(database) = launch.get("database").filter(|value| !value.is_null()) { + let name = database + .get("name") + .and_then(Value::as_str) + .unwrap_or("database"); + let kind = database + .pointer("/kind") + .and_then(Value::as_str) + .unwrap_or("database"); + let status = database + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let keys = launch + .get("database_env_keys") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + lines.push(format!("Database **{name}** ({kind}) is {status}; it injected {} into the site's environment. The values are the provider's — nothing here can read them.", if keys.is_empty() { "no variables".to_owned() } else { keys.join(", ") })); + } + if let Some(domains) = launch + .get("domains") + .and_then(Value::as_array) + .filter(|domains| !domains.is_empty()) + { + let unverified = domains + .iter() + .filter(|domain| { + !domain + .get("verified") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|domain| domain.get("name").and_then(Value::as_str)) + .collect::>(); + if unverified.is_empty() { + lines.push("Every domain is verified.".to_owned()); + } else { + lines.push(format!("These domains still need their DNS records pointed at the provider before they serve traffic: {}.", unverified.join(", "))); + } + } + lines.join("\n\n") +} + +fn status_debug(status: &str) -> &'static str { + match status { + "queued" => "Queued", + "building" => "Building", + "ready" => "Ready", + "failed" => "Failed", + "canceled" => "Canceled", + _ => "Other", + } +} + +fn rollback_target_is_ready(deployment: &Value) -> bool { + deployment.get("status").and_then(Value::as_str) == Some("ready") +} + +fn rollback_status(deployment: &Value) -> &str { + deployment + .get("status") + .and_then(Value::as_str) + .map(status_debug) + .unwrap_or("unknown status") +} + +#[cfg(test)] +#[path = "tools_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/hosting/tools_tests.rs b/crates/openhuman-core/src/hosting/tools_tests.rs new file mode 100644 index 00000000000..bcfe306f07e --- /dev/null +++ b/crates/openhuman-core/src/hosting/tools_tests.rs @@ -0,0 +1,27 @@ +use super::*; + +#[test] +fn rollback_refuses_everything_except_a_ready_deployment() { + for status in ["queued", "building", "failed", "canceled", "unknown"] { + let deployment = json!({"status": status}); + assert!(!rollback_target_is_ready(&deployment), "{status}"); + assert_ne!(rollback_status(&deployment), "Ready"); + } + let ready = json!({"status": "ready"}); + assert!(rollback_target_is_ready(&ready)); + assert_eq!(rollback_status(&ready), "Ready"); +} + +#[test] +fn launch_summary_keeps_the_model_facing_status_and_wait_guidance() { + let launch = json!({ + "site": {"name": "demo"}, + "created_site": true, + "deployment": {"id": "dpl_1", "status": "building", "url": "https://demo.example"}, + "database": null, + "domains": [] + }); + let summary = launch_summary(&launch); + assert!(summary.contains("Site **demo** (created), deployment `dpl_1` is Building.")); + assert!(summary.contains("poll `hosting_deployment_status`")); +} diff --git a/crates/openhuman-core/src/integrations/composio/README.md b/crates/openhuman-core/src/integrations/composio/README.md index b971f63a209..3ab70276aca 100644 --- a/crates/openhuman-core/src/integrations/composio/README.md +++ b/crates/openhuman-core/src/integrations/composio/README.md @@ -63,7 +63,7 @@ triggers) from a real transport failure. | [`crates/openhuman-core/src/integrations/composio/identity_store.rs`](./identity_store.rs) | Persists connected-account identities in `/integrations/composio_identities.json`. | | [`crates/openhuman-core/src/integrations/composio/file_store.rs`](./file_store.rs) | Atomic JSON file helpers for the identity and user-scope files under `/integrations/`. With a storage backend configured the same values are `composio_state` documents under the acting agent's scope ([`file_store_documents.rs`](./file_store_documents.rs)) and the files are not used. | | [`crates/openhuman-core/src/integrations/composio/direct_auth/mod.rs`](./direct_auth/mod.rs) | Direct-mode API-key health tracking: a process-local consecutive-401-failure counter (keyed by a non-logged key fingerprint) that short-circuits repeated invalid-key polling. | -| [`crates/openhuman-core/src/integrations/composio/trigger_history.rs`](./trigger_history.rs) | Process-global `OnceLock` handle to `tinyconnectors::triggers::TriggerArchive` (`init_global`/`global`). | +| [`crates/openhuman-core/src/integrations/composio/trigger_history.rs`](./trigger_history.rs) | Lazy host lifecycle owner of a module-side opaque archive handle (`init_global`/`global`). | | [`crates/openhuman-core/src/integrations/composio/bus.rs`](./bus.rs) + `bus/` | Trigger, connection-created and config-changed subscribers, and their registration. | | [`crates/openhuman-core/src/integrations/composio/providers/mod.rs`](./providers/mod.rs) | Re-exports the curated catalogs, scope verdicts, identity vocabulary and run types from `contract/`. | | [`crates/openhuman-core/src/integrations/composio/contract/`](./contract/) | The Composio vocabulary: catalogs, scopes, profiles, run shapes, task shapes. Plain data and pure functions. | @@ -146,7 +146,7 @@ Published from `ops/` via `crate::core::bus::BUS.publish` (`crate::core::events: ## Persistence -- **Trigger history** (`trigger_history.rs`): JSONL records under `/state/triggers/YYYY-MM-DD.jsonl`, partitioned by UTC day, written by `tinyconnectors::triggers::TriggerArchive` (exclusive file lock on append) behind a process-global `OnceLock` handle. Exposed via `composio.list_trigger_history`. +- **Trigger history** (`trigger_history.rs`): JSONL records under `/state/triggers/YYYY-MM-DD.jsonl`, partitioned by UTC day, written inside the TinyConnectors module (exclusive file lock on append). The host keeps an opaque lease, closes it on sign-out/shutdown, and releases the old lease before switching user directories. Exposed via `composio.list_trigger_history`. - **Direct-mode API key**: stored in the encrypted keychain (via `credentials`); never logged/returned. `direct_auth/mod.rs` additionally tracks a process-local (non-persisted) consecutive-401 counter for the same key. - **Connected identities**: `/integrations/composio_identities.json` (`identity_store.rs`). **User scope prefs**: `/integrations/composio_user_scopes.json` (`ops::user_scopes`). Both written atomically (temp file + rename) under one process-wide lock (`file_store.rs`). - **Integrations cache**: warmed in the background after app startup/sign-in, then kept for the process lifetime. Connection create/delete, config changes, and a divergent `list_connections` response invalidate it; the change paths eagerly re-warm it. Idle time does not trigger a backend fetch on a chat turn (`connected_integrations.rs`). @@ -199,3 +199,10 @@ Published from `ops/` via `crate::core::bus::BUS.publish` (`crate::core::events: - [Parent module README](../README.md) - [Third-party integrations](../../../../../gitbooks/features/integrations/README.md) - [tinyconnectors](../../../../../vendor/tinyconnectors/README.md) + +Connector argument preparation, calendar defaults, task-window filtering and +provider classification use the v0.14.0 module's contract 1.13 operations. The +host supplies configuration and policy context; it links only +`tinyconnectors-bus`. Trigger history opens lazily through `OpenArchive`, with +`RecordTrigger`, `ReadArchive` and `CloseArchive` handling all filesystem work. +Cancelling an archive-open caller does not discard its eventual resource handle. diff --git a/crates/openhuman-core/src/integrations/composio/action_tool.rs b/crates/openhuman-core/src/integrations/composio/action_tool.rs index 73152b78b02..1c4a9ead13f 100644 --- a/crates/openhuman-core/src/integrations/composio/action_tool.rs +++ b/crates/openhuman-core/src/integrations/composio/action_tool.rs @@ -347,11 +347,18 @@ impl ComposioActionTool { iana = %iana, "[composio][per-action] applying calendar query defaults pre-dispatch" ); - let args = tinyconnectors::execute::apply_calendar_query_defaults( + let args = match super::processing::prepare( + &live_config, &self.action_name, Some(args), - &iana, - ); + Some(iana), + None, + ) + .await + { + Ok(args) => args, + Err(error) => return (live_config, Ok(ToolResult::error(error))), + }; let started = std::time::Instant::now(); // Allow the agent to override the baked-in connection_id via args diff --git a/crates/openhuman-core/src/integrations/composio/bus/trigger_subscriber.rs b/crates/openhuman-core/src/integrations/composio/bus/trigger_subscriber.rs index 5728137e0bf..654a82c126e 100644 --- a/crates/openhuman-core/src/integrations/composio/bus/trigger_subscriber.rs +++ b/crates/openhuman-core/src/integrations/composio/bus/trigger_subscriber.rs @@ -94,47 +94,25 @@ impl EventHandler for ComposioTriggerSubscriber { } if let Some(store) = trigger_history::global() { - let toolkit_owned = toolkit.clone(); - let trigger_owned = trigger.clone(); - let metadata_id_owned = metadata_id.clone(); - let metadata_uuid_owned = metadata_uuid.clone(); - let payload_owned = payload.clone(); - - match tokio::task::spawn_blocking(move || { - store.record( - &toolkit_owned, - &trigger_owned, - &metadata_id_owned, - &metadata_uuid_owned, - &payload_owned, - ) - }) - .await - { - Ok(Ok(_)) => {} - Ok(Err(error)) => { - tracing::warn!( - toolkit = %toolkit, - trigger = %trigger, - error = %error, - "[composio][history] failed to archive trigger" - ); + match config_rpc::load_config_with_timeout().await { + Ok(config) => { + let event = super::super::types::ComposioTriggerEvent { + toolkit: toolkit.clone(), + trigger: trigger.clone(), + payload: payload.clone(), + metadata: super::super::types::ComposioTriggerMetadata { + id: metadata_id.clone(), + uuid: metadata_uuid.clone(), + }, + }; + if let Err(error) = store.record(&config, event).await { + tracing::warn!(error = %error, "[composio][history] failed to archive trigger"); + } } Err(error) => { - tracing::warn!( - toolkit = %toolkit, - trigger = %trigger, - error = %error, - "[composio][history] failed to join archive task" - ); + tracing::warn!(error = %error, "[composio][history] archive configuration unavailable") } } - } else { - tracing::debug!( - toolkit = %toolkit, - trigger = %trigger, - "[composio][history] archive store not initialized" - ); } if triage_disabled() { diff --git a/crates/openhuman-core/src/integrations/composio/googlecalendar_args.rs b/crates/openhuman-core/src/integrations/composio/googlecalendar_args.rs index 6190972005c..506c93a43d8 100644 --- a/crates/openhuman-core/src/integrations/composio/googlecalendar_args.rs +++ b/crates/openhuman-core/src/integrations/composio/googlecalendar_args.rs @@ -1,7 +1,7 @@ //! Host time-zone lookup for Google Calendar defaults. //! //! The defaulting itself (`timeZone` + `singleEvents` for calendar list/find -//! actions, issue #1714) is `tinyconnectors::execute::apply_calendar_query_defaults`. +//! actions, issue #1714) is the connector module’s `PrepareArguments` operation. //! It takes the zone as a parameter because reading the machine's zone is host //! business; this is that lookup. diff --git a/crates/openhuman-core/src/integrations/composio/mod.rs b/crates/openhuman-core/src/integrations/composio/mod.rs index e42c581743c..755a3f82ad5 100644 --- a/crates/openhuman-core/src/integrations/composio/mod.rs +++ b/crates/openhuman-core/src/integrations/composio/mod.rs @@ -53,10 +53,11 @@ pub mod identity; pub mod identity_store; pub mod module_client; pub mod ops; +pub(crate) mod processing; pub mod providers; pub mod schemas; pub mod tools; -pub mod trigger_history; +pub(crate) mod trigger_history; pub mod types; pub use crate::agent::prompts::types::ConnectedIntegration; diff --git a/crates/openhuman-core/src/integrations/composio/module_client.rs b/crates/openhuman-core/src/integrations/composio/module_client.rs index 93d9d37f5cf..d68cdf46908 100644 --- a/crates/openhuman-core/src/integrations/composio/module_client.rs +++ b/crates/openhuman-core/src/integrations/composio/module_client.rs @@ -130,7 +130,10 @@ where Request: serde::Serialize + Send, Reply: serde::de::DeserializeOwned, { - Err(format!("{member}: {WITHOUT_MODULES}")) + crate::modules::client::ModuleClient::new(_config.clone()) + .call::("tinyconnectors", member, (_request,)) + .await + .map_err(|error| format!("{member}: {error}; {WITHOUT_MODULES}")) } /// Give an already-serving connector module the route the current config @@ -187,7 +190,10 @@ where Request: serde::Serialize + Send, Reply: serde::de::DeserializeOwned, { - Err(format!("{member}: {WITHOUT_MODULES}")) + crate::modules::client::ModuleClient::new(_config.clone()) + .call::("tinyconnectors", member, (_request,)) + .await + .map_err(|error| format!("{member}: {error}; {WITHOUT_MODULES}")) } /// The message a stateless member's failure carries, with the transport layers @@ -242,7 +248,10 @@ pub async fn call_bare( _config: &crate::config::Config, member: &str, ) -> Result { - Err(format!("{member}: {WITHOUT_MODULES}")) + crate::modules::client::ModuleClient::new(_config.clone()) + .call::("tinyconnectors", member, ()) + .await + .map_err(|error| format!("{member}: {error}; {WITHOUT_MODULES}")) } /// Serializes every test that reaches the connector module. diff --git a/crates/openhuman-core/src/integrations/composio/ops/error_utils.rs b/crates/openhuman-core/src/integrations/composio/ops/error_utils.rs index e138c1bda30..9d58ef7fef8 100644 --- a/crates/openhuman-core/src/integrations/composio/ops/error_utils.rs +++ b/crates/openhuman-core/src/integrations/composio/ops/error_utils.rs @@ -119,27 +119,28 @@ pub(crate) fn backend_mode_without_session(config: &Config) -> bool { } } -/// Defense-in-depth Sentry funnel for composio op-layer errors. +/// Report host-side Composio faults without provider content or credentials. /// -/// The shared [`crate::integrations::IntegrationClient`] -/// (which fronts every `client.list_*` / `client.execute_tool` / -/// `client.authorize` call) already reports its own failures under -/// `domain="integrations"` with `failure="non_2xx" | "transport"` tags, -/// and the Sentry `before_send` filter (`is_transient_integrations_failure`) -/// drops the transient subset. This helper re-classifies the same -/// anyhow chain at the **op layer** under `domain="composio"` so: -/// -/// 1. Future call sites that bypass `IntegrationClient` still funnel through -/// the same classifier. -/// 2. Op-layer-specific failures get tagged consistently rather than -/// reaching Sentry as bare `Err(String)` returned via RPC. +/// Module failures have already emitted a sanitized terminal event at the bus +/// boundary. Recognize their established wire/presentation forms rather than +/// modifying user-visible messages or reporting them again. Host failures keep +/// only the existing failure/status classification and a static message. pub(crate) fn report_composio_op_error(operation: &str, err: &E) { let rendered = format!("{err:#}"); + let reported_member = tinyconnectors_bus::METHODS.iter().any(|member| { + rendered + .strip_prefix(member) + .is_some_and(|tail| tail.starts_with(": ai.tinyhumans.tinybus.Error.Failed: ")) + }); + if reported_member || crate::core::observability::is_module_unavailable_message(&rendered) { + // Bus failures are reported once, with sanitized module metadata. + return; + } let failure_tag = classify_composio_failure_tag(rendered.as_str()); if failure_tag == "non_2xx" { if let Some(status) = extract_backend_returned_status(&rendered) { crate::core::observability::report_error_or_expected( - rendered.as_str(), + "connector operation failed", "composio", operation, &[("failure", failure_tag), ("status", status.as_str())], @@ -148,7 +149,7 @@ pub(crate) fn report_composio_op_error(operation: } } crate::core::observability::report_error_or_expected( - rendered.as_str(), + "connector operation failed", "composio", operation, &[("failure", failure_tag)], diff --git a/crates/openhuman-core/src/integrations/composio/ops/triggers.rs b/crates/openhuman-core/src/integrations/composio/ops/triggers.rs index 19185b8e0aa..3d5fa4123b6 100644 --- a/crates/openhuman-core/src/integrations/composio/ops/triggers.rs +++ b/crates/openhuman-core/src/integrations/composio/ops/triggers.rs @@ -19,8 +19,7 @@ use super::super::types::{ ComposioCreateTriggerRequest, ComposioCreateTriggerResponse, ComposioDisableTriggerRequest, ComposioDisableTriggerResponse, ComposioEnableTriggerRequest, ComposioEnableTriggerResponse, ComposioGithubReposResponse, ComposioListAvailableTriggersRequest, - ComposioListGithubReposRequest, ComposioListTriggerHistoryRequest, ComposioListTriggersRequest, - ComposioTriggerHistoryResult, + ComposioListGithubReposRequest, ComposioListTriggersRequest, ComposioTriggerHistoryResult, }; use super::error_utils::{report_composio_op_error, OpResult}; @@ -145,22 +144,38 @@ pub async fn composio_enable_trigger( trigger_config, }, ) - .await - .map_err(|e| { - report_composio_op_error("enable_trigger", &anyhow::anyhow!("{e}")); - // Enabling is the one trigger call a user drives directly from a - // settings screen, so its failures are mapped to something a person can - // act on ("reconnect GitHub") rather than the provider's own wording. - let class = tinyconnectors::execute::classify_composio_error(slug, &e); - let mapped = tinyconnectors::execute::format_provider_error(slug, &e); - tracing::warn!( - slug = %slug, - connection_id = %connection_id, - class = class.as_str(), - "[composio] enable_trigger failed; surfacing mapped error" - ); - mapped - })?; + .await; + let resp = match resp { + Ok(response) => response, + Err(e) => { + if e.contains("MODULE_CALL_REPORTED:") { + return Err(e); + } + report_composio_op_error("enable_trigger", &anyhow::anyhow!("{e}")); + // Enabling is the one trigger call a user drives directly from a + // settings screen, so its failures are mapped to something a person can + // act on ("reconnect GitHub") rather than the provider's own wording. + let classified: tinyconnectors_bus::ProviderError = + crate::modules::client::ModuleClient::new(config.clone()) + .call( + "tinyconnectors", + methods::CLASSIFY_ERROR, + (tinyconnectors_bus::ClassifyErrorRequest { + tool: slug.to_owned(), + message: e, + },), + ) + .await + .map_err(|error| error.to_string())?; + tracing::warn!( + slug = %slug, + connection_id = %connection_id, + class = classified.class.as_str(), + "[composio] enable_trigger failed; surfacing mapped error" + ); + return Err(classified.message); + } + }; let trigger_id = resp.trigger_id.clone(); Ok(Outcome::new( resp, @@ -209,15 +224,13 @@ pub async fn composio_list_trigger_history( "[composio] rpc list_trigger_history" ); - let history = connectors::call::<_, ComposioTriggerHistoryResult>( - config, - methods::LIST_TRIGGER_HISTORY, - ComposioListTriggerHistoryRequest { - limit: Some(requested_limit), - }, - ) - .await - .map_err(|error| format!("[composio] list_trigger_history failed: {error}"))?; + let store = super::super::trigger_history::global().ok_or_else(|| { + "[composio] list_trigger_history failed: trigger archive owner unavailable".to_owned() + })?; + let history = store + .read(config, Some(requested_limit)) + .await + .map_err(|error| format!("[composio] list_trigger_history failed: {error}"))?; let count = history.entries.len(); Ok(Outcome::new( diff --git a/crates/openhuman-core/src/integrations/composio/processing.rs b/crates/openhuman-core/src/integrations/composio/processing.rs new file mode 100644 index 00000000000..49aafbf7766 --- /dev/null +++ b/crates/openhuman-core/src/integrations/composio/processing.rs @@ -0,0 +1,75 @@ +//! Thin adapters for connector-owned argument and response processing. + +use crate::config::Config; +use tinyconnectors_bus::{FilterResponseRequest, PrepareArgumentsRequest, PreparedArguments}; + +use super::module_client::methods; + +/// Apply the connector's validation, calendar defaults and task window rules. +pub(crate) async fn prepare( + config: &Config, + tool: &str, + arguments: Option, + timezone: Option, + since: Option, +) -> Result, String> { + prepare_with( + &crate::modules::client::ModuleClient::new(config.clone()), + tool, + arguments, + timezone, + since, + ) + .await +} + +async fn prepare_with( + client: &crate::modules::client::ModuleClient, + tool: &str, + arguments: Option, + timezone: Option, + since: Option, +) -> Result, String> { + let reply: PreparedArguments = client + .call( + "tinyconnectors", + methods::PREPARE_ARGUMENTS, + (PrepareArgumentsRequest { + tool: tool.to_owned(), + arguments, + timezone, + since, + },), + ) + .await + .map_err(|error| error.to_string())?; + match reply.error { + Some(error) => Err(error.message), + None => Ok(reply.arguments), + } +} + +/// Filter a provider response within the separately compiled module. +pub(crate) async fn filter( + config: &Config, + tool: &str, + response: super::types::ComposioExecuteResponse, + since: String, +) -> Result { + crate::modules::client::ModuleClient::new(config.clone()) + .call( + "tinyconnectors", + methods::FILTER_RESPONSE, + (FilterResponseRequest { + tool: tool.to_owned(), + response, + since, + },), + ) + .await + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +#[path = "processing_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/integrations/composio/processing_tests.rs b/crates/openhuman-core/src/integrations/composio/processing_tests.rs new file mode 100644 index 00000000000..93bba62c17c --- /dev/null +++ b/crates/openhuman-core/src/integrations/composio/processing_tests.rs @@ -0,0 +1,101 @@ +use super::*; + +#[tokio::test] +async fn disabled_processing_never_executes_linked_calendar_or_validation_helpers() { + let mut config = Config::default(); + config.modules.enabled = false; + let result = prepare( + &config, + "GOOGLECALENDAR_LIST_EVENTS", + Some(serde_json::json!({})), + Some("UTC".into()), + None, + ) + .await; + assert!(result + .unwrap_err() + .contains("MODULE_CALL_REPORTED: module unavailable")); +} + +#[cfg(feature = "modules")] +#[derive(Clone)] +struct Fixture(std::sync::Arc>>); +#[cfg(feature = "modules")] +use tokio::sync::Mutex; + +#[cfg(feature = "modules")] +#[tinybus::interface(name = "test.ConnectorProcessing")] +impl Fixture { + async fn prepare_arguments( + &self, + request: PrepareArgumentsRequest, + ) -> tinybus::Result { + self.0.lock().await.push(request.clone()); + Ok(PreparedArguments { + arguments: Some(serde_json::json!({"module_prepared":true})), + error: (request.tool == "invalid").then(|| tinyconnectors_bus::ProviderError { + class: "validation".into(), + message: "provider validation refused the action".into(), + }), + }) + } +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn preparation_forwards_host_context_and_preserves_module_validation_errors() { + use tinybus::{broker::Broker, transport::memory::MemoryBus, Connection, ObjectPath}; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let requests = std::sync::Arc::new(Mutex::new(Vec::new())); + service + .serve_at( + ObjectPath::new("/test/processing").unwrap(), + Fixture(requests.clone()), + ) + .await + .unwrap(); + service + .request_name("test.ConnectorProcessing") + .await + .unwrap(); + let client = crate::modules::client::ModuleClient::fixture( + caller + .proxy( + "test.ConnectorProcessing", + "/test/processing", + "test.ConnectorProcessing", + ) + .unwrap(), + ); + let args = Some(serde_json::json!({"caller_argument":"value"})); + assert_eq!( + prepare_with( + &client, + "calendar", + args.clone(), + Some("Asia/Kuwait".into()), + Some("2026-10-10T00:00:00Z".into()) + ) + .await + .unwrap(), + Some(serde_json::json!({"module_prepared":true})) + ); + let captured = requests.lock().await; + assert_eq!(captured[0].arguments, args); + assert_eq!(captured[0].timezone.as_deref(), Some("Asia/Kuwait")); + assert_eq!(captured[0].since.as_deref(), Some("2026-10-10T00:00:00Z")); + drop(captured); + assert_eq!( + prepare_with(&client, "invalid", None, None, None) + .await + .unwrap_err(), + "provider validation refused the action" + ); +} diff --git a/crates/openhuman-core/src/integrations/composio/tools/execute.rs b/crates/openhuman-core/src/integrations/composio/tools/execute.rs index d09a558fdfc..d8784b3e9c7 100644 --- a/crates/openhuman-core/src/integrations/composio/tools/execute.rs +++ b/crates/openhuman-core/src/integrations/composio/tools/execute.rs @@ -236,8 +236,6 @@ impl ComposioExecuteTool { iana = %iana, "[composio][dispatcher] applying calendar query defaults pre-dispatch" ); - let arguments = - tinyconnectors::execute::apply_calendar_query_defaults(&tool, arguments, &iana); // Task-recency window (morning briefing): when the calling agent // installed a window, inject best-effort server-side narrowing for @@ -248,9 +246,22 @@ impl ComposioExecuteTool { chrono::Utc::now() - chrono::Duration::from_std(w).unwrap_or_else(|_| chrono::Duration::zero()) }); - let arguments = match task_window_since { - Some(since) => tinyconnectors::execute::apply_window_args(&tool, arguments, since), - None => arguments, + let arguments = match super::super::processing::prepare( + self.config.as_ref(), + &tool, + arguments, + Some(iana), + task_window_since.map(|since| since.to_rfc3339()), + ) + .await + { + Ok(arguments) => arguments, + Err(error) => { + return ( + Box::new(self.config.as_ref().clone()), + Ok(ToolResult::error(error)), + ) + } }; // Resolve the client through the mode-aware factory on every @@ -289,7 +300,17 @@ impl ComposioExecuteTool { // slug is a curated task-fetch action. Runs before the // markdown/JSON body decision so the agent reads filtered data. let resp = match task_window_since { - Some(since) => tinyconnectors::execute::filter_response(&tool, resp, since), + Some(since) => match super::super::processing::filter( + &live_config, + &tool, + resp, + since.to_rfc3339(), + ) + .await + { + Ok(response) => response, + Err(error) => return (live_config, Ok(ToolResult::error(error))), + }, None => resp, }; tracing::info!( diff --git a/crates/openhuman-core/src/integrations/composio/trigger_history.rs b/crates/openhuman-core/src/integrations/composio/trigger_history.rs index 43812087604..a271a24751b 100644 --- a/crates/openhuman-core/src/integrations/composio/trigger_history.rs +++ b/crates/openhuman-core/src/integrations/composio/trigger_history.rs @@ -1,42 +1,195 @@ -//! Process-global handle to the persistent Composio trigger archive. -//! -//! The archive itself (daily JSONL under `/state/triggers/`, exclusive -//! file locking on append, newest-first reads that skip corrupt lines) is -//! `tinyconnectors::triggers::TriggerArchive`. What stays here is the -//! host lifecycle: one archive per process, opened once at startup and read by -//! the trigger subscriber. +//! Lazy ownership of a module-side trigger archive; no files are opened here. use std::path::PathBuf; use std::sync::{Arc, OnceLock}; +use tinyconnectors_bus::{ + ArchiveHandle, OpenArchiveRequest, ReadArchiveRequest, RecordTriggerRequest, +}; +use tokio::sync::Mutex; -use tinyconnectors::triggers::TriggerArchive; +use super::module_client::methods; +use super::types::{ + ComposioTriggerEvent, ComposioTriggerHistoryEntry, ComposioTriggerHistoryResult, +}; +use crate::config::Config; +use crate::modules::client::ModuleClient; -static GLOBAL_TRIGGER_HISTORY: OnceLock> = OnceLock::new(); +static GLOBAL_TRIGGER_HISTORY: OnceLock> = OnceLock::new(); -/// Open the archive under `/state/triggers` and install it as the -/// process-global store. Idempotent for the same workspace; a different -/// workspace after initialization is an error. +struct Lease { + workspace: PathBuf, + client: ModuleClient, + handle: ArchiveHandle, +} + +/// Host lifecycle owner of one opaque archive lease. The module owns its files. +pub struct TriggerHistory { + initial_workspace: PathBuf, + lease: Arc>>, + #[cfg(all(test, feature = "modules"))] + fixture: Option, +} + +impl TriggerHistory { + fn client(&self, config: &Config) -> ModuleClient { + #[cfg(all(test, feature = "modules"))] + if let Some(client) = self.fixture.as_ref() { + return client.clone(); + } + ModuleClient::new(config.clone()) + } + + async fn ensure_lease( + config: &Config, + lease: &mut Option, + client: ModuleClient, + ) -> Result { + if lease + .as_ref() + .is_some_and(|current| current.workspace != config.workspace_dir) + { + Self::release(lease).await?; + } + if let Some(current) = lease.as_ref() { + return Ok(current.handle.clone()); + } + let state_dir = config.workspace_dir.join("state"); + let state_dir = state_dir + .to_str() + .ok_or_else(|| "trigger archive requires a UTF-8 state directory".to_owned())?; + let handle = client + .call( + "tinyconnectors", + methods::OPEN_ARCHIVE, + (OpenArchiveRequest { + state_dir: state_dir.to_owned(), + },), + ) + .await + .map_err(|error| error.to_string())?; + *lease = Some(Lease { + workspace: config.workspace_dir.clone(), + client, + handle, + }); + Ok(lease.as_ref().expect("lease installed").handle.clone()) + } + + async fn acquire( + &self, + config: &Config, + ) -> Result>, String> { + let lease = Arc::clone(&self.lease); + let config = config.clone(); + let client = self.client(&config); + // Keep ownership installation running if the caller is cancelled while + // OpenArchive completes. A later close can still release the handle. + tokio::spawn(async move { + let mut guard = lease.lock_owned().await; + Self::ensure_lease(&config, &mut guard, client).await?; + Ok(guard) + }) + .await + .map_err(|_| "trigger archive ownership task failed".to_owned())? + } + + async fn release(lease: &mut Option) -> Result<(), String> { + if let Some(current) = lease.as_ref() { + current + .client + .call::<()>( + "tinyconnectors", + methods::CLOSE_ARCHIVE, + (current.handle.clone(),), + ) + .await + .map_err(|error| error.to_string())?; + *lease = None; + } + Ok(()) + } + + /// Archive one delivered event, serialized with reads and scope changes. + pub async fn record( + &self, + config: &Config, + event: ComposioTriggerEvent, + ) -> Result { + let lease = self.acquire(config).await?; + let handle = lease.as_ref().expect("lease installed").handle.clone(); + lease + .as_ref() + .expect("lease installed") + .client + .call( + "tinyconnectors", + methods::RECORD_TRIGGER, + (RecordTriggerRequest { handle, event },), + ) + .await + .map_err(|error| error.to_string()) + } + + /// Read persisted history without changing the user's connector route. + pub async fn read( + &self, + config: &Config, + limit: Option, + ) -> Result { + let lease = self.acquire(config).await?; + let handle = lease.as_ref().expect("lease installed").handle.clone(); + lease + .as_ref() + .expect("lease installed") + .client + .call( + "tinyconnectors", + methods::READ_ARCHIVE, + (ReadArchiveRequest { handle, limit },), + ) + .await + .map_err(|error| error.to_string()) + } + + /// Release the module resource while retaining all persisted documents. + pub async fn close(&self) -> Result<(), String> { + let lease = Arc::clone(&self.lease); + tokio::spawn(async move { Self::release(&mut *lease.lock_owned().await).await }) + .await + .map_err(|_| "trigger archive release task failed".to_owned())? + } +} + +/// Install the lifecycle owner without downloading or loading a module. pub fn init_global(workspace_dir: PathBuf) -> Result<(), String> { - let state_dir = workspace_dir.join("state"); - let archive = Arc::new( - TriggerArchive::open(&state_dir).map_err(|error| format!("[composio][history] {error}"))?, - ); - let installed = GLOBAL_TRIGGER_HISTORY.get_or_init(|| archive.clone()); - if installed.archive_dir() == archive.archive_dir() { - tracing::debug!( - archive_dir = %archive.archive_dir().display(), - "[composio][history] archive initialized" - ); - return Ok(()); - } - Err(format!( - "[composio][history] global store already initialized for {} while attempting {}", - installed.archive_dir().display(), - archive.archive_dir().display() - )) + let installed = GLOBAL_TRIGGER_HISTORY.get_or_init(|| { + Arc::new(TriggerHistory { + initial_workspace: workspace_dir.clone(), + lease: Arc::new(Mutex::new(None)), + #[cfg(all(test, feature = "modules"))] + fixture: None, + }) + }); + if installed.initial_workspace == workspace_dir { + Ok(()) + } else { + Err("trigger archive owner already initialized for another workspace".to_owned()) + } } -/// The process-global archive, if [`init_global`] has run. -pub fn global() -> Option> { +/// The process-wide lifecycle owner, if initialized at startup. +pub fn global() -> Option> { GLOBAL_TRIGGER_HISTORY.get().cloned() } + +/// Close an existing lease on sign-out or runtime shutdown; never load at exit. +pub(crate) async fn close_global() -> Result<(), String> { + match global() { + Some(store) => store.close().await, + None => Ok(()), + } +} + +#[cfg(test)] +#[path = "trigger_history_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/integrations/composio/trigger_history_tests.rs b/crates/openhuman-core/src/integrations/composio/trigger_history_tests.rs new file mode 100644 index 00000000000..2fb046ca5c3 --- /dev/null +++ b/crates/openhuman-core/src/integrations/composio/trigger_history_tests.rs @@ -0,0 +1,159 @@ +use super::*; + +#[tokio::test] +async fn disabled_archive_operations_leave_the_host_filesystem_untouched() { + let workspace = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.workspace_dir = workspace.path().to_owned(); + config.modules.enabled = false; + let owner = TriggerHistory { + initial_workspace: config.workspace_dir.clone(), + lease: Arc::new(Mutex::new(None)), + #[cfg(feature = "modules")] + fixture: None, + }; + assert!(owner + .read(&config, Some(10)) + .await + .unwrap_err() + .contains("module unavailable")); + assert!(!workspace.path().join("state").exists()); + assert!(owner.lease.lock().await.is_none()); + owner.close().await.unwrap(); +} + +#[cfg(feature = "modules")] +#[derive(Clone, Default)] +struct Fixture { + calls: Arc>>, + open_started: Arc, + open_continue: Arc, + pause_open: bool, +} + +#[cfg(feature = "modules")] +#[tinybus::interface(name = "test.ConnectorArchive")] +impl Fixture { + async fn open_archive(&self, request: OpenArchiveRequest) -> tinybus::Result { + self.calls + .lock() + .await + .push(format!("open:{}", request.state_dir)); + if self.pause_open { + self.open_started.notify_one(); + self.open_continue.notified().await; + } + Ok(ArchiveHandle(request.state_dir)) + } + async fn read_archive( + &self, + request: ReadArchiveRequest, + ) -> tinybus::Result { + self.calls + .lock() + .await + .push(format!("read:{}", request.handle.0)); + Ok(ComposioTriggerHistoryResult { + archive_dir: "fixture".into(), + current_day_file: "fixture-day".into(), + entries: Vec::new(), + }) + } + async fn close_archive(&self, handle: ArchiveHandle) -> tinybus::Result<()> { + self.calls.lock().await.push(format!("close:{}", handle.0)); + Ok(()) + } +} + +#[cfg(feature = "modules")] +async fn connected_owner(fixture: Fixture) -> (Arc, tinybus::Connection) { + use tinybus::{broker::Broker, transport::memory::MemoryBus, Connection, ObjectPath}; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service + .serve_at(ObjectPath::new("/test/archive").unwrap(), fixture) + .await + .unwrap(); + service.request_name("test.ConnectorArchive").await.unwrap(); + let client = ModuleClient::fixture( + caller + .proxy( + "test.ConnectorArchive", + "/test/archive", + "test.ConnectorArchive", + ) + .unwrap(), + ); + ( + Arc::new(TriggerHistory { + initial_workspace: PathBuf::from("first"), + lease: Arc::new(Mutex::new(None)), + fixture: Some(client), + }), + service, + ) +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn changing_users_releases_the_old_archive_before_opening_the_next_scope() { + let fixture = Fixture::default(); + let calls = fixture.calls.clone(); + let (owner, _service) = connected_owner(fixture).await; + assert!( + calls.lock().await.is_empty(), + "constructing an owner must remain lazy" + ); + let mut config = Config::default(); + config.workspace_dir = PathBuf::from("first"); + owner.read(&config, Some(2)).await.unwrap(); + config.workspace_dir = PathBuf::from("second"); + owner.read(&config, Some(2)).await.unwrap(); + owner.close().await.unwrap(); + owner.close().await.unwrap(); + assert_eq!( + *calls.lock().await, + vec![ + format!("open:{}", PathBuf::from("first").join("state").display()), + format!("read:{}", PathBuf::from("first").join("state").display()), + format!("close:{}", PathBuf::from("first").join("state").display()), + format!("open:{}", PathBuf::from("second").join("state").display()), + format!("read:{}", PathBuf::from("second").join("state").display()), + format!("close:{}", PathBuf::from("second").join("state").display()) + ] + ); + assert!(owner.lease.lock().await.is_none()); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn cancelling_a_pending_open_retains_a_handle_that_shutdown_can_release() { + let fixture = Fixture { + pause_open: true, + ..Default::default() + }; + let observed = fixture.clone(); + let (owner, _service) = connected_owner(fixture).await; + let mut config = Config::default(); + config.workspace_dir = PathBuf::from("cancelled"); + let opening = tokio::spawn({ + let owner = owner.clone(); + async move { owner.read(&config, Some(1)).await } + }); + observed.open_started.notified().await; + opening.abort(); + assert!(opening.await.unwrap_err().is_cancelled()); + observed.open_continue.notify_one(); + owner.close().await.unwrap(); + assert_eq!( + *observed.calls.lock().await, + vec!["open:cancelled/state", "close:cancelled/state"] + ); + assert!(owner.lease.lock().await.is_none()); +} diff --git a/crates/openhuman-core/src/lib.rs b/crates/openhuman-core/src/lib.rs index 3a3abf1159a..f00ea7cc89d 100644 --- a/crates/openhuman-core/src/lib.rs +++ b/crates/openhuman-core/src/lib.rs @@ -71,7 +71,6 @@ pub mod mcp; #[cfg(feature = "media")] pub mod media; pub mod memory; -#[cfg(feature = "modules")] pub mod modules; pub mod platform; pub mod profiles; diff --git a/crates/openhuman-core/src/modules/README.md b/crates/openhuman-core/src/modules/README.md index d7523585605..19b687fc6ca 100644 --- a/crates/openhuman-core/src/modules/README.md +++ b/crates/openhuman-core/src/modules/README.md @@ -254,6 +254,20 @@ no host file in this folder. - `ops::set_bundled_releases_dir` (`ops.rs`) is called by the desktop host before the core starts. +## Calls before core startup + +Hosts obtain `ModuleClient` through `openhuman_rpc::embed::modules`. Construct +it with explicit configuration, and set bundled-artifact discovery before the +first call. Construction starts nothing; calls use the same process-wide lazy +loader as the core and require neither a core runtime nor a signed-in session. +The client preserves argument tuple arity and offers confidential calls that +require recipient attestation. A failed load or call returns a sanitized +`ModuleCallError` and never executes an implementation library as a fallback. + +Stateful adapters must call the module's close, cancellation and shutdown +members to release its resources. Keeping a native library mapped does not +remove that obligation. + ## RPC surface All methods are in the `modules` namespace ([`schemas.rs`](./schemas.rs)), wired into the @@ -315,8 +329,10 @@ loadable: Never redeclare a contract type here, and call members through the contract's constants rather than string literals. Contract crates stay - synchronous and free of I/O. Shared wire behavior belongs in the contract; - runtime, configuration and security policy stay in this host. + synchronous and free of I/O. Serialized types, errors, identifiers, versions, + schemas and static tool declarations belong in the contract. Component + algorithms execute inside the compiled module; configuration, execution + policy and approvals stay in this host. - Policy stays here even where the work moved into a module. For connectors that means egress policy (`crate::security::egress`, applied before `Execute`), route selection, and webhook delivery; scope enforcement belongs @@ -337,9 +353,12 @@ loadable: ## Gotchas -- `lib.rs` gates the whole folder on the `modules` feature, which is in both - the contributor default set and the shipped product set. `documents`, - `voice` and `web3` each imply `modules` and turn on their host file here. +- The registry, configuration types and shared `ModuleClient` are available + without the `modules` feature. The loader and capability adapters are gated + on `modules`, which is in both the contributor default set and the shipped + product set. A client compiled without the loader returns an explicit + unavailable error on a call. `documents`, `voice` and `web3` each imply + `modules` and turn on their host file here. Never enable `modules` directly on the unconditional `tinybus` dependency; it is forwarded from this crate's own feature (`modules = ["tinybus/modules", ...]`), so a build without `modules` does not pull in @@ -376,3 +395,10 @@ minimal build small. - [Loadable modules](../../../../gitbooks/developing/loadable-modules.md) - [tinybus submodule](../../../../vendor/tinybus/README.md) - [Architecture overview](../../../../gitbooks/developing/architecture.md) + +The connector host consumes verified release v0.14.0 (contract 1.13). The +argument/default/filter/classification and trigger-archive adapters call bus +members; no normal or build edge reaches `tinyconnectors` or its sync library. +Provider messages remain product output, while terminal bus reports contain +only the module/version/stage/platform/reason vocabulary. Credential-bearing +configuration and direct reads use attested confidential calls. diff --git a/crates/openhuman-core/src/modules/client.rs b/crates/openhuman-core/src/modules/client.rs new file mode 100644 index 00000000000..ec9b9e9a11d --- /dev/null +++ b/crates/openhuman-core/src/modules/client.rs @@ -0,0 +1,303 @@ +//! Shared module access for hosts, including before a core runtime is started. + +use serde::{Serialize, de::DeserializeOwned}; +use std::time::Duration; + +use crate::config::Config; + +use super::{failure, registry}; + +/// A sanitized terminal module outcome. Arguments and remote error text are absent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ModuleCallError { + Unavailable, + IncompatibleContract, + TransportFailed, + ModuleFault, +} + +impl std::fmt::Display for ModuleCallError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let reason = match self { + Self::Unavailable => "unavailable", + Self::IncompatibleContract => "incompatible contract", + Self::TransportFailed => "transport failed", + Self::ModuleFault => "execution failed", + }; + // Later product reporting recognises this as an already reported + // module failure, rather than producing a second terminal event. + write!(formatter, "MODULE_CALL_REPORTED: module {reason}") + } +} + +impl std::error::Error for ModuleCallError {} + +/// Explicit configuration over the existing process-wide lazy module loader. +/// +/// Constructing a client starts nothing. It needs neither a signed-in session +/// nor a core runtime. Set the bundled releases directory through the module +/// facade before the first call when artifacts ship with the host. +#[derive(Clone)] +pub struct ModuleClient { + config: Config, + timeout: Option, + #[cfg(all(test, feature = "modules"))] + fixture: Option, +} + +impl ModuleClient { + #[must_use] + pub fn new(config: Config) -> Self { + Self { + config, + timeout: None, + #[cfg(all(test, feature = "modules"))] + fixture: None, + } + } + + #[cfg(all(test, feature = "modules"))] + pub(crate) fn fixture(proxy: tinybus::Proxy) -> Self { + Self { + config: Config::default(), + timeout: None, + fixture: Some(proxy), + } + } + + /// Override the TinyBus deadline for every call made through this client. + /// + /// Use this for module operations whose contract has a longer bounded + /// duration than TinyBus's default. It applies equally to confidential + /// calls and test fixtures. + #[must_use] + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Execute a contract member, retaining its existing argument tuple arity. + /// + /// No implementation fallback is provided. The caller owns approvals, + /// cancellation, and lifecycle cleanup; use [`Self::with_timeout`] for a + /// longer module-specific deadline. + pub async fn call( + &self, + module: &str, + member: &str, + args: impl Serialize, + ) -> Result { + self.invoke(module, member, args, false).await + } + + /// Execute a confidential member after TinyBus recipient attestation. + /// + /// Use this for credentials and other sensitive module inputs. A failure to + /// attest the pinned artifact fails the call; ordinary delivery is never retried. + pub async fn call_confidential( + &self, + module: &str, + member: &str, + args: impl Serialize, + ) -> Result { + self.invoke(module, member, args, true).await + } + + /// Call a member that returns a JSON-encoded string and deserialize that + /// inner document. Malformed module output is reported using only the + /// registered module identity and the closed `module_fault` reason. + pub async fn call_json( + &self, + module: &str, + member: &str, + args: impl Serialize, + ) -> Result { + let response: String = self.call(module, member, args).await?; + decode_module_json(module, &response) + } + + /// Confidential counterpart to [`Self::call_json`]. A decode failure is + /// terminal; the call is never retried through ordinary delivery. + pub async fn call_confidential_json( + &self, + module: &str, + member: &str, + args: impl Serialize, + ) -> Result { + let response: String = self.call_confidential(module, member, args).await?; + decode_module_json(module, &response) + } + + /// Report a structurally invalid reply from a known module invocation. + pub(crate) fn report_malformed_reply(module: &str) { + if let Some(record) = registry::find(module) { + failure::report_invocation(record, failure::Reason::ModuleFault); + } + } + + /// Report a known module that does not provide the requested capability. + pub(crate) fn report_unavailable(module: &str) { + if let Some(record) = registry::find(module) { + failure::report(record, failure::Reason::ModuleUnavailable); + } + } + + async fn invoke( + &self, + module: &str, + member: &str, + args: impl Serialize, + confidential: bool, + ) -> Result { + let Some(record) = registry::find(module) else { + failure::report_unknown_module(); + return Err(ModuleCallError::Unavailable); + }; + if !self.config.modules.enabled { + failure::report(record, failure::Reason::Disabled); + return Err(ModuleCallError::Unavailable); + } + #[cfg(all(test, feature = "modules"))] + if let Some(proxy) = self.fixture.as_ref() { + let proxy = self.timeout.map_or_else( + || proxy.clone(), + |timeout| proxy.clone().with_timeout(timeout), + ); + return invoke_proxy(record, &proxy, member, args, confidential).await; + } + #[cfg(not(feature = "modules"))] + { + let _ = (member, args, confidential); + failure::report(record, failure::Reason::LoaderDisabled); + Err(ModuleCallError::Unavailable) + } + #[cfg(feature = "modules")] + { + super::ops::ensure_loaded(&self.config, module) + .await + .map_err(|_| ModuleCallError::Unavailable)?; + let runtime = super::host::runtime().await.map_err(|_| { + failure::report(record, failure::Reason::TransportFailed); + ModuleCallError::TransportFailed + })?; + let proxy = runtime + .proxy(record.bus_name, record.object_path) + .map_err(|_| { + failure::report(record, failure::Reason::IncompatibleContract); + ModuleCallError::IncompatibleContract + })?; + let proxy = self + .timeout + .map_or(proxy.clone(), |timeout| proxy.with_timeout(timeout)); + invoke_proxy(record, &proxy, member, args, confidential).await + } + } +} + +fn decode_module_json( + module: &str, + response: &str, +) -> Result { + serde_json::from_str(response).map_err(|_| { + ModuleClient::report_malformed_reply(module); + ModuleCallError::ModuleFault + }) +} + +#[cfg(feature = "modules")] +async fn invoke_proxy( + record: &'static super::types::ModuleRecord, + proxy: &tinybus::Proxy, + member: &str, + args: impl Serialize, + confidential: bool, +) -> Result { + let result = if confidential { + proxy.call_confidential(member, args).await + } else { + proxy.call(member, args).await + }; + result.map_err(|error| report_proxy_failure(record, &error)) +} + +/// Report a bus failure without exposing its payload to telemetry. Product +/// adapters may separately decode a structured provider refusal for display. +#[cfg(feature = "modules")] +pub(super) fn report_proxy_failure( + record: &'static super::types::ModuleRecord, + error: &tinybus::Error, +) -> ModuleCallError { + let (outcome, reason) = classify(error); + if reason == failure::Reason::ModuleUnavailable { + failure::report(record, reason); + } else { + failure::report_invocation(record, reason); + } + outcome +} + +#[cfg(feature = "modules")] +fn classify(error: &tinybus::Error) -> (ModuleCallError, failure::Reason) { + use tinybus::Error; + match error { + Error::ModuleUnavailable { .. } => ( + ModuleCallError::Unavailable, + failure::Reason::ModuleUnavailable, + ), + Error::IncompatibleVersion { .. } + | Error::UnknownMethod { .. } + | Error::UnknownInterface { .. } + | Error::UnknownObject { .. } + | Error::BadArguments { .. } => ( + ModuleCallError::IncompatibleContract, + failure::Reason::IncompatibleContract, + ), + Error::MethodFailed { name, .. } + if name == "ai.tinyhumans.tinybus.Error.ModuleUnavailable" => + { + ( + ModuleCallError::Unavailable, + failure::Reason::ModuleUnavailable, + ) + } + Error::MethodFailed { name, .. } + if matches!( + name.as_str(), + Error::UNKNOWN_METHOD + | "ai.tinyhumans.tinybus.Error.IncompatibleVersion" + | "ai.tinyhumans.tinybus.Error.UnknownInterface" + | "ai.tinyhumans.tinybus.Error.UnknownObject" + | "ai.tinyhumans.tinybus.Error.BadArguments" + ) => + { + ( + ModuleCallError::IncompatibleContract, + failure::Reason::IncompatibleContract, + ) + } + Error::Json(_) => ( + ModuleCallError::IncompatibleContract, + failure::Reason::IncompatibleContract, + ), + Error::MethodFailed { name, .. } + if matches!( + name.as_str(), + Error::NOT_ATTESTED | Error::CONFIDENTIALITY_REQUIRED + ) => + { + ( + ModuleCallError::TransportFailed, + failure::Reason::TransportFailed, + ) + } + Error::MethodFailed { .. } => (ModuleCallError::ModuleFault, failure::Reason::ModuleFault), + _ => ( + ModuleCallError::TransportFailed, + failure::Reason::TransportFailed, + ), + } +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/modules/client_tests.rs b/crates/openhuman-core/src/modules/client_tests.rs new file mode 100644 index 00000000000..f6588200f24 --- /dev/null +++ b/crates/openhuman-core/src/modules/client_tests.rs @@ -0,0 +1,419 @@ +use super::*; + +#[tokio::test] +async fn a_disabled_client_is_unavailable_before_core_startup() { + let mut config = Config::default(); + config.modules.enabled = false; + let result = ModuleClient::new(config) + .call::("tinyhosts", "Providers", ()) + .await; + assert_eq!(result, Err(ModuleCallError::Unavailable)); +} + +#[tokio::test] +async fn unknown_modules_are_not_loaded_or_reported_using_untrusted_ids() { + let client = ModuleClient::new(Config::default()); + let result = client + .call::("private-user-path-and-token", "Anything", ()) + .await; + assert_eq!(result, Err(ModuleCallError::Unavailable)); + assert!( + !result + .unwrap_err() + .to_string() + .contains("private-user-path-and-token") + ); +} + +#[cfg(not(feature = "modules"))] +#[tokio::test] +async fn a_client_without_the_loader_never_falls_back_to_an_implementation() { + let result = ModuleClient::new(Config::default()) + .call::("tinyhosts", "Providers", ()) + .await; + assert_eq!(result, Err(ModuleCallError::Unavailable)); +} + +#[cfg(feature = "modules")] +#[test] +fn remote_fault_text_is_not_part_of_the_public_error() { + let error = tinybus::Error::MethodFailed { + name: "private-provider".to_string(), + message: "secret-token /home/private-user document content".to_string(), + }; + assert_eq!(classify(&error).0, ModuleCallError::ModuleFault); + assert_eq!( + classify(&error).0.to_string(), + "MODULE_CALL_REPORTED: module execution failed" + ); +} + +#[test] +fn subsequent_product_reports_are_demoted() { + for error in [ + ModuleCallError::Unavailable, + ModuleCallError::IncompatibleContract, + ModuleCallError::TransportFailed, + ModuleCallError::ModuleFault, + ] { + assert!(crate::core::observability::is_module_unavailable_message( + &error.to_string() + )); + } +} + +#[tokio::test] +async fn confidential_calls_do_not_fall_back_when_disabled() { + let mut config = Config::default(); + config.modules.enabled = false; + let result = ModuleClient::new(config) + .call_confidential::("tinywallet", "SignTransaction", ("secret-value",)) + .await; + assert_eq!(result, Err(ModuleCallError::Unavailable)); +} + +#[cfg(feature = "modules")] +#[test] +fn wire_unknown_method_is_an_incompatible_contract() { + let error = tinybus::Error::MethodFailed { + name: tinybus::Error::UNKNOWN_METHOD.to_string(), + message: "private-response-content".to_string(), + }; + assert_eq!(classify(&error).0, ModuleCallError::IncompatibleContract); + assert_eq!( + classify(&tinybus::Error::ConnectionClosed).0, + ModuleCallError::TransportFailed + ); +} + +#[cfg(feature = "modules")] +#[derive(Clone)] +struct BusFixture(std::sync::Arc); + +#[cfg(feature = "modules")] +#[tinybus::interface(name = "test.ModuleClient")] +impl BusFixture { + async fn sum(&self, left: u32, right: u32) -> tinybus::Result { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(left + right) + } + + async fn fault(&self) -> tinybus::Result<()> { + Err(tinybus::Error::MethodFailed { + name: "test.ProviderFault".to_string(), + message: "secret-value /home/private-user/payload".to_string(), + }) + } + + async fn unavailable(&self) -> tinybus::Result<()> { + Err(tinybus::Error::ModuleUnavailable { + module: "test.ModuleClient".to_string(), + state: "faulted".to_string(), + detail: "module stopped accepting calls".to_string(), + }) + } + + async fn malformed_json(&self) -> tinybus::Result { + Ok("{ private response data".to_owned()) + } + + async fn delayed(&self) -> tinybus::Result<()> { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + Ok(()) + } +} + +#[cfg(all(feature = "modules", feature = "crash-reporting"))] +#[test] +fn independent_invocation_faults_report_separately_around_a_success() { + use tinybus::{Connection, ObjectPath, broker::Broker, transport::memory::MemoryBus}; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let proxy = runtime.block_on(async { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service + .serve_at( + ObjectPath::new("/test/ModuleClient").unwrap(), + BusFixture(std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))), + ) + .await + .unwrap(); + service.request_name("test.ModuleClient").await.unwrap(); + let proxy = caller + .proxy( + "test.ModuleClient", + "/test/ModuleClient", + "test.ModuleClient", + ) + .unwrap(); + (proxy, service) + }); + let (proxy, _service) = proxy; + let record = registry::find("tinysearch").unwrap(); + let events = sentry::test::with_captured_events(|| { + runtime.block_on(async { + assert_eq!( + invoke_proxy::<()>(record, &proxy, "Fault", (), false).await, + Err(ModuleCallError::ModuleFault) + ); + assert_eq!( + invoke_proxy::(record, &proxy, "Sum", (20, 22), false).await, + Ok(42) + ); + assert_eq!( + invoke_proxy::<()>(record, &proxy, "Fault", (), false).await, + Err(ModuleCallError::ModuleFault) + ); + }); + }); + + assert_eq!(events.len(), 2, "{events:?}"); + for event in &events { + assert_eq!( + event.tags.get("reason_code").map(String::as_str), + Some("module_fault") + ); + let captured = format!("{event:?}"); + assert!(!captured.contains("secret-value")); + assert!(!captured.contains("/home/private-user")); + } +} + +#[cfg(all(feature = "modules", feature = "crash-reporting"))] +#[test] +fn cached_native_module_unavailability_is_reported_once() { + use tinybus::{Connection, ObjectPath, broker::Broker, transport::memory::MemoryBus}; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let proxy = runtime.block_on(async { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service + .serve_at( + ObjectPath::new("/test/ModuleClient").unwrap(), + BusFixture(std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))), + ) + .await + .unwrap(); + service.request_name("test.ModuleClient").await.unwrap(); + let proxy = caller + .proxy( + "test.ModuleClient", + "/test/ModuleClient", + "test.ModuleClient", + ) + .unwrap(); + (proxy, service) + }); + let (proxy, _service) = proxy; + let record = registry::find("tinybox").unwrap(); + let events = sentry::test::with_captured_events(|| { + runtime.block_on(async { + for _ in 0..2 { + assert_eq!( + invoke_proxy::<()>(record, &proxy, "Unavailable", (), false).await, + Err(ModuleCallError::Unavailable) + ); + } + }); + }); + + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!( + events[0].tags.get("reason_code").map(String::as_str), + Some("module_unavailable") + ); +} + +#[cfg(feature = "modules")] +#[tokio::test] +async fn bus_calls_preserve_tuple_arity_and_never_downgrade_confidentiality() { + use tinybus::{Connection, ObjectPath, broker::Broker, transport::memory::MemoryBus}; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + service + .serve_at( + ObjectPath::new("/test/ModuleClient").unwrap(), + BusFixture(calls.clone()), + ) + .await + .unwrap(); + service.request_name("test.ModuleClient").await.unwrap(); + let proxy = caller + .proxy( + "test.ModuleClient", + "/test/ModuleClient", + "test.ModuleClient", + ) + .unwrap(); + let record = registry::find("tinysearch").unwrap(); + assert_eq!( + invoke_proxy::(record, &proxy, "Sum", (20_u32, 22_u32), false).await, + Ok(42) + ); + assert_eq!( + invoke_proxy::(record, &proxy, "Sum", (20_u32, 22_u32), true).await, + Err(ModuleCallError::TransportFailed) + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!( + invoke_proxy::<()>(record, &proxy, "Fault", (), false).await, + Err(ModuleCallError::ModuleFault) + ); + assert_eq!( + invoke_proxy::<()>(record, &proxy, "MissingMember", (), false).await, + Err(ModuleCallError::IncompatibleContract) + ); +} + +#[cfg(feature = "modules")] +#[test] +fn configured_client_timeout_overrides_the_default_for_fixture_calls() { + use tinybus::{Connection, ObjectPath, broker::Broker, transport::memory::MemoryBus}; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (proxy, _service) = runtime.block_on(async { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service + .serve_at( + ObjectPath::new("/test/ModuleClient").unwrap(), + BusFixture(std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))), + ) + .await + .unwrap(); + service.request_name("test.ModuleClient").await.unwrap(); + let proxy = caller + .proxy( + "test.ModuleClient", + "/test/ModuleClient", + "test.ModuleClient", + ) + .unwrap(); + (proxy, service) + }); + let client = ModuleClient::fixture(proxy).with_timeout(std::time::Duration::from_millis(5)); + let result = runtime.block_on(client.call::<()>("tinysearch", "Delayed", ())); + assert_eq!(result, Err(ModuleCallError::TransportFailed)); +} + +#[cfg(all(feature = "modules", feature = "crash-reporting"))] +#[test] +fn malformed_inner_json_from_a_real_bus_reply_is_reported_as_a_sanitized_fault() { + use tinybus::{Connection, ObjectPath, broker::Broker, transport::memory::MemoryBus}; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (proxy, _service) = runtime.block_on(async { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let caller = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service + .serve_at( + ObjectPath::new("/test/ModuleClient").unwrap(), + BusFixture(std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))), + ) + .await + .unwrap(); + service.request_name("test.ModuleClient").await.unwrap(); + let proxy = caller + .proxy( + "test.ModuleClient", + "/test/ModuleClient", + "test.ModuleClient", + ) + .unwrap(); + (proxy, service) + }); + let client = ModuleClient::fixture(proxy); + let events = sentry::test::with_captured_events(|| { + assert_eq!( + runtime.block_on(client.call_json::( + "tinysearch", + "MalformedJson", + () + )), + Err(ModuleCallError::ModuleFault) + ); + }); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!( + events[0].tags.get("reason_code").map(String::as_str), + Some("module_fault") + ); + let captured = format!("{events:?}"); + assert!(!captured.contains("private response data")); +} + +#[cfg(feature = "crash-reporting")] +#[test] +fn unknown_module_failures_emit_only_safe_registry_metadata() { + let events = sentry::test::with_captured_events(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + runtime.block_on(async { + let client = ModuleClient::new(Config::default()); + for _ in 0..2 { + let error = client + .call::( + "private-token /home/private-user", + "secret-member", + ("private-content",), + ) + .await + .unwrap_err(); + assert_eq!(error, ModuleCallError::Unavailable); + assert!(!error.to_string().contains("private")); + } + }); + }); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!( + events[0].tags.get("module").map(String::as_str), + Some("unregistered") + ); + assert_eq!( + events[0].tags.get("reason_code").map(String::as_str), + Some("unknown_module") + ); + assert!(!format!("{events:?}").contains("private")); +} diff --git a/crates/openhuman-core/src/modules/connectors.rs b/crates/openhuman-core/src/modules/connectors.rs index ad1d24a964a..607724cb092 100644 --- a/crates/openhuman-core/src/modules/connectors.rs +++ b/crates/openhuman-core/src/modules/connectors.rs @@ -210,9 +210,9 @@ async fn ensure_routed(config: &Config, proxy: &Proxy) -> Result<(), String> { } proxy - .call::(methods::CONFIGURE, (route,)) + .call_confidential::(methods::CONFIGURE, (route,)) .await - .map_err(|error| format!("{}: {error}", methods::CONFIGURE))?; + .map_err(|error| bus_failure(methods::CONFIGURE, &error))?; // Recorded only after the module accepted it, so a failed reconfiguration // is retried on the next call rather than remembered as done. @@ -233,7 +233,9 @@ async fn ensure_routed(config: &Config, proxy: &Proxy) -> Result<(), String> { /// the artifact missing or failing its digest check, or the bus refusing the /// proxy. pub async fn proxy(config: &Config) -> Result { - ops::ensure_loaded(config, MODULE_ID).await?; + ops::ensure_loaded(config, MODULE_ID) + .await + .map_err(|_| super::client::ModuleCallError::Unavailable.to_string())?; let proxy = proxy_to_serving().await?; ensure_routed(config, &proxy).await?; Ok(proxy) @@ -244,12 +246,13 @@ pub async fn proxy(config: &Config) -> Result { async fn proxy_to_serving() -> Result { let record = registry::find(MODULE_ID).ok_or_else(|| format!("unknown module '{MODULE_ID}'"))?; - let runtime = super::host::runtime() - .await - .map_err(|error| format!("the module runtime is unavailable: {error}"))?; + let runtime = super::host::runtime().await.map_err(|_| { + super::failure::report(record, super::failure::Reason::TransportFailed); + super::client::ModuleCallError::TransportFailed.to_string() + })?; runtime .proxy(record.bus_name, record.object_path) - .map_err(|error| format!("could not reach '{MODULE_ID}': {error}")) + .map_err(|error| bus_failure("Connect", &error)) } /// Give an already-serving module the route the current configuration @@ -294,6 +297,26 @@ pub(crate) async fn proxy_without_reconcile() -> Result { proxy_to_serving().await } +fn bus_failure(member: &str, error: &tinybus::Error) -> String { + let outcome = super::client::report_proxy_failure( + registry::find(MODULE_ID).expect("registered connector module"), + error, + ); + if let tinybus::Error::MethodFailed { name, message } = error { + if message.starts_with("[composio:error:") { + // Preserve the existing provider presentation contract. This is + // product output only; telemetry above contains no remote text. + return message.clone(); + } + if name == tinybus::Error::FAILED { + // Keep provider presentation byte-for-byte. The Composio reporting + // boundary recognizes this wire envelope without a visible marker. + return format!("{member}: {error}"); + } + } + format!("{member}: {outcome}") +} + /// Call one member with an argument and decode its reply. /// /// # Errors @@ -317,7 +340,7 @@ where proxy .call::(member, (request,)) .await - .map_err(|error| format!("{member}: {error}")) + .map_err(|error| bus_failure(member, &error)) } /// Call a member that carries its own credential, without touching the route. @@ -344,12 +367,14 @@ where // The module's own HTTP deadline is 30s; give the bus a little longer so a // slow Composio surfaces as the module's message, not a bus timeout. const STATELESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); - ops::ensure_loaded(config, MODULE_ID).await?; + ops::ensure_loaded(config, MODULE_ID) + .await + .map_err(|_| super::client::ModuleCallError::Unavailable.to_string())?; let proxy = proxy_to_serving().await?.with_timeout(STATELESS_TIMEOUT); proxy - .call::(member, (request,)) + .call_confidential::(member, (request,)) .await - .map_err(|error| format!("{member}: {error}")) + .map_err(|error| bus_failure(member, &error)) } /// Call a member that takes no arguments. @@ -365,7 +390,7 @@ pub async fn call_bare( proxy .call::(member, ()) .await - .map_err(|error| format!("{member}: {error}")) + .map_err(|error| bus_failure(member, &error)) } #[cfg(test)] diff --git a/crates/openhuman-core/src/modules/connectors_tests.rs b/crates/openhuman-core/src/modules/connectors_tests.rs index 81e83663da2..4c5175fb072 100644 --- a/crates/openhuman-core/src/modules/connectors_tests.rs +++ b/crates/openhuman-core/src/modules/connectors_tests.rs @@ -196,3 +196,54 @@ async fn reconciling_the_route_of_a_module_that_is_not_serving_loads_nothing() { // one that was serving stays serving. assert_eq!(crate::modules::ops::state_of(MODULE_ID), before); } + +#[test] +fn unclassified_connector_faults_do_not_expose_remote_content() { + let error = tinybus::Error::MethodFailed { + name: "private-provider".into(), + message: "secret-token /home/private-user/document.txt".into(), + }; + let message = super::bus_failure(methods::EXECUTE, &error); + assert!(message.contains("MODULE_CALL_REPORTED: module execution failed")); + assert!(!message.contains("secret-token")); + assert!(!message.contains("/home/private-user")); +} + +#[cfg(feature = "crash-reporting")] +#[test] +fn classified_provider_errors_remain_product_output_and_emit_one_sanitized_event() { + let provider_message = + "[composio:error:auth] private-test-token /home/private-user/document.txt"; + let error = tinybus::Error::MethodFailed { + name: "ai.tinyhumans.tinybus.Error.Failed".into(), + message: provider_message.into(), + }; + let events = sentry::test::with_captured_events(|| { + let message = super::bus_failure(methods::EXECUTE, &error); + assert_eq!(message, provider_message); + crate::core::observability::report_error_or_expected(&message, "rpc", "invoke_method", &[]); + }); + assert_eq!(events.len(), 1); + let captured = format!("{:?}", events[0]); + assert!(!captured.contains("private-test-token")); + assert!(!captured.contains("/home/private-user")); + assert_eq!( + events[0].tags.get("module").map(String::as_str), + Some(MODULE_ID) + ); + assert_eq!( + events[0].tags.get("reason_code").map(String::as_str), + Some("module_fault") + ); +} + +#[test] +fn direct_auth_refusals_preserve_the_provider_status_and_prevent_duplicate_reports() { + let error = + tinybus::Error::failed("Composio v3 connected_accounts failed: HTTP 401: Invalid API key"); + let message = super::bus_failure(methods::LIST_CONNECTIONS_DIRECT, &error); + assert!(message.contains("HTTP 401: Invalid API key")); + assert!(crate::core::observability::is_module_unavailable_message( + &message + )); +} diff --git a/crates/openhuman-core/src/modules/failure.rs b/crates/openhuman-core/src/modules/failure.rs new file mode 100644 index 00000000000..bf78481a198 --- /dev/null +++ b/crates/openhuman-core/src/modules/failure.rs @@ -0,0 +1,110 @@ +//! Sanitized terminal module reports with deduplication for cached outcomes. + +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; + +use super::types::ModuleRecord; + +/// Closed reason vocabulary: never accept a module error, path or payload here. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(super) enum Reason { + UnknownModule, + Disabled, + LoaderDisabled, + ResolutionFailed, + IncompatibleContract, + TransportFailed, + ModuleFault, + ModuleUnavailable, +} + +impl Reason { + fn code(self) -> &'static str { + match self { + Self::UnknownModule => "unknown_module", + Self::Disabled => "disabled", + Self::LoaderDisabled => "loader_disabled", + Self::ResolutionFailed => "resolution_failed", + Self::IncompatibleContract => "incompatible_contract", + Self::TransportFailed => "transport_failed", + Self::ModuleFault => "module_fault", + Self::ModuleUnavailable => "module_unavailable", + } + } + + fn stage(self) -> &'static str { + match self { + Self::UnknownModule => "registry", + Self::Disabled | Self::LoaderDisabled => "availability", + Self::ResolutionFailed => "resolve", + Self::IncompatibleContract => "contract", + Self::TransportFailed => "transport", + Self::ModuleFault => "execution", + Self::ModuleUnavailable => "execution", + } + } +} + +/// Report a terminal outcome once. Registry records supply all identifying tags. +/// +/// There are finitely many registry records and reasons, so the process cache +/// is bounded. Concurrent callers cannot duplicate the same terminal report. +pub(super) fn report(record: &'static ModuleRecord, reason: Reason) { + report_metadata(record.id, record.version, reason, true); +} + +/// Report a failure from this invocation. Independent executions can fail +/// with the same sanitized reason and still represent distinct terminal events. +pub(super) fn report_invocation(record: &'static ModuleRecord, reason: Reason) { + report_metadata(record.id, record.version, reason, false); +} + +/// Unknown identifiers never become event metadata or deduplication keys. +pub(super) fn report_unknown_module() { + report_metadata("unregistered", "unknown", Reason::UnknownModule, true); +} + +fn report_metadata(module: &'static str, version: &'static str, reason: Reason, deduplicate: bool) { + static REPORTED: OnceLock>> = OnceLock::new(); + let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let capture = || { + crate::core::observability::report_error( + "loadable module operation failed", + "modules", + reason.stage(), + &[ + ("module", module), + ("version", version), + ("stage", reason.stage()), + ("platform", &platform), + ("reason_code", reason.code()), + ], + ) + }; + // Pre-core embedders may call before Sentry is initialized. Do not spend + // the process deduplication key on an event no bound client can receive. + #[cfg(feature = "crash-reporting")] + let retain_key = sentry::Hub::current().client().is_some(); + #[cfg(not(feature = "crash-reporting"))] + let retain_key = true; + if retain_key && deduplicate { + let first = REPORTED + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((module, reason)); + if !first { + return; + } + } + // A host may have put request payloads or user paths on its current scope. + // These terminal events carry only the closed metadata above. + #[cfg(feature = "crash-reporting")] + sentry::with_scope(|scope| scope.clear(), capture); + #[cfg(not(feature = "crash-reporting"))] + capture(); +} + +#[cfg(test)] +#[path = "failure_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/modules/failure_tests.rs b/crates/openhuman-core/src/modules/failure_tests.rs new file mode 100644 index 00000000000..9aa4632f2e2 --- /dev/null +++ b/crates/openhuman-core/src/modules/failure_tests.rs @@ -0,0 +1,87 @@ +use super::*; + +#[cfg(feature = "crash-reporting")] +#[test] +fn terminal_reports_are_sanitized_and_deduplicated() { + let record = crate::modules::registry::find("tinybox").unwrap(); + let events = sentry::test::with_captured_events(|| { + sentry::with_scope( + |scope| { + scope.set_tag("unsafe_path", "/home/private-user/document.txt"); + scope.set_extra( + "unsafe_payload", + "private-test-token and user content".into(), + ); + }, + || { + report(record, Reason::ModuleFault); + report(record, Reason::ModuleFault); + }, + ); + }); + assert_eq!(events.len(), 1, "{events:?}"); + let event = &events[0]; + let captured = format!("{event:?}"); + assert!(!captured.contains("private-test-token")); + assert!(!captured.contains("/home/private-user")); + assert!(!event.tags.contains_key("unsafe_path")); + assert!(event.extra.is_empty()); + assert_eq!( + event.message.as_deref(), + Some("loadable module operation failed") + ); + for (key, value) in [ + ("module", record.id), + ("version", record.version), + ("stage", "execution"), + ("reason_code", "module_fault"), + ] { + assert_eq!(event.tags.get(key).map(String::as_str), Some(value)); + } + assert_eq!( + event.tags.get("platform"), + Some(&format!( + "{}-{}", + std::env::consts::OS, + std::env::consts::ARCH + )) + ); +} + +#[test] +fn reason_codes_and_stages_are_closed_vocabulary() { + for reason in [ + Reason::UnknownModule, + Reason::Disabled, + Reason::LoaderDisabled, + Reason::ResolutionFailed, + Reason::IncompatibleContract, + Reason::TransportFailed, + Reason::ModuleFault, + Reason::ModuleUnavailable, + ] { + assert!(reason + .code() + .chars() + .all(|c| c.is_ascii_lowercase() || c == '_')); + assert!(reason.stage().chars().all(|c| c.is_ascii_lowercase())); + } +} + +#[cfg(feature = "crash-reporting")] +#[test] +fn a_pre_core_report_without_a_sentry_client_can_be_captured_later() { + let record = crate::modules::registry::find("tinyhosts").unwrap(); + let hub = std::sync::Arc::new(sentry::Hub::new_from_top(sentry::Hub::current())); + hub.bind_client(None); + sentry::Hub::run(hub, || report(record, Reason::IncompatibleContract)); + let events = sentry::test::with_captured_events(|| { + report(record, Reason::IncompatibleContract); + report(record, Reason::IncompatibleContract); + }); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!( + events[0].tags.get("module").map(String::as_str), + Some("tinyhosts") + ); +} diff --git a/crates/openhuman-core/src/modules/mod.rs b/crates/openhuman-core/src/modules/mod.rs index 62b4d6369fe..e0ec67de53d 100644 --- a/crates/openhuman-core/src/modules/mod.rs +++ b/crates/openhuman-core/src/modules/mod.rs @@ -40,30 +40,52 @@ //! - [`schemas`] — the `modules` RPC surface. //! - [`boot`] — what happens at startup. +#[cfg(feature = "modules")] pub mod boot; +#[cfg(feature = "modules")] pub mod browser; +#[cfg(feature = "modules")] pub(crate) mod browser_sites; +#[cfg(feature = "modules")] pub mod browser_task; +#[cfg(feature = "modules")] mod browser_task_report; +pub mod client; +#[cfg(feature = "modules")] pub mod computer; +#[cfg(feature = "modules")] pub mod computer_config; +#[cfg(feature = "modules")] pub mod connectors; +#[cfg(feature = "modules")] pub mod desktop; #[cfg(feature = "documents")] +#[cfg(feature = "modules")] pub mod documents; +mod failure; +#[cfg(feature = "modules")] pub mod host; +#[cfg(feature = "modules")] pub mod ops; pub mod registry; +#[cfg(feature = "modules")] pub mod runtime; +#[cfg(feature = "modules")] pub mod schemas; +#[cfg(feature = "modules")] pub mod search; +#[cfg(feature = "modules")] mod tokenjuice_host; pub mod types; #[cfg(feature = "voice")] +#[cfg(feature = "modules")] pub mod voice; #[cfg(feature = "web3")] +#[cfg(feature = "modules")] pub mod wallet; +#[cfg(feature = "modules")] pub use ops::{ensure_loaded, ensure_loaded_within, state_of, LoadError}; +#[cfg(feature = "modules")] pub use schemas::{all_controller_schemas, all_registered_controllers}; pub use types::{LoadPolicy, ModuleRecord, ModuleState, ModuleStatus, PlatformAsset}; diff --git a/crates/openhuman-core/src/modules/ops.rs b/crates/openhuman-core/src/modules/ops.rs index 04b68c9c080..a4f7da77eb5 100644 --- a/crates/openhuman-core/src/modules/ops.rs +++ b/crates/openhuman-core/src/modules/ops.rs @@ -150,6 +150,9 @@ pub async fn ensure_loaded_within( within: Option, ) -> Result<(), LoadError> { if !config.modules.enabled { + if let Some(record) = registry::find(id) { + super::failure::report(record, super::failure::Reason::Disabled); + } return Err(LoadError::Failed(format!( "module '{id}' is unavailable: modules are disabled in configuration" ))); @@ -159,7 +162,13 @@ pub async fn ensure_loaded_within( let receiver = match resolution::global().claim(id) { Claim::Done(Resolution::Ready) => return Ok(()), - Claim::Done(Resolution::Failed(reason)) => return Err(LoadError::Failed(reason)), + Claim::Done(Resolution::Failed(reason)) => { + // The original resolution may have completed before a Sentry + // client was bound. Revisit reporting at the actual cached caller + // boundary; the report key suppresses duplicates once captured. + report_resolution_failure(id, &reason); + return Err(LoadError::Failed(reason)); + } Claim::Wait(receiver) => receiver, Claim::Run { sender, receiver } => { start_resolution(config.clone(), record, sender).await; @@ -168,7 +177,10 @@ pub async fn ensure_loaded_within( }; match resolution::global().wait(id, receiver, within).await { Waited::Ready => Ok(()), - Waited::Failed(reason) => Err(LoadError::Failed(reason)), + Waited::Failed(reason) => { + report_resolution_failure(id, &reason); + Err(LoadError::Failed(reason)) + } Waited::StillLoading => Err(LoadError::StillLoading), } } @@ -229,14 +241,20 @@ async fn start_resolution( /// /// A resolution runs once and its failure is cached for every later caller /// (see the module docs), so this is the single Sentry event a broken install -/// produces. Those callers re-raise the cached reason, and those re-reports -/// classify as `ExpectedErrorKind::ModuleUnavailable` and are demoted; this -/// one goes through [`report_error`] directly so the classifier cannot swallow -/// it too. +/// produces. The failed resolution itself reports here, and cached callers +/// revisit this function in case the original resolution happened before a +/// Sentry client was bound. Its stable report key suppresses repeats. The +/// report goes through [`report_error`] directly so the observability +/// classifier cannot swallow the original terminal event. /// /// [`report_error`]: crate::core::observability::report_error pub(super) fn report_resolution_failure(id: &str, reason: &str) { - crate::core::observability::report_error(reason, "modules", "resolve", &[("module", id)]); + // Loader errors can include download URLs or user paths. Keep their detail + // in the operation's returned error, never in the terminal Sentry report. + let _ = reason; + if let Some(record) = registry::find(id) { + super::failure::report(record, super::failure::Reason::ResolutionFailed); + } } /// Do the actual work of getting `record` serving. diff --git a/crates/openhuman-core/src/modules/ops_tests.rs b/crates/openhuman-core/src/modules/ops_tests.rs index 6422578f66a..9dfeae4be9e 100644 --- a/crates/openhuman-core/src/modules/ops_tests.rs +++ b/crates/openhuman-core/src/modules/ops_tests.rs @@ -276,6 +276,86 @@ async fn a_bounded_wait_with_nothing_cached_and_downloads_off_fails_rather_than_ } } +#[cfg(feature = "crash-reporting")] +#[test] +fn terminal_resolution_cached_failure_is_reported_when_a_client_becomes_available() { + const MODULE: &str = "tinycomputer"; + let table = tinybus::module::resolution::global(); + table.forget(MODULE); + + // Model resolution finishing before core/Sentry startup, then publish its + // terminal outcome into the same process-wide table read by ensure_loaded. + let hub = std::sync::Arc::new(sentry::Hub::new_from_top(sentry::Hub::current())); + hub.bind_client(None); + sentry::Hub::run(hub, || { + ops::report_resolution_failure(MODULE, "private token /home/private-user/module.so"); + }); + let sender = table.mark_in_flight(MODULE); + table.complete( + MODULE, + tinybus::module::resolution::Resolution::Failed( + "private token /home/private-user/module.so".to_string(), + ), + sender, + ); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let events = sentry::test::with_captured_events(|| { + runtime.block_on(async { + let config = Config::default(); + let (first, second) = tokio::join!( + ops::ensure_loaded_within(&config, MODULE, None), + ops::ensure_loaded_within(&config, MODULE, None), + ); + for result in [first, second] { + assert!(matches!(result, Err(ops::LoadError::Failed(_)))); + } + let repeated = ops::ensure_loaded_within(&config, MODULE, None) + .await + .expect_err("the same cached failure remains terminal"); + assert!(matches!(repeated, ops::LoadError::Failed(_))); + }); + }); + table.forget(MODULE); + assert_eq!(events.len(), 1, "{events:?}"); + let captured = format!("{:?}", events[0]); + assert!(!captured.contains("private token")); + assert!(!captured.contains("/home/private-user")); + assert_eq!( + events[0].tags.get("reason_code").map(String::as_str), + Some("resolution_failed") + ); +} + +#[cfg(feature = "crash-reporting")] +#[test] +fn terminal_resolution_wait_timeout_is_not_reported_as_terminal() { + const MODULE: &str = "tinyvoice"; + let table = tinybus::module::resolution::global(); + table.forget(MODULE); + let _sender = table.mark_in_flight(MODULE); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let events = sentry::test::with_captured_events(|| { + runtime.block_on(async { + let result = ops::ensure_loaded_within( + &Config::default(), + MODULE, + Some(std::time::Duration::ZERO), + ) + .await; + assert!(matches!(result, Err(ops::LoadError::StillLoading))); + }); + }); + table.forget(MODULE); + assert!(events.is_empty(), "{events:?}"); +} + #[test] fn every_shipped_registry_entry_names_a_cache_directory() { // Every shipped registry entry names a directory on every host it claims. @@ -349,10 +429,28 @@ fn a_failed_resolution_is_reported_once_with_the_module_id() { // report is the one Sentry event per broken install. Every later caller's // re-report is demoted as `ModuleUnavailable`; this one must not be. let events = sentry::test::with_captured_events(|| { - ops::report_resolution_failure("tinyconnectors", REFUSED_LOAD); + ops::report_resolution_failure( + "tinyconnectors", + "private-test-token /home/private-user/document.txt", + ); }); assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!( + events[0].message.as_deref(), + Some("loadable module operation failed") + ); + let captured = format!("{:?}", events[0]); + assert!(!captured.contains("private-test-token")); + assert!(!captured.contains("/home/private-user")); let tags = &events[0].tags; + assert_eq!( + tags.get("reason_code").map(String::as_str), + Some("resolution_failed") + ); + assert_eq!( + tags.get("version").map(String::as_str), + Some(registry::find("tinyconnectors").unwrap().version) + ); assert_eq!(tags.get("domain").map(String::as_str), Some("modules")); assert_eq!(tags.get("operation").map(String::as_str), Some("resolve")); assert_eq!( @@ -440,3 +538,13 @@ fn a_marked_bus_startup_failure_is_classified_as_module_unavailable() { // Idempotent: an already-marked error is not annotated twice. assert_eq!(ops::mark_terminal(marked.clone()), marked); } + +#[tokio::test] +async fn disabled_modules_are_reported_then_demoted_at_product_boundaries() { + let mut config = Config::default(); + config.modules.enabled = false; + let reason = ops::ensure_loaded(&config, "tinyjuice").await.unwrap_err(); + assert!(crate::core::observability::is_module_unavailable_message( + &reason + )); +} diff --git a/crates/openhuman-core/src/modules/registry.rs b/crates/openhuman-core/src/modules/registry.rs index 9725429757d..880607901c3 100644 --- a/crates/openhuman-core/src/modules/registry.rs +++ b/crates/openhuman-core/src/modules/registry.rs @@ -30,7 +30,7 @@ //! count — see `registry/records_*.rs`. This file only wires them into //! [`ALL`] and answers [`find`]. -#[cfg(test)] +#[cfg(all(test, feature = "modules"))] #[path = "registry_tests.rs"] mod tests; diff --git a/crates/openhuman-core/src/modules/registry/records_extra.rs b/crates/openhuman-core/src/modules/registry/records_extra.rs index ef342422877..69bc7b739a3 100644 --- a/crates/openhuman-core/src/modules/registry/records_extra.rs +++ b/crates/openhuman-core/src/modules/registry/records_extra.rs @@ -144,63 +144,63 @@ pub(crate) const TINYHOSTS: ModuleRecord = ModuleRecord { description: "Hosting provider operations", bus_name: "ai.tinyhumans.tinyhosts.Hosting", object_path: "/ai/tinyhumans/tinyhosts/Hosting", - version: "0.2.3", - release_url: "https://github.com/tinyhumansai/tinyhosts/releases/tag/v0.2.3", + version: "0.3.0", + release_url: "https://github.com/tinyhumansai/tinyhosts/releases/tag/v0.3.0", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinyhosts-0.2.3-ubuntu-24.04-x86_64.tar.gz", - sha256: "a0e623c5f541c1d21f2e7aba16ce3074fcee06f014c39c43fd2a7f7a3a77f6dd", + archive: "tinyhosts-0.3.0-ubuntu-24.04-x86_64.tar.gz", + sha256: "368f00ee397649481ff8f61d33eeb8b908cc416d6b2f9d4ef3c81263d6b0460e", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinyhosts-0.2.3-ubuntu-24.04-arm64.tar.gz", - sha256: "0e0b0467c08e3f79275fbbd46e84df3dea570f896b746031a581d030eb7eb9c5", + archive: "tinyhosts-0.3.0-ubuntu-24.04-arm64.tar.gz", + sha256: "e298942fddec8930fd78651632c02abe78968a17ab3589608875b8dc4f731669", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinyhosts-0.2.3-ubuntu-22.04-x86_64.tar.gz", - sha256: "6d622d9497ebdea67323125e25dab0d6497e0d3f39c351f3ef13de016076fc8b", + archive: "tinyhosts-0.3.0-ubuntu-22.04-x86_64.tar.gz", + sha256: "f7c9484176b62357fb449eb79830e3df0eebfd4e0814c8d8e1c58669d829f482", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinyhosts-0.2.3-ubuntu-22.04-arm64.tar.gz", - sha256: "6648fa3debb518ab5090aef2e49a3eed147ca00eef5c0c7e22576768cb7d1f51", + archive: "tinyhosts-0.3.0-ubuntu-22.04-arm64.tar.gz", + sha256: "576ff6204ccaa1e2dc2ef1b27762848efa4555d4d32ef46b4c329daf1e04eac3", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinyhosts-0.2.3-macos-26-arm64.tar.gz", - sha256: "8c8d62564df5fad2b3d4da30e56e86969584aacb30a22875f96a6209d1e0cfae", + archive: "tinyhosts-0.3.0-macos-26-arm64.tar.gz", + sha256: "b0083e70b567659d095a1ff0f9d2c8cb5bafe110d90fb08527c509362ce77a55", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinyhosts-0.2.3-macos-26-x86_64.tar.gz", - sha256: "801cec78dfe01006ff6811b445730de3f246087f1cbe9f0d63da8bcf55bf3f5d", + archive: "tinyhosts-0.3.0-macos-26-x86_64.tar.gz", + sha256: "44fa7c9efde0c388f154f154809ce2829460bae4931f27d20f8f0d3ee3bf2a8c", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinyhosts-0.2.3-macos-15-arm64.tar.gz", - sha256: "10b9ac5b6e414064b4d9abb9cc4cb47b7a6e80e0cbfd0c29a8b1afe41637fb0f", + archive: "tinyhosts-0.3.0-macos-15-arm64.tar.gz", + sha256: "56ca93b2cab2010ff599c38179eb99f6ad67631e39f671be973256c3d9142381", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinyhosts-0.2.3-macos-15-x86_64.tar.gz", - sha256: "3b9b608d4fa3b638c93485fefca3825ebdc4220d0fc4dc2312f94a570dadcb83", + archive: "tinyhosts-0.3.0-macos-15-x86_64.tar.gz", + sha256: "0ffb9fc8d1c7fc7d95a7808af7722d8c6537a508c0c3a294ecf8fb8a28430207", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinyhosts-0.2.3-windows-2025-x86_64.zip", - sha256: "b9caaed8b6b2235098c88477d8101f2dcd26e4d5a68109957836272616f25b71", + archive: "tinyhosts-0.3.0-windows-2025-x86_64.zip", + sha256: "3d6d5f7fd0806ac6fd6c2ed0c60a9155d6c09a861ebd1ac0c43864e969f88fb9", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinyhosts-0.2.3-windows-2022-x86_64.zip", - sha256: "a1ca38023401efd5fda4db6f8b30701dd9a209563f74d5a961e6572e7c4b6223", + archive: "tinyhosts-0.3.0-windows-2022-x86_64.zip", + sha256: "a5003f1254052828d6fa362b993b0bed6b7256c252f969536090e148378c331f", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinyhosts-0.2.3-windows-11-arm64.zip", - sha256: "3912ad5a10ce87e2de5e91e29f8beb1ffb669e31ca2dcdd7e0e522b63607adf7", + archive: "tinyhosts-0.3.0-windows-11-arm64.zip", + sha256: "21f8909a2a81a541e3d0347510b2bf25baec59438a5536edcd57e65fae7f4a4d", }, ], load: LoadPolicy::Lazy, diff --git a/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs b/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs index f067acaad9b..dd52b1bd8d4 100644 --- a/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs +++ b/crates/openhuman-core/src/modules/registry/records_mcp_connectors.rs @@ -94,63 +94,63 @@ pub(crate) const TINYCONNECTORS: ModuleRecord = ModuleRecord { description: "OAuth connector integrations: accounts, actions, and triggers", bus_name: "ai.tinyhumans.connectors.Composio", object_path: "/ai/tinyhumans/connectors/Composio", - version: "0.13.2", - release_url: "https://github.com/tinyhumansai/tinyconnectors/releases/tag/v0.13.2", + version: "0.14.0", + release_url: "https://github.com/tinyhumansai/tinyconnectors/releases/tag/v0.14.0", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinyconnectors-0.13.2-ubuntu-24.04-x86_64.tar.gz", - sha256: "aeab20e2cc5fe2849b78150d3df919cb72f1f1d5b0485cae5a07b84b81784ffc", + archive: "tinyconnectors-0.14.0-ubuntu-24.04-x86_64.tar.gz", + sha256: "836bffa2fe4f935c8504f608e08bdf023326d1285fc977fd37149f2799899478", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinyconnectors-0.13.2-ubuntu-24.04-arm64.tar.gz", - sha256: "185b91fe3354bd0346e1281b34e066abc092e52a856e4ed3de9f6359c1e05cce", + archive: "tinyconnectors-0.14.0-ubuntu-24.04-arm64.tar.gz", + sha256: "b7dbafe019aca715e55444a37b5ff4fc469ef2bcc675dfa987e1de626e70fd22", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinyconnectors-0.13.2-ubuntu-22.04-x86_64.tar.gz", - sha256: "72cdb1705acd7598b66d529808191e4eeeb9c83c672061abfa1c92eec112fc8a", + archive: "tinyconnectors-0.14.0-ubuntu-22.04-x86_64.tar.gz", + sha256: "a29cb970b385cf8cd40998baa3b6f040447196d019a8d3413d67ca64a6509687", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinyconnectors-0.13.2-ubuntu-22.04-arm64.tar.gz", - sha256: "05ac114ba4575407aaca39016339ff334affa88a9652c1f66b4dcc1d59f9e7e9", + archive: "tinyconnectors-0.14.0-ubuntu-22.04-arm64.tar.gz", + sha256: "e961477b9cac1892beef8b498ba4f44daa4b39b8dddf277ec49496e3869427b5", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinyconnectors-0.13.2-macos-26-arm64.tar.gz", - sha256: "2ad04ea45d9c89f7cedfcc4d7448cae70c5468c7460d9ed8a742376040360700", + archive: "tinyconnectors-0.14.0-macos-26-arm64.tar.gz", + sha256: "5a24f1eabb0f246a5954e4d6f93cc0feeaed4fe6bfbf1fceb7399078e0e5734a", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinyconnectors-0.13.2-macos-26-x86_64.tar.gz", - sha256: "24e3ab0cfb624118d7371957341661f6e23b1c8379129257c6365395101cebfc", + archive: "tinyconnectors-0.14.0-macos-26-x86_64.tar.gz", + sha256: "5df7dbc74b2f39174e57695c5556ab4c232b775897e7bd534be015ead01ef51b", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinyconnectors-0.13.2-macos-15-arm64.tar.gz", - sha256: "6985646cde86616dc60ad3637867db2b8ea2e722ff9990cc4875dd27e68619b0", + archive: "tinyconnectors-0.14.0-macos-15-arm64.tar.gz", + sha256: "ea8010163f279f14e25a4ad884b4665e24385c560dd2caf46e8c80220556ba11", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinyconnectors-0.13.2-macos-15-x86_64.tar.gz", - sha256: "dd47655c4a5ef6344978c6780fb8400490e7c5e251dfb57ff99cd3050309fc08", + archive: "tinyconnectors-0.14.0-macos-15-x86_64.tar.gz", + sha256: "508e44cc9cd273dcbd2d269295ab0b7009efb1a27d3244ba9adde6b297306db1", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinyconnectors-0.13.2-windows-2025-x86_64.zip", - sha256: "0c1e4569fc655425f6a945dba57fc73c0f01dccc80d84ce297f44386b71202fe", + archive: "tinyconnectors-0.14.0-windows-2025-x86_64.zip", + sha256: "5a5e62e8d11bbdc792c9d8fd2a9b59bcea9add80978457ea0faa7c058cd48546", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinyconnectors-0.13.2-windows-2022-x86_64.zip", - sha256: "d0e07c73fce16619c1ff5265dc5f49c5ac8812534ec3b2dfb53edd7419ee1083", + archive: "tinyconnectors-0.14.0-windows-2022-x86_64.zip", + sha256: "1560ed909f338a5bf1118d644d44e1fbeee5c26461241df9f24630e3f9ba3cbe", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinyconnectors-0.13.2-windows-11-arm64.zip", - sha256: "0ae07bf8dacd998fd0edb9097a6e808c94c16059523ae416b150f0477bd4f9ad", + archive: "tinyconnectors-0.14.0-windows-11-arm64.zip", + sha256: "0bdfb0424ad62fc93ba66a7d8dd7063a39ae8656b78d2e65f4f1a6425aec3279", }, ], // Lazy: a user with no connected accounts should not pay to load it, and diff --git a/crates/openhuman-core/src/security/credentials/ops/gated_services.rs b/crates/openhuman-core/src/security/credentials/ops/gated_services.rs index 6ed6fa01820..36e5997fe8a 100644 --- a/crates/openhuman-core/src/security/credentials/ops/gated_services.rs +++ b/crates/openhuman-core/src/security/credentials/ops/gated_services.rs @@ -213,6 +213,10 @@ pub async fn stop_credential_gated_services(config: &Config) { // logged out). Symmetric with start_login_gated_services step 3b. crate::voice::always_on::stop(); + if let Err(error) = crate::integrations::composio::trigger_history::close_global().await { + log::warn!("[composio][history] archive release failed on sign-out: {error}"); + } + log::info!("[services] all login-gated services stopped"); } diff --git a/crates/openhuman-core/src/tools/ops.rs b/crates/openhuman-core/src/tools/ops.rs index f4a6151ef04..8e7c8f227fc 100644 --- a/crates/openhuman-core/src/tools/ops.rs +++ b/crates/openhuman-core/src/tools/ops.rs @@ -758,10 +758,11 @@ pub fn all_tools_with_runtime( action_dir, )); - // Hosting tools — deploy a workspace directory to a real hosting provider, - // with a managed database wired into it. Registered only once a credential + // Hosting tools — deploy a workspace directory through the compiled + // TinyHosts module. Registered only once a credential // actually resolves (`[hosting].api_key`, else the provider's environment - // variables): a tool that cannot work is worse than one that is absent, + // variables). Module loading stays lazy until a tool is invoked. + // A tool that cannot work is worse than one that is absent, // because a model retries it. A misconfigured section — an unknown provider // slug, a blank key — is logged and skipped rather than failing startup, // since nothing else in the process depends on hosting. diff --git a/crates/openhuman-embed/Cargo.toml b/crates/openhuman-embed/Cargo.toml index 1107364ba6b..eaa5cdffe34 100644 --- a/crates/openhuman-embed/Cargo.toml +++ b/crates/openhuman-embed/Cargo.toml @@ -12,7 +12,7 @@ publish = false langfuse = ["openhuman-core/langfuse"] # `channels` is in core's default set already; enabling it here too compiles # the `Runtime::channels` facade in a default build. -default = ["openhuman-core/default", "channels"] +default = ["openhuman-core/default", "channels", "modules"] http-server = ["openhuman-core/http-server"] inference = ["openhuman-core/inference"] documents = ["openhuman-core/documents"] diff --git a/crates/openhuman-embed/src/lib.rs b/crates/openhuman-embed/src/lib.rs index 921b0e999db..979ef8785fc 100644 --- a/crates/openhuman-embed/src/lib.rs +++ b/crates/openhuman-embed/src/lib.rs @@ -122,7 +122,6 @@ pub mod fanout; mod harness; pub mod identity; pub mod memory; -#[cfg(feature = "modules")] pub mod modules; mod permission; pub use permission::PermissionFuture; diff --git a/crates/openhuman-embed/src/modules.rs b/crates/openhuman-embed/src/modules.rs index e1d9279740c..89443dcdea4 100644 --- a/crates/openhuman-embed/src/modules.rs +++ b/crates/openhuman-embed/src/modules.rs @@ -3,10 +3,19 @@ /// Point module installs at artifacts bundled with the host (an app's /// resources directory) instead of downloading them. Set once, before boot; /// `Err` returns the path when one was already set. +#[cfg(feature = "modules")] pub use openhuman_core::modules::ops::set_bundled_releases_dir; /// The browser-control module. +#[cfg(feature = "modules")] pub mod browser { /// Its registry id, for a host that checks whether it is installed. pub use openhuman_core::modules::browser::MODULE_ID; } + +/// Pre-core calls through the process-wide loader, with explicit runtime configuration. +pub use openhuman_core::modules::client::{ModuleCallError, ModuleClient}; + +#[cfg(all(test, not(feature = "modules"), not(feature = "default")))] +#[path = "modules_tests.rs"] +mod tests; diff --git a/crates/openhuman-embed/src/modules_tests.rs b/crates/openhuman-embed/src/modules_tests.rs new file mode 100644 index 00000000000..5748762c98b --- /dev/null +++ b/crates/openhuman-embed/src/modules_tests.rs @@ -0,0 +1,12 @@ +use super::*; + +#[tokio::test] +async fn pre_core_client_without_a_loader_returns_unavailable() { + let mut config = crate::config::RuntimeConfig::default(); + config.modules.enabled = true; + config.modules.allow_download = false; + let result = ModuleClient::new(config) + .call::("tinyhosts", "Providers", ()) + .await; + assert_eq!(result, Err(ModuleCallError::Unavailable)); +} diff --git a/crates/openhuman-rpc/src/lib.rs b/crates/openhuman-rpc/src/lib.rs index 79cd37a9f0e..088cffbcb98 100644 --- a/crates/openhuman-rpc/src/lib.rs +++ b/crates/openhuman-rpc/src/lib.rs @@ -51,7 +51,6 @@ pub mod tinyhumans { /// config/artifact/chat-surface/modules facades, plus the few process-level /// facts. A curated list, not the crate; embed's `__host` is not on it. pub mod embed { - #[cfg(feature = "modules")] pub use openhuman_tinyhumans::embed::modules; pub use openhuman_tinyhumans::embed::{ artifacts, chat_surface, config, process, schema_for_rpc_method, CoreRuntime, diff --git a/crates/openhuman-tinyhumans/src/lib.rs b/crates/openhuman-tinyhumans/src/lib.rs index 4d0b8ecec3a..1abbe344316 100644 --- a/crates/openhuman-tinyhumans/src/lib.rs +++ b/crates/openhuman-tinyhumans/src/lib.rs @@ -62,7 +62,6 @@ pub mod embed { schema_for_rpc_method, PickListenPortError, HTTP_SERVER_COMPILED_IN, VOICE_COMPILED_IN, }; // The curated facades (each is an explicit list inside embed). - #[cfg(feature = "modules")] pub use openhuman_embed::modules; pub use openhuman_embed::{ artifacts, chat_surface, config, identity, memory, process, seams, session_store, diff --git a/docs/module-boundary-inventory.md b/docs/module-boundary-inventory.md new file mode 100644 index 00000000000..4b6f2f322e4 --- /dev/null +++ b/docs/module-boundary-inventory.md @@ -0,0 +1,199 @@ +# Loadable module boundary inventory + +Status: migration incomplete. This inventory records the pinned source at +`ad89cddbca`; it does not assert that the host already uses only contracts. +The machine-readable dependency policy is +[`scripts/ci/module-boundaries.json`](../scripts/ci/module-boundaries.json). + +Every owner below is the canonical upstream under +`https://github.com/tinyhumansai/`. Contract changes land there first. OpenHuman +host adapters switch only after compatible per-platform artifacts are released +and their digests pinned. Do not remove a dependency by removing its capability, +copying its implementation into a host, or accepting weaker isolation. + +| Owner | Linked implementation packages | Contract | Host consumers | Required module work | +| --- | --- | --- | --- | --- | +| tinyjuice | tinyjuice | tinyjuice-bus | core tools, compression, CCR REPL | HTML extraction, query supplied artifacts against module CCR store, schema and tool declarations; retain turn-bound model callbacks | +| tinyconnectors | tinyconnectors, tinyconnectors-sync | tinyconnectors-bus | core integrations, credentials and triggers | Argument preparation, calendar defaults, task windows, structured provider errors, trigger archives; preserve sign-in/out reconciliation | +| tinymcp | tinymcp | tinymcp-bus | core MCP registry, supervisor, CLI stdio/HTTP server | Supervisor notifications, server protocol operations and callbacks for approved host tools/resources/prompts | +| tinychannels | tinychannels, tinychannels-runtime; runtime/crypto code in contract | tinychannels-bus | core channels and podcast email, TinyHumans host, CLI REPL | Move providers, signing and pairing out of contract; relay config, pairing, start/stop/send/status, inbound/status callbacks, bounded delivery/draining | +| tinyhosts | tinyhosts | tinyhosts-bus | core hosting tools | Consume released Execute/Providers contract; keep workspace authorization, credentials, and approval policy in OpenHuman | +| tinywallet | tinywallet-crypto, tinywallet-web3, tinywallet-x402 via bus and direct imports | tinywallet-bus | core wallet/web3/x402 | Move behavioral re-exports out of bus; validation, transaction construction, quotes, swaps, payments, budgets, ledger; retain host custody/approval and confidential attestation | +| tinybox | tinybox-core, tinybox-jail, tinybox-docker, tinybox-host, tinybox-ssh | tinybox-bus (missing at pin) | core sandbox and security, desktop gateways | Discovery-only module needs handle-based sandbox/exec/streams/cancel/files/forward/status/close and shell-analysis facts; pre-core gateways use process loader | +| tinycomputer | tinycomputer-accessibility | tinycomputer-bus | core voice, paste/focus and permission checks | Permission, focus, paste, Globe listener vocabulary/operations; preserve native thread and target rules | +| tinyvoice | tinyvoice, cpal (also through accessibility probe) | tinyvoice-bus | core voice capture/hotkeys | Devices, recording/capture and hotkey lifecycle, bounded event batches; computer module permission decisions | +| tinyruntime | tinyruntime-pyserver | tinyruntime-bus | core Python worker, optional TinyJuice ML | Excluded from this migration: TinyRuntime is being removed separately | +| tinydocs / tinymemory | pdf-extract, calamine through tinymemory-integrations/documents-office | tinydocs-bus | core memory converter and file sources | Replace OfficeConverter with bus DocumentConverter; XLSX extraction plus existing PDF/DOCX/PPTX metadata/format coverage | +| tinysearch | none observed | tinysearch-bus | core module search proxy | Preserve existing bus adapter; enforce contract and host dependency graphs | + +## Gate behavior + +`pnpm rust:module-boundaries` checks the resolved normal and build dependency +closure of core, CLI, TUI and the excluded desktop workspace with all features. +Dev-only dependencies are excluded, including the test-only TinyWallet key +implementation. Package IDs, not dependency aliases, determine graph identity. +Target-specific dependencies are retained by Cargo metadata without a platform +filter. A new implementation package within an inventoried owner namespace +fails unless it is a registered contract. + +Contracts are resolved separately in generated probe workspaces, once with +default features and once with every declared feature. Only serialization, +schema and error-derive closures are approved. This catches optional runtime or +implementation imports independently of host feature selection. Probes do not +compile native module code, and their manifests and lockfiles are kept under +`target/module-boundaries/` in the checkout. + +Exceptions are exact package/scope pairs with reasons. Host exceptions still +traverse their descendants. Contract exceptions stop at the first unsafe edge: +its implementation closure is not approved, and that edge must be removed with +the owning migration. The channel and wallet contract migrations remain +explicitly outstanding. An unused exception fails, prompting removal instead +of leaving future regressions silently exempted. + +`pnpm rust:module-boundaries:complete` also fails while any exception or pending +contract exists. Passing the transitional gate means no unlisted boundary +violation was observed; it does not mean this plan is complete. + +## Remaining acceptance work + +The shared `openhuman_rpc::embed::modules::ModuleClient` takes explicit runtime +configuration and uses the same process-wide loader before or after core +startup. It has no linked fallback. Resolution reports and client errors use a +closed reason vocabulary and registry metadata. Cached resolution failures and +native terminal unavailability are deduplicated; independent failed invocations +each produce an event. Cached callers revisit reporting if resolution finished +before a Sentry client was bound. An unfinished loader wait produces no terminal +event. Reports clear request scope and exclude raw loader paths and remote fault +prose. Existing domain adapters still need to migrate through this reporting path. + +Upstream operations and artifacts, gateway integration, frozen tool restoration, +lifecycle regressions and coverage of all migrated adapter failures are still +required. No capability migration has landed in this change. The minimal/default/product/individual-feature build matrix, +platform compilation, runtime bus fixtures and shed measurements remain +separate acceptance checks. Dependency counts alone do not demonstrate a +build-time or binary-size improvement. + +## Upstream migration work + +Owner changes are independently reviewable; host dependencies and artifact pins +remain unchanged until compatible upstream releases are available. + +| Change | Canonical PR | Local verification | +| --- | --- | --- | +| Async HTML extraction seam, deadlines and generic tool-metadata sanitization in TinyTools | [tinytools#60](https://github.com/tinyhumansai/tinytools/pull/60) | 1,183 workspace tests and six doctests; clippy/build; independent sanitizer review accepted; all 113 source files at least 90% coverage | +| TinyJuice typed CCR/content queries, HTML extraction, pure schemas and declarations | [tinyjuice#59](https://github.com/tinyhumansai/tinyjuice/pull/59) | 737 tests; dynamic artifact E2E; module input/limit guards | +| Complete TinyDocs Markdown conversion for memory ingestion | [tinydocs#31](https://github.com/tinyhumansai/tinydocs/pull/31) | 183 tests; dynamic artifact E2E; per-file coverage at least 90% | +| TinyHosts pure vocabulary and authorized source preparation with captured deployment bytes | [tinyhosts#21](https://github.com/tinyhumansai/tinyhosts/pull/21) | 210 all-feature and 179 default tests including doctests; compiled snapshot and large legacy deployment probes; pure contract audit; all 19 implementation files at least 90% coverage; independent preparation review accepted | +| TinyComputer native permissions, confidential focus/paste and reliable Globe leases/read/shutdown | [tinycomputer#87](https://github.com/tinyhumansai/tinycomputer/pull/87) | 1,372 all-feature and 1,367 default tests; 90-member compiled artifact verification; independent ownership and event-loss review accepted; macOS cross-check and 233-file coverage gate pass; physical macOS helper not exercised on Linux | +| TinyConnectors argument preparation, task filtering, structured provider errors and leased archives | [tinyconnectors#46](https://github.com/tinyhumansai/tinyconnectors/pull/46) | 423 tests; dynamic artifact calls; user archive lifecycle fixtures; pure contract audit; per-file coverage at least 90% | +| TinyChannels contract vocabulary separated from provider, relay, pairing and runtime behavior | [tinychannels#56](https://github.com/tinyhumansai/tinychannels/pull/56) | 1,174 default and 1,180 all-feature tests; independent contract audit and relocation review accepted; bus files at least 98.65% coverage; legacy provider/worker coverage gaps disclosed | +| TinyBox pure contract, shell facts, reserved sandbox/process handles and acknowledged cleanup/shutdown | [tinybox#30](https://github.com/tinyhumansai/tinybox/pull/30) | 710 default and all-feature tests; 57 source files at least 90% coverage; compiled native-process artifact verification; independent lifecycle review accepted; supervised execution currently limited to Unix passthrough | +| TinyWallet pure contracts, address validation and stateless EVM construction with exact approval facts | [tinywallet#56](https://github.com/tinyhumansai/tinywallet/pull/56) | 745 all-feature and 601 default tests; four-chain signing and native/ERC-20/contract construction through the compiled artifact; pure contract audit; all 95 source files at least 90% coverage; independent construction review accepted | +| TinyVoice device enumeration, recording/continuous capture, reserved hotkey leases, replayable batches and acknowledged shutdown | [tinyvoice#23](https://github.com/tinyhumansai/tinyvoice/pull/23) | Contract 1.4; 32-member compiled artifact; 88 module tests; independent hotkey, Windows owner and Xvfb cleanup fixtures; pure contract audit, own-module formatting and Windows GNU test cross-check; formatted module source coverage 1,592/1,743 (91.34%), with the existing physical-device exclusion; no physical Windows/MSVC or macOS input validation | +| TinyRuntime pure JSONL worker vocabulary, persistent cache recipes, optional provider preparation and module-owned lifecycle | [tinyruntime#29](https://github.com/tinyhumansai/tinyruntime/pull/29) | 244 router unit tests, 6 default / 7 all-feature public API tests, 64 contract tests, 35 pyserver tests and doctests; 13-member compiled artifact verifies old-provider compatibility, preparation, cache reuse, adoption and rebuilding; per-file coverage gate passes; independent cleanup, cache and provider-bridge reviews accepted | +| TinyMCP pure shared vocabulary, supervisor observations, server callbacks and bounded text/argument/tool rendering operations | [tinymcp#54](https://github.com/tinyhumansai/tinymcp/pull/54) | 1,471 all-feature and 1,359 default tests; dynamic 44-member artifact verification; pure contract audit; all 93 source files at least 90% coverage; independent lifecycle, vocabulary and library-feature reviews accepted; external regressions cover module-disabled and host feature combinations | + +The TinyDocs and TinyJuice operations need new published module artifacts. +TinyHosts preserves existing member arities and adds authorized preparation inside +Execute; consuming that operation requires a new published artifact. No local build digest +has been used as a release pin, and these PRs do not yet remove any host exception. + +TinyChannels preserves its serialized vocabulary and moves behavioral APIs to +implementation crates, using compatibility extension traits where needed. Its +relay/pairing/delivery operations now have owning-module implementations in PR 56, +including bounded queues, replayable results and shutdown reconciliation. The +OpenHuman adapters remain outstanding. +Published v0.1.13 contains the earlier implementation-bearing contract, so that +release does not unblock this contract cut. + +TinyBox currently refuses supervised execution on unsupported backends and platforms. +Docker supervision is implemented in PR 30 and still undergoing lifecycle review. +Namespace/SSH and native Windows supervision, streaming, file transfer, forwarding +and gateway operations remain required before migrating its host callers. + +TinyRuntime is now excluded from the active migration because it is being removed +separately. The previously published owning PR remains recorded above; the frozen +Python-provider work has not been published or integrated. The boundary inventory +still records existing dependencies until the separate removal reaches OpenHuman. + +TinyHosts’ bounded directory preparation currently returns up to 4 MiB of source. +Existing larger Launch/Deploy requests keep their transport budget. Streaming or +module-owned prepared artifacts remain required for larger host directory inputs. + +TinyComputer’s reliable Globe reads retain one bounded snapshot until acknowledgment. +Native overflow, malformed events and unexpected EOF mark continuity loss. Hosts +must await its terminal shutdown before unloading. TinyVoice owns native Linux and +Windows hotkey listeners and accepts sequenced generic facts for macOS. Its reads +retain the previous batch across continuity loss, allowing a valid acknowledgment +to retrieve the inactive reset snapshot. Shared activation types live in the bus +contract and are re-exported by the implementation. OpenHuman still needs the +Computer-to-Voice adapter and compatible released artifacts before switching. + +TinyDocs PR 35 moves PNG/JPEG header interpretation into the module and adds +bounded typed image inspection. Its parser regressions and rebuilt artifact +verification pass. The source API change requires a minor package release; +host presentation and document ingestion still await the approved release. + +## Current delivery gates + +The module PR babysitters repair CI and review findings and merge only after +checks, approval, resolved feedback and mergeability all pass. TinyRuntime is +excluded from that work. CI success does not clear an old changes-requested +review, and resolved threads do not establish independent approval. + +TinyConnectors PR 46 is merged; its canonical main CI passed. The minor release +[run 38084368799](https://github.com/tinyhumansai/tinyconnectors/actions/runs/38084368799) +is building compatible platform artifacts. Host adoption remains gated on +published packages and verified digests. No host dependency cut is claimed here. + +## Released connector integration (2026-10-11) + +The `module-connectors-host` branch consumes TinyConnectors v0.14.0, source +`8b35ca5`, contract 1.13. All 16 archive bytes were hashed and matched both +`checksum.toml` and GitHub asset digests before replacing registry pins. The +host calls the module for preparation, calendar defaults, task-window filtering, +provider classification and opaque archive operations. It releases archive +leases on scope changes, sign-out and runtime shutdown; cancellation during +open retains the handle for cleanup. Frozen recorded tool declarations are +retained by the existing session adapter. + +The normal/build graph audit removes the two connector implementation +exceptions and reports 32 remaining temporary exceptions and two pending +contracts. The measured flows profile is 335 packages / 313 names / two native +builds, compared with the combined foundation baseline of 340 / 318 / two. +Validation passes: 354 Composio tests against the released native artifact and +local provider mocks, 176 module/Sentry tests, ten recorded-tool tests, the +no-loader library check, the contract/host graph audit, crate-chain, feature +forwarding and Rust layout checks. A new Sentry regression first captured two +events; the fixed adapter emits one sanitized event and preserves existing +provider messages. The broad native suite first exposed changed authentication +text and an uninitialized-history read; both existing regressions now pass. +This does not claim completion of the full migration. + +TinyWallet #56 and TinyHosts #21 merged with green hosted checks and approval. +Their release workflows exposed two packaging failures: Wallet still selected +an implementation crate with an unpublished sibling dependency, and Hosts +shared native build-script caches across Ubuntu images. Owner follow-ups +[Wallet #60](https://github.com/tinyhumansai/tinywallet/pull/60) and +[Hosts #25](https://github.com/tinyhumansai/tinyhosts/pull/25) correct the release +workflows with regressions; both follow-ups have merged. Hosts v0.3.0 completed +in run 38087761940, and all 16 archive bytes match the checksum file and GitHub +asset digests. Its host adapter is isolated in `module-tinyhosts-host`. + +Wallet v0.9.0 committed source `69938a6` but its original run could not push the +tag. The exact release commit was tagged successfully through the owner remote. +Recovery run 38088958171 skipped the bundle job after the skipped versioning +ancestor; [Wallet #61](https://github.com/tinyhumansai/tinywallet/pull/61) adds the +missing recovery condition and a regression. Run 38089224879 uses the release-tag +workflow to build the existing tag and completed successfully. All eleven +supported-platform archives and the checksum file match their GitHub asset +digests and checksum entries. The older tagged workflow does not produce the +five additional distro bundles; the next complete wallet-operations release +must use the current distro-inclusive workflow before host adoption. Wallet #61 +merged with 35 passing hosted checks and approval. + +The current base also contains unrelated source/artifact pin drift for computer, +search, docs, runtime/providers, MCP and Box. The module-pin gate reports this; +it is not treated as a passing check or hidden by new exceptions. The stale +Wallet and Channels pin exemptions were removed because those base pins now +agree. TinyRuntime changes remain excluded from this migration. diff --git a/gitbooks/developing/architecture/README.md b/gitbooks/developing/architecture/README.md index 6eff1df3195..d172c32a8e8 100644 --- a/gitbooks/developing/architecture/README.md +++ b/gitbooks/developing/architecture/README.md @@ -97,3 +97,22 @@ The code is at [github.com/tinyhumansai/openhuman](https://github.com/tinyhumans - [Frontend](frontend.md) and [Tauri shell](tauri-shell.md) for the two outer layers. - [Loadable modules](../loadable-modules.md) and [Pluggable engines](../engines.md) for what plugs into the core. - [`AGENTS.md`](https://github.com/tinyhumansai/openhuman/blob/main/AGENTS.md) for the repo-wide rules, including which `vendor/` submodule owns what. + +## Loadable component dependency boundary + +Hosts execute loadable component behavior through compiled TinyBus modules and +link only the components' minimal `*-bus` vocabulary. Contract crates contain +serialized types, errors, identifiers, versions, schemas and tool declarations; +providers, parsers, cryptography, storage, native resources and execution +algorithms belong inside modules. OpenHuman retains policy, credentials, +approvals, cancellation and product orchestration. Stateful resources cross +the bus as opaque handles with explicit cleanup operations. + +This boundary is still being migrated. The +[module boundary inventory](../../../docs/module-boundary-inventory.md) names +existing exceptions and required upstream work. `pnpm rust:module-boundaries` +checks host normal/build dependency graphs and independently resolved contract +graphs. Its transitional pass does not certify completion; +`pnpm rust:module-boundaries:complete` requires an empty exception list and no +pending contracts. Host adapters switch after compatible module artifacts are +released and pinned, preserving lazy loading and cached resolution. diff --git a/gitbooks/developing/loadable-modules.md b/gitbooks/developing/loadable-modules.md index 0bbe97332cd..e2a064b6e15 100644 --- a/gitbooks/developing/loadable-modules.md +++ b/gitbooks/developing/loadable-modules.md @@ -11,6 +11,25 @@ Feature gates decide what compiles into the binary. Modules decide what the runn The trade is explicit. A module shares the core's address space and crash domain, so it is trusted code and the admission checks matter. In return, a user who never generates a document never pays for the document writer in download size, memory or dependencies. +## Host access and contracts + +Hosts reach module clients through the `openhuman-rpc` facade chain. The shared +`openhuman_rpc::embed::modules::ModuleClient` accepts explicit configuration +and uses the process-wide lazy loader before or after core startup. Constructing +a client starts nothing and requires no signed-in session. Set bundled-artifact +discovery before its first call. Without the loader, a call returns an explicit +unavailable error. + +A `*-bus` crate contains serialized vocabulary, errors, identifiers, versions, +schemas and static tool declarations. Parsing, provider dispatch, cryptography, +processes and device access execute inside the compiled module. Hosts retain +configuration, credentials, approvals and execution policy. They never link an +implementation fallback when a module is unavailable. Stateful adapters release +module-owned resources through explicit close, cancellation and shutdown calls. + +The remaining migration work and temporary dependency exceptions are tracked +in [the module-boundary inventory](../../docs/module-boundary-inventory.md). + ## The registry `crates/openhuman-core/src/modules/registry/` compiles in fourteen records. Only this registry may select an artifact. Nothing reads a module name from config and goes looking for it. diff --git a/package.json b/package.json index fc0d3a402fb..cb7f4938ab2 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1", "rust:check": "pnpm --filter openhuman-app rust:check", "rust:clippy": "cargo clippy -p openhuman -p openhuman-cli -p openhuman-tinyhumans -- -D warnings && pnpm --filter openhuman-app rust:clippy", + "rust:module-boundaries": "node scripts/ci/check-module-boundaries.mjs", + "rust:module-boundaries:complete": "node scripts/ci/check-module-boundaries.mjs --require-complete", "rust:layout": "node scripts/ci/check-openhuman-rust-layout.mjs && node scripts/ci/check-crate-chain.mjs && node scripts/ci/check-storage-bypass.mjs", "rust:ignored-tests": "node scripts/ci/check-ignored-tests.mjs", "dep:audit": "bash scripts/dep-audit/run.sh", diff --git a/scripts/__tests__/check-module-boundaries.test.mjs b/scripts/__tests__/check-module-boundaries.test.mjs new file mode 100644 index 00000000000..323c03c0154 --- /dev/null +++ b/scripts/__tests__/check-module-boundaries.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { auditGraph, graph, isImplementation, validatePolicy, CONTRACT_REGISTRY } from '../ci/check-module-boundaries.mjs'; + +const policy = JSON.parse(readFileSync(new URL('../ci/module-boundaries.json', import.meta.url), 'utf8')); +const strict = { ...policy, exceptions: [] }; +function metadata(edges, names = {}) { + const ids = [...new Set(Object.entries(edges).flatMap(([id, deps]) => [id, ...deps.map(d => d[0])]))]; + return { + packages: ids.map(id => ({ id, name: names[id] ?? id, source: CONTRACT_REGISTRY })), + resolve: { nodes: ids.map(id => ({ id, deps: (edges[id] ?? []).map(([pkg, kind = null, target = null]) => ({ pkg, dep_kinds: [{ kind, target }] })) })) }, + }; +} + +test('checked-in inventory has explicit owners and reasoned exceptions', () => validatePolicy(policy)); +test('host traversal rejects direct, wrapper and build dependencies', () => { + const m = metadata({ host: [['wrapper'], ['tinyvoice', 'build']], wrapper: [['tinyjuice']] }); + const result = auditGraph(m, ['host'], strict); + assert.deepEqual(result.violations.map(v => v.path), [['host', 'tinyvoice'], ['host', 'wrapper', 'tinyjuice']]); +}); +test('dev-only implementation dependencies do not enter shipped graphs', () => { + const m = metadata({ host: [['test-helper', 'dev']], 'test-helper': [['tinyjuice']] }); + assert.deepEqual(auditGraph(m, ['host'], strict).violations, []); +}); +test('a dependency with both dev and normal kinds remains forbidden', () => { + const m = metadata({ host: [['tinyjuice', 'dev']] }); + m.resolve.nodes[0].deps[0].dep_kinds.push({ kind: null, target: null }); + assert.equal(auditGraph(m, ['host'], strict).violations.length, 1); +}); +test('platform-specific build and normal dependencies are inspected', () => { + const m = metadata({ host: [['tinybox-jail', null, 'cfg(windows)'], ['tinyvoice', 'build', 'cfg(target_os = "macos")']] }); + assert.equal(auditGraph(m, ['host'], strict).violations.length, 2); +}); +test('package identity is not a dependency alias', () => { + const m = metadata({ host: [['pkg#implementation']] }, { 'pkg#implementation': 'tinyjuice' }); + assert.equal(auditGraph(m, ['host'], strict).violations[0].package, 'tinyjuice'); +}); +test('new implementation packages in owning repositories are forbidden', () => { + assert.equal(isImplementation('tinywallet-new-provider', strict), true); + assert.equal(isImplementation('tinywallet-bus', strict), false); + assert.equal(isImplementation('tinywallet-unregistered-bus', strict), true); + assert.equal(isImplementation('tinysearch', strict), true); +}); +test('contracts permit only the serialization/schema/error closure', () => { + const m = metadata({ 'tinyjuice-bus': [['serde'], ['thiserror']], serde: [['serde_derive']], serde_derive: [['syn']], thiserror: [['thiserror-impl']] }); + assert.deepEqual(auditGraph(m, ['tinyjuice-bus'], strict, 'tinyjuice-bus').violations, []); +}); +test('contracts reject transport, runtime, HTTP, database and native libraries', () => { + for (const dependency of ['tinybus', 'tokio', 'reqwest', 'rusqlite', 'cpal', 'tinyjuice']) { + const m = metadata({ 'tinyjuice-bus': [[dependency]] }); + assert.equal(auditGraph(m, ['tinyjuice-bus'], strict, 'tinyjuice-bus').violations[0].package, dependency); + } +}); +test('contracts reject an indirect implementation in an approved dependency', () => { + const m = metadata({ 'tinyjuice-bus': [['serde']], serde: [['tinyjuice']] }); + assert.deepEqual(auditGraph(m, ['tinyjuice-bus'], strict, 'tinyjuice-bus').violations[0].path, ['tinyjuice-bus', 'serde', 'tinyjuice']); +}); +test('host exceptions do not exempt contract dependencies', () => { + const m = metadata({ 'tinyjuice-bus': [['tinyjuice']] }); + assert.equal(auditGraph(m, ['tinyjuice-bus'], policy, 'tinyjuice-bus').violations.length, 1); +}); +test('exceptions retain findings and do not hide new host descendants', () => { + const m = metadata({ host: [['tinyjuice']], tinyjuice: [['tinyjuice-new-engine']] }); + const result = auditGraph(m, ['host'], policy); + assert.equal(result.exceptions.length, 1); + assert.equal(result.violations[0].package, 'tinyjuice-new-engine'); +}); +test('cycles terminate and versions preserve distinct package identities', () => { + const m = metadata({ host: [['juice-old'], ['juice-new']], 'juice-old': [['host']] }, { 'juice-old': 'tinyjuice', 'juice-new': 'tinyjuice' }); + assert.equal(auditGraph(m, ['host'], strict).violations.length, 2); +}); +test('missing resolve, missing roots and dangling edges fail closed', () => { + assert.throws(() => graph({ packages: [] }), /resolved dependency graph/); + assert.throws(() => auditGraph(metadata({ host: [] }), ['missing'], strict), /root package/); + const m = metadata({ host: [['serde']] }); + m.resolve.nodes.pop(); + assert.throws(() => auditGraph(m, ['host'], strict), /unresolved dependency/); +}); +test('missing dependency kinds fail closed rather than assuming dev-only', () => { + const m = metadata({ host: [['tinyjuice']] }); + m.resolve.nodes[0].deps[0].dep_kinds = []; + assert.throws(() => auditGraph(m, ['host'], strict), /missing dependency kinds/); +}); +test('inventory rejects broad, duplicate, empty-reason or mis-scoped exceptions', () => { + for (const item of [ + { scope: 'hosts', package: '*', reason: 'broad' }, + { scope: 'hosts', package: 'tinyjuice', reason: '' }, + { scope: 'tinyjuice-bus', package: 'serde', reason: 'approved' }, + { scope: 'missing-bus', package: 'tokio', reason: 'untracked' }, + policy.exceptions[0], + ]) assert.throws(() => validatePolicy({ ...policy, exceptions: [...policy.exceptions, item] })); +}); + +test('a same-named contract from another source cannot evade independent auditing', () => { + const m = metadata({ host: [['tinyjuice-bus']] }); + m.packages.find(pkg => pkg.name === 'tinyjuice-bus').manifest_path = '/unexpected/Cargo.toml'; + const pinned = { ...strict, contractManifests: { 'tinyjuice-bus': '/pinned/Cargo.toml' } }; + assert.equal(auditGraph(m, ['host'], pinned).violations.length, 1); + m.packages.find(pkg => pkg.name === 'tinyjuice-bus').manifest_path = '/pinned/Cargo.toml'; + assert.equal(auditGraph(m, ['host'], pinned).violations.length, 0); +}); + + +test('allowlisted vocabulary names cannot mask a path, git or alternate-registry package', () => { + for (const source of [null, undefined, 'git+https://example.invalid/serde', 'registry+https://example.invalid/index']) { + const m = metadata({ 'tinyjuice-bus': [['serde']], serde: [['syn']] }); + m.packages.find(pkg => pkg.name === 'serde').source = source; + const result = auditGraph(m, ['tinyjuice-bus'], strict, 'tinyjuice-bus'); + assert.deepEqual(result.violations.map(v => v.package), ['serde']); + } +}); + +test('every approved vocabulary descendant must come from the approved registry', () => { + const m = metadata({ 'tinyjuice-bus': [['serde']], serde: [['syn']] }); + m.packages.find(pkg => pkg.name === 'syn').source = null; + assert.deepEqual(auditGraph(m, ['tinyjuice-bus'], strict, 'tinyjuice-bus').violations[0].path, ['tinyjuice-bus', 'serde', 'syn']); +}); + +test('bare owning packages and their implementation descendants are both forbidden', () => { + for (const prefix of policy.implementationPrefixes) { + const owner = prefix.replace(/-$/, ''); + assert.equal(isImplementation(owner, strict), true, owner); + assert.equal(isImplementation(`${owner}-new-engine`, strict), true, owner); + const contract = policy.contracts.find(item => item.name === `${owner}-bus`); + if (contract) assert.equal(isImplementation(contract.name, strict), false, contract.name); + } +}); diff --git a/scripts/__tests__/self-hosted-lanes.test.mjs b/scripts/__tests__/self-hosted-lanes.test.mjs index 2019314eafe..7550bdb7d0f 100644 --- a/scripts/__tests__/self-hosted-lanes.test.mjs +++ b/scripts/__tests__/self-hosted-lanes.test.mjs @@ -651,3 +651,27 @@ test("the rust-core path filter arms the lane for every crate the tui depends on assert.ok(block.includes(`'crates/openhuman-${crate}/**'`), crate); } }); + +test("module boundaries run for Rust and scripts changes in both profiles", () => { + for (const profile of ["hosted", "ex63"]) { + for (const area of ["rustCore", "rustTauri", "scripts"]) { + const plan = buildPlan({ + profile, + areas: { ...NONE, [area]: true }, + env: profile === "ex63" ? EX63_ENV : {}, + }); + const check = plan.lanes.flatMap(lane => lane.checks).find(check => check.name === "module-boundaries"); + assert.equal(check.when, true, `${profile}:${area}`); + assert.equal(check.run, "bash scripts/ci-cancel-aware.sh node scripts/ci/check-module-boundaries.mjs"); + } + } +}); + + +test("module boundary policy and checker changes arm their own scripts lane", () => { + const filters = fs.readFileSync(path.join(repoRoot, ".github/ci-paths-filter.yml"), "utf8"); + const scripts = filters.split(/^scripts:\s*$/m)[1].split(/^\S[^\n]*:/m)[0]; + for (const file of ["scripts/ci/check-module-boundaries.mjs", "scripts/ci/module-boundaries.json"]) { + assert.ok(scripts.includes(`'${file}'`), `${file} must arm the scripts area`); + } +}); diff --git a/scripts/ci/README.md b/scripts/ci/README.md index a756855e864..e9cb0ab8eb7 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -24,8 +24,21 @@ changed-line diff-cover check (>= 80%) in `ci-lite.yml`. ## Running locally -Only `check-openhuman-rust-layout.mjs` and `check-crate-chain.mjs` are wired to a pnpm script -(`pnpm rust:layout`); the rest are invoked directly, e.g. +The module boundary audit (`pnpm rust:module-boundaries`) resolves the root and +excluded desktop host with all features, traversing normal and build edges on +all platforms. It also resolves each bus contract independently with default +and all features, outside the owning module workspace, to avoid feature +unification with implementation crates. It runs in the Rust scripts lane. +Temporary exceptions in [`module-boundaries.json`](module-boundaries.json) must +have reasons; unused exceptions fail the audit. A passing transitional audit is +not a completed migration. `pnpm rust:module-boundaries:complete` additionally +rejects all exceptions and pending contracts. Cargo failure is an audit failure. +Generated probes and lockfiles stay in the checkout's `target/module-boundaries/`. + +`check-openhuman-rust-layout.mjs` and `check-crate-chain.mjs` run through +`pnpm rust:layout`. The module-boundary audit has the two pnpm commands above, +and the ambient-access ratchet runs through `pnpm saas:ambient`. Other gates +are invoked directly, e.g. `node scripts/ci/check-feature-forwarding.mjs` or `bash scripts/ci/orch-ip-gate.sh`, and can be run the same way locally. Every gate here runs from `.github/workflows/ci-lite.yml`; `ci-full.yml` does not diff --git a/scripts/ci/check-dep-sim-calibration.sh b/scripts/ci/check-dep-sim-calibration.sh index c1cd19c8ebb..f5c6acb977e 100755 --- a/scripts/ci/check-dep-sim-calibration.sh +++ b/scripts/ci/check-dep-sim-calibration.sh @@ -110,11 +110,12 @@ cd "$(dirname "$0")/../.." # 315 -> 316 on 2026-10-09: cron and flows on the storage ports add the # first-party tinyflows-drivers crate (one package, one name; no new # external crate). -# 316 -> 317 on 2026-10-10: storage-file is always on, adding the -# first-party tinystoragedrivers-file crate (one package, one name; no new -# external crate). -# This matches the current `flows:339:317:2` entry in +# 316 -> 317: storage-file adds the first-party file driver. +# 317 -> 318: pre-core registry vocabulary adds the pure tinycomputer-bus. + +# This matches the current `flows:335:313:2` entry in # scripts/kernel-floor.limits; its preceding entries are historical. -EXPECTED_NAMES=317 +# 318 -> 313: connector implementation cut (-8) and explicit flows loader (+3). +EXPECTED_NAMES=313 exec python3 scripts/dep-sim.py --cut-nothing --expect-names "${EXPECTED_NAMES}" diff --git a/scripts/ci/check-module-boundaries.mjs b/scripts/ci/check-module-boundaries.mjs new file mode 100644 index 00000000000..533b0c09ce8 --- /dev/null +++ b/scripts/ci/check-module-boundaries.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// Audit resolved normal/build edges, never Cargo.lock membership or dev edges. +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const CONTRACT_REGISTRY = 'registry+https://github.com/rust-lang/crates.io-index'; + +export const CONTRACT_CLOSURE = new Set([ + 'serde', 'serde_core', 'serde_derive', 'serde_json', 'itoa', 'memchr', 'zmij', + 'thiserror', 'thiserror-impl', 'schemars', 'schemars_derive', 'dyn-clone', + 'ref-cast', 'ref-cast-impl', 'serde_derive_internals', + 'proc-macro2', 'quote', 'syn', 'unicode-ident', +]); + +export function isImplementation(name, policy) { + return policy.implementationPackages.includes(name) || + policy.implementationPrefixes.some(prefix => (name === prefix.replace(/-$/, '') || name.startsWith(prefix)) && + !policy.contracts.some(contract => contract.name === name)); +} + +/** Fail closed on unresolved metadata; retain package IDs (including versions). */ +export function graph(metadata) { + if (!metadata.resolve?.nodes || !metadata.packages?.length) { + throw new Error('Cargo metadata must contain packages and a resolved dependency graph'); + } + const packages = new Map(metadata.packages.map(pkg => [pkg.id, pkg])); + const nodes = new Map(metadata.resolve.nodes.map(node => [node.id, node])); + const edges = id => { + const node = nodes.get(id); + if (!packages.has(id) || !node) throw new Error(`missing resolved package ${id}`); + if (!Array.isArray(node.deps)) throw new Error(`missing dependency kinds for ${id}`); + return node.deps.filter(dep => { + if (!Array.isArray(dep.dep_kinds) || dep.dep_kinds.length === 0) { + throw new Error(`missing dependency kinds for ${dep.pkg}`); + } + if (dep.dep_kinds.some(kind => ![null, 'build', 'dev'].includes(kind.kind))) { + throw new Error(`unknown dependency kind for ${dep.pkg}`); + } + return dep.dep_kinds.some(kind => kind.kind === null || kind.kind === 'build'); + }).map(dep => { + if (!packages.has(dep.pkg) || !nodes.has(dep.pkg)) { + throw new Error(`unresolved dependency ${dep.pkg}`); + } + return dep.pkg; + }); + }; + return { packages, edges }; +} + +/** One shortest dependency path per forbidden package, including build scripts. */ +export function auditGraph(metadata, roots, policy, contract = null) { + const { packages, edges } = graph(metadata); + const violations = []; + const exceptions = []; + for (const root of roots) { + const candidates = [...packages.values()].filter(pkg => pkg.name === root); + if (candidates.length !== 1) throw new Error(`expected exactly one root package ${root}`); + const queue = [[candidates[0].id]]; + const seen = new Set(); + for (let at = 0; at < queue.length; at++) { + const path = queue[at]; + const id = path.at(-1); + if (seen.has(id)) continue; + seen.add(id); + const name = packages.get(id).name; + if (path.length > 1) { + const sourceMismatch = !contract && policy.contractManifests?.[name] && + packages.get(id).manifest_path !== policy.contractManifests[name]; + const forbidden = contract ? !CONTRACT_CLOSURE.has(name) || packages.get(id).source !== CONTRACT_REGISTRY : + isImplementation(name, policy) || sourceMismatch; + if (forbidden) { + const scope = contract ?? 'hosts'; + const exemption = policy.exceptions.find(item => item.scope === scope && item.package === name); + const finding = { scope, package: name, path: path.map(id => packages.get(id).name) }; + (exemption ? exceptions : violations).push(finding); + // Contract exceptions pin the first unsafe dependency. Its implementation + // closure is deliberately NOT approved vocabulary and must disappear + // with this edge. Hosts still traverse it to catch indirect implementations. + if (contract) continue; + } + } + for (const next of edges(id)) queue.push([...path, next]); + } + } + return { violations, exceptions }; +} + +export function validatePolicy(policy) { + for (const key of ['implementationPackages', 'implementationPrefixes', 'contracts', 'pendingContracts', 'exceptions']) { + if (!Array.isArray(policy[key])) throw new Error(`policy.${key} must be an array`); + } + if (!policy.contracts.length || !policy.implementationPackages.length || !policy.implementationPrefixes.length) { + throw new Error('boundary inventory may not be empty'); + } + const names = new Set(); + for (const contract of [...policy.contracts, ...policy.pendingContracts]) { + if (!contract.name?.endsWith('-bus') || !contract.manifest || !contract.owner || names.has(contract.name)) { + throw new Error(`invalid or duplicate contract ${contract.name}`); + } + if (policy.pendingContracts.includes(contract) && !contract.reason?.trim()) { + throw new Error(`pending contract ${contract.name} needs a reason`); + } + names.add(contract.name); + } + const seen = new Set(); + for (const item of policy.exceptions) { + const key = `${item.scope}:${item.package}`; + if (!item.reason?.trim() || !item.package || !item.scope || seen.has(key)) { + throw new Error(`invalid or duplicate exception ${key}`); + } + if (item.scope === 'hosts' ? !isImplementation(item.package, policy) : + !names.has(item.scope) || CONTRACT_CLOSURE.has(item.package)) { + throw new Error(`exception ${key} does not name a forbidden dependency`); + } + seen.add(key); + } +} + +function cargo(root, args) { + // All generated manifests and logs stay under the checkout's target/. + const stdout = execFileSync('bash', [join(root, 'scripts/ci-cancel-aware.sh'), + 'cargo', 'metadata', '--format-version', '1', ...args], { + cwd: root, encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, stdio: ['ignore', 'pipe', 'inherit'], + }); + return JSON.parse(stdout); +} + +/** Resolve only this contract's selected features, without module/host unification. */ +function contractMetadata(root, contract, allFeatures) { + const manifest = resolve(root, contract.manifest); + const listing = cargo(root, ['--manifest-path', manifest, '--no-deps']); + const pkg = listing.packages.find(pkg => resolve(pkg.manifest_path) === manifest); + if (!pkg || pkg.name !== contract.name) throw new Error(`missing contract ${contract.name}`); + const harness = join(root, 'target/module-boundaries', contract.name, allFeatures ? 'all' : 'default'); + mkdirSync(join(harness, 'src'), { recursive: true }); + const features = allFeatures ? Object.keys(pkg.features) : []; + writeFileSync(join(harness, 'Cargo.toml'), `[workspace]\n[package]\nname = "boundary-probe"\nversion = "0.0.0"\nedition = "2024"\n[dependencies]\n${contract.name} = { path = ${JSON.stringify(dirname(manifest))}, features = ${JSON.stringify(features)} }\n`); + writeFileSync(join(harness, 'src/lib.rs'), ''); + return cargo(root, ['--manifest-path', join(harness, 'Cargo.toml')]); +} + +export function run(root, strict = false) { + const policy = JSON.parse(readFileSync(join(root, 'scripts/ci/module-boundaries.json'), 'utf8')); + validatePolicy(policy); + policy.contractManifests = Object.fromEntries(policy.contracts.map(contract => + [contract.name, resolve(root, contract.manifest)])); + for (const contract of policy.pendingContracts) { + if (existsSync(join(root, contract.manifest))) { + throw new Error(`pending contract ${contract.name} now exists: register it for independent auditing`); + } + } + const results = []; + for (const [manifest, roots] of [ + ['Cargo.toml', ['openhuman', 'openhuman-cli', 'openhuman-tui']], + ['crates/openhuman-app/Cargo.toml', ['openhuman-app']], + ]) { + const metadata = cargo(root, ['--manifest-path', join(root, manifest), '--locked', '--all-features']); + results.push(auditGraph(metadata, roots, policy)); + } + for (const contract of policy.contracts) { + for (const all of [false, true]) { + results.push(auditGraph(contractMetadata(root, contract, all), [contract.name], policy, contract.name)); + } + } + const violations = results.flatMap(result => result.violations); + const exceptions = results.flatMap(result => result.exceptions); + for (const item of violations) console.error(`FORBIDDEN [${item.scope}] ${item.path.join(' -> ')}`); + const used = new Set(exceptions.map(item => `${item.scope}:${item.package}`)); + const stale = policy.exceptions.filter(item => !used.has(`${item.scope}:${item.package}`)); + for (const item of stale) console.error(`STALE exception [${item.scope}] ${item.package}: remove it`); + const pending = policy.pendingContracts.length + policy.exceptions.length; + console.log(`Module boundaries: ${violations.length} violations, ${stale.length} stale exceptions; ${policy.exceptions.length} temporary exceptions, ${policy.pendingContracts.length} pending contracts.`); + if (pending) console.log('Migration INCOMPLETE. --require-complete rejects every temporary exception and pending contract.'); + return violations.length === 0 && stale.length === 0 && (!strict || pending === 0); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const args = process.argv.slice(2); + if (args.some(arg => arg.startsWith('--') && arg !== '--require-complete')) throw new Error('unknown option'); + const root = resolve(args.find(arg => !arg.startsWith('--')) ?? join(dirname(fileURLToPath(import.meta.url)), '../..')); + process.exitCode = run(root, args.includes('--require-complete')) ? 0 : 1; + } catch (error) { + console.error(`Module boundary audit could not complete: ${error.message}`); + process.exitCode = 2; + } +} diff --git a/scripts/ci/module-boundaries.json b/scripts/ci/module-boundaries.json new file mode 100644 index 00000000000..572dd8c70ad --- /dev/null +++ b/scripts/ci/module-boundaries.json @@ -0,0 +1,270 @@ +{ + "implementationPackages": [ + "tinyjuice", + "tinyconnectors", + "tinyconnectors-sync", + "tinymcp", + "tinychannels", + "tinychannels-runtime", + "tinyhosts", + "tinybox-core", + "tinybox-jail", + "tinybox-docker", + "tinybox-host", + "tinybox-ssh", + "tinycomputer-accessibility", + "tinyvoice", + "tinyruntime-pyserver", + "tinywallet-crypto", + "tinywallet-web3", + "tinywallet-x402", + "pdf-extract", + "calamine", + "tinybox-linux", + "tinybox-microvm", + "tinybox-sync", + "tinydocs", + "tinydocs-core", + "tinydocs-extract", + "tinysearch", + "tinysearch-core", + "tinyruntime", + "cpal" + ], + "implementationPrefixes": [ + "tinyjuice-", + "tinyconnectors-", + "tinymcp-", + "tinychannels-", + "tinyhosts-", + "tinybox-", + "tinywallet-", + "tinycomputer-", + "tinyvoice-", + "tinyruntime-", + "tinydocs-", + "tinysearch-" + ], + "contracts": [ + { + "name": "tinyjuice-bus", + "manifest": "vendor/tinyjuice/crates/tinyjuice-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinyjuice" + }, + { + "name": "tinyconnectors-bus", + "manifest": "vendor/tinyconnectors/crates/tinyconnectors-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinyconnectors" + }, + { + "name": "tinymcp-bus", + "manifest": "vendor/tinymcp/crates/tinymcp-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinymcp" + }, + { + "name": "tinychannels-bus", + "manifest": "vendor/tinychannels/crates/tinychannels-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinychannels" + }, + { + "name": "tinywallet-bus", + "manifest": "vendor/tinywallet/crates/tinywallet-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinywallet" + }, + { + "name": "tinycomputer-bus", + "manifest": "vendor/tinycomputer/crates/tinycomputer-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinycomputer" + }, + { + "name": "tinyvoice-bus", + "manifest": "vendor/tinyvoice/crates/tinyvoice-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinyvoice" + }, + { + "name": "tinyruntime-bus", + "manifest": "vendor/tinyruntime/crates/tinyruntime-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinyruntime" + }, + { + "name": "tinydocs-bus", + "manifest": "vendor/tinydocs/crates/tinydocs-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinydocs" + }, + { + "name": "tinysearch-bus", + "manifest": "vendor/tinysearch/crates/tinysearch-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinysearch" + }, + { + "name": "tinyhosts-bus", + "manifest": "vendor/tinyhosts/crates/tinyhosts-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinyhosts" + } + ], + "pendingContracts": [ + { + "name": "tinybox-bus", + "manifest": "vendor/tinybox/crates/tinybox-bus/Cargo.toml", + "owner": "https://github.com/tinyhumansai/tinybox", + "reason": "Sandbox execution, shell analysis and pre-core gateways need a bus contract." + } + ], + "exceptions": [ + { + "scope": "hosts", + "package": "tinyjuice", + "reason": "HTML extraction, REPL execution and schema vocabulary still call the implementation." + }, + { + "scope": "hosts", + "package": "tinymcp", + "reason": "Protocol servers and supervisor operations still call the implementation." + }, + { + "scope": "hosts", + "package": "tinychannels", + "reason": "Providers, relay, pairing and delivery lifecycles still call the implementation." + }, + { + "scope": "hosts", + "package": "tinychannels-runtime", + "reason": "Providers, relay, pairing and delivery lifecycles still call the implementation." + }, + { + "scope": "hosts", + "package": "tinybox-core", + "reason": "Sandbox execution, shell analysis and pre-core gateways need a bus contract." + }, + { + "scope": "hosts", + "package": "tinybox-jail", + "reason": "Sandbox execution, shell analysis and pre-core gateways need a bus contract." + }, + { + "scope": "hosts", + "package": "tinybox-docker", + "reason": "Sandbox execution, shell analysis and pre-core gateways need a bus contract." + }, + { + "scope": "hosts", + "package": "tinybox-host", + "reason": "Sandbox execution, shell analysis and pre-core gateways need a bus contract." + }, + { + "scope": "hosts", + "package": "tinybox-ssh", + "reason": "Sandbox execution, shell analysis and pre-core gateways need a bus contract." + }, + { + "scope": "hosts", + "package": "tinycomputer-accessibility", + "reason": "Focus, paste, permission and Globe-listener operations need bus members." + }, + { + "scope": "hosts", + "package": "tinyvoice", + "reason": "Capture and hotkey lifecycles need module operations." + }, + { + "scope": "hosts", + "package": "tinyruntime-pyserver", + "reason": "Python-worker lifecycle needs module operations." + }, + { + "scope": "hosts", + "package": "tinywallet-crypto", + "reason": "Validation, transaction construction, web3 and x402 need module operations." + }, + { + "scope": "hosts", + "package": "tinywallet-web3", + "reason": "Validation, transaction construction, web3 and x402 need module operations." + }, + { + "scope": "hosts", + "package": "tinywallet-x402", + "reason": "Validation, transaction construction, web3 and x402 need module operations." + }, + { + "scope": "hosts", + "package": "pdf-extract", + "reason": "Memory documents-office still enables local PDF extraction." + }, + { + "scope": "hosts", + "package": "calamine", + "reason": "Memory documents-office still enables local XLSX parsing." + }, + { + "scope": "tinychannels-bus", + "package": "anyhow", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "async-trait", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "base64", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "hex", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "hmac", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "parking_lot", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "rand", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "sha2", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "tokio", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "tracing", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinychannels-bus", + "package": "uuid", + "reason": "Channel trait, relay signing and pairing implementations remain in the contract; move them to the module." + }, + { + "scope": "tinywallet-bus", + "package": "tinywallet-crypto", + "reason": "Compatibility behavioral re-exports still link implementation; move vocabulary into the contract." + }, + { + "scope": "tinywallet-bus", + "package": "tinywallet-x402", + "reason": "Compatibility behavioral re-exports still link implementation; move vocabulary into the contract." + }, + { + "scope": "hosts", + "package": "cpal", + "reason": "Voice capture and the accessibility microphone probe still link host audio-device access." + } + ] +} diff --git a/scripts/ci/module-pin-exemptions.json b/scripts/ci/module-pin-exemptions.json index 8af2cdaf35e..9720a45edb0 100644 --- a/scripts/ci/module-pin-exemptions.json +++ b/scripts/ci/module-pin-exemptions.json @@ -11,16 +11,5 @@ "Delete an entry the moment the pins are reconciled. An exemption that has", "stopped being true fails the gate too \u2014 `expect` must still match." ], - "exemptions": [ - { - "id": "tinywallet", - "expect": "v0.7.4-6-gaccb920a", - "reason": "The vendored tree includes six unreleased commits; the shipped v0.7.4 artifact remains the last published runtime module. Remove this exception when the next artifact is published and both pins advance." - }, - { - "id": "tinychannels", - "expect": "v0.1.12-3-g5e3b2044", - "reason": "The vendored tree includes three unreleased commits; the shipped v0.1.12 artifact remains the last published runtime module. Remove this exception when the next artifact is published and both pins advance." - } - ] + "exemptions": [] } diff --git a/scripts/ci/self-hosted/lanes-plan.mjs b/scripts/ci/self-hosted/lanes-plan.mjs index d0558e6ffb3..f841a3ffe84 100644 --- a/scripts/ci/self-hosted/lanes-plan.mjs +++ b/scripts/ci/self-hosted/lanes-plan.mjs @@ -232,6 +232,11 @@ export function buildPlan({ profile, areas, env = {}, isPullRequest = true }) { when: true, run: "node scripts/ci/check-crate-chain.mjs", }, + { + name: "module-boundaries", + when: rust || areas.scripts, + run: "bash scripts/ci-cancel-aware.sh node scripts/ci/check-module-boundaries.mjs", + }, { name: "module-pins", when: true, diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index 59c165421b3..9fd03aaa404 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -673,4 +673,19 @@ # the file driver backs `file:` URLs), which adds the # first-party tinystoragedrivers-file crate (one package, # one name; no external crate, native builds unchanged). -flows:339:317:2 +# 339/317/2 2026-10-10 Pre-core module registry vocabulary adds tinycomputer-bus +# to loader-disabled builds. Only its pure contract is +# added (one package/name); no native or implementation +# dependency is introduced. This foundation is additive, +# not a dependency-shedding claim. +# 340/318/2 2026-10-10 Combining both independent additions above: measured +# with dep-sim --cut-nothing on current upstream main. +# Two existing native builds; no shedding claimed. + +# 335/313/2 2026-10-11 Connector processing and archive ownership now execute +# through the verified v0.14.0 module. Removing the linked +# implementation removes eight package names from this +# profile; making flows enable the loader adds three. +# Measured net change: five fewer packages/names, with +# the same two native builds. No binary-size claim. +flows:335:313:2 diff --git a/vendor/tinyconnectors b/vendor/tinyconnectors index afccaa4c2c3..8b35ca5e96b 160000 --- a/vendor/tinyconnectors +++ b/vendor/tinyconnectors @@ -1 +1 @@ -Subproject commit afccaa4c2c31ee1e51359f7e8e98c9fe879ac417 +Subproject commit 8b35ca5e96b824934b68dc2a7b136776dc17fece diff --git a/vendor/tinyhosts b/vendor/tinyhosts index 94098349c8d..6ad6e8b2c03 160000 --- a/vendor/tinyhosts +++ b/vendor/tinyhosts @@ -1 +1 @@ -Subproject commit 94098349c8d75269438e8903f9042148b1ab4752 +Subproject commit 6ad6e8b2c038b5214cb4f2cd49927f8cc11e9dc7