From a0d5d3f91dab98542a85205447b42e9005de2530 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 4 Aug 2026 16:46:35 +0300 Subject: [PATCH 1/8] feat(kernel): gate the managed Node.js runtime behind `runtime-node` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First gate in this program to remove a NATIVE toolchain build. The floor drops 312/285/6 -> 310/283/5: `xz2` and its static liblzma C build are exclusive to `runtime::node::extractor`'s `.tar.xz` path, and `lzma-sys` leaves the native list entirely. Remaining natives: aws-lc-sys, libgit2-sys, libsqlite3-sys, libz-sys, ring — the 222-names/2-native target keeps the last two. `kernel-floor.limits` is lowered accordingly, which is the point of the ratchet. FACADE + STUB, not a leaf gate, and the reason is worth recording because I got it wrong twice while scoping. It is NOT `agent/harness_init`: its `node_runtime_step()` is an entry in a `vec!` of bootstrap steps, i.e. a registration site, which is leaf-gateable on its own. The forcing constraint is `ShellTool`, which holds `Option>` as a struct FIELD for managed-Node PATH injection — and `tools/impl/system/shell.rs` is kernel, always compiled. Delete the module and the shell tool goes with it. So `runtime/node/stub.rs` carries the type surface (`NodeBootstrap`, `ResolvedNode`, `NodeSource`, `ExecuteToolOutcome`, `ops::{classify_tool_call, execute_tool}`) while every registration site is leaf-gated: the `javascript` controllers in `core/all.rs`, `node_exec`/`npm_exec` in `tools/ops.rs`, and `node_runtime_step`. Registration sites want absence — a stub that registered a failing controller would make `javascript.*` a KNOWN method that errors, the opposite of the intended unknown-method. Stub signature drift is the pattern's main hazard and it bit immediately: both `ops` functions were guessed wrong on the first attempt (`classify_tool_call` returns `Result`, not `Option`; `execute_tool` returns `ExecuteToolOutcome`, not `Value`). Signatures are now taken from the real definitions, and the stub's doc comment says so. Off-state: `try_cached`/`probe_installed` return `None`, so the shell simply never prepends a managed bin dir — identical to today's `node.enabled = false` path — and `tools/ops.rs` does not construct a bootstrap at all, keeping that branch dead rather than a silent per-invocation no-op. The flows adapter's `classify_tool_call(...).unwrap_or(CommandClass::Network)` degrades to the most restrictive classification, never a wider one. `tar` and `zip` are NOT shed — `tar` is shared with `inference` (install_piper) and `runtime::python`, `zip` with `inference` and the document tools. Do not "fix" the feature list by adding them. Retrofits two existing tests that hard-asserted `node_exec` registration: `all_tools_default_registry_contains_expected_baseline_surface` now extends its baseline conditionally (same shape as the `channels` conditional beside it), and `all_tools_registers_node_exec_when_node_enabled` is `#[cfg]`-gated. Adds both-ways tests: `javascript_controllers_{registered_when_feature_on, absent_when_feature_off}` and `default_tools_omits_node_tools_when_runtime_node_off`. Verified: default and gates-off lib builds clean; `--all-targets` clean; Tauri shell clean; `cargo clippy -p openhuman -- -D warnings` (CI's command) clean in both configurations; fmt clean. core:: 685 ON / 565 OFF; tools 867 ON / 767 OFF (serial). Combination check `--features "tokenjuice-treesitter,skills, flows"` with runtime-node OFF compiles — both families call into `runtime::node` behind their own gates. Shed proven with `scripts/assert-shed.sh tokenjuice-treesitter xz2 lzma-sys`. Co-authored-by: Medulla --- AGENTS.md | 1 + Cargo.toml | 25 +++- app/src-tauri/Cargo.toml | 1 + scripts/kernel-floor.limits | 9 +- src/core/all.rs | 5 +- src/core/all_tests.rs | 30 +++++ src/openhuman/agent/harness_init/registry.rs | 7 ++ src/openhuman/runtime/javascript/mod.rs | 20 ++- src/openhuman/runtime/node/mod.rs | 30 +++++ src/openhuman/runtime/node/stub.rs | 125 +++++++++++++++++++ src/openhuman/tools/ops.rs | 8 +- src/openhuman/tools/ops_tests.rs | 26 +++- 12 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 src/openhuman/runtime/node/stub.rs diff --git a/AGENTS.md b/AGENTS.md index 43100fd277..06d7b4fcce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -335,6 +335,7 @@ whole cohort or expect a delta of 0. | `tui` | ON | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | | `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `channels::webview_accounts` / `webview_apis` / `webview_notifications` / `channels::whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | | `contacts` | ON | `memory::people::address_book`'s macOS CNContactStore reader — the address-book seeding path for the people domain. Leaf gate over a **pre-existing** off-state: the module already shipped a non-macOS `imp` stub returning an empty contact list, so the gate only widens that stub's cfg. `read`/`read_with`/`AddressBookError`/`SystemContactsSource` and the whole `people` RPC surface stay compiled in every build; off ⇒ a refresh seeds nothing instead of failing. | **6** on macOS (`objc2`, `objc2-foundation`, `objc2-contacts`, `block2` + 2 transitive). **No-op on Linux/Windows** — never in those graphs, so the kernel-floor ratchet does not move. Verify cross-target: `cargo tree --target aarch64-apple-darwin -e normal -i objc2-contacts --no-default-features --features tokenjuice-treesitter` (294 → 288 packages). | +| `runtime-node` | ON | `runtime::node` (download / verify / extract / install a pinned Node.js toolchain), the `runtime::javascript` language slot, `runtime::pool::node`, the `node_exec` / `npm_exec` agent tools, and the `node_runtime` harness-init step. **Facade + stub** — `ShellTool` holds `Option>` and `shell.rs` is kernel, so the module cannot simply vanish; `runtime/node/stub.rs` carries the type surface while registration sites are leaf-gated. Off ⇒ `try_cached`/`probe_installed` return `None` and the shell never prepends a managed bin dir, identical to today's `node.enabled = false` path. | **`xz2` + its static liblzma C build.** First gate to remove a NATIVE toolchain build: `lzma-sys` leaves the list, 6 → 5. `tar`/`zip` are NOT shed — shared with `inference` (install_piper), `runtime::python`, and the document tools. | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. diff --git a/Cargo.toml b/Cargo.toml index 110f3a695b..55587bbac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ hmac = "0.12" # distributions ship as .tar.xz, Windows as .zip. `xz2` with `static` # bundles liblzma so we don't need it as a system dependency. tar = "0.4" -xz2 = { version = "0.1", features = ["static"] } +xz2 = { version = "0.1", features = ["static"], optional = true } zip = { version = "2", default-features = false, features = ["deflate"] } # gzip decoder for the Piper tar.gz binary releases on macOS / Linux. Already # pulled in transitively by zip's `deflate` feature; declared directly so @@ -400,7 +400,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla", "scheduler-gate", "file-logging", "prediction-markets", "contacts"] +default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla", "scheduler-gate", "file-logging", "prediction-markets", "contacts", "runtime-node"] # HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and # its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1` # OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file @@ -510,6 +510,27 @@ web3 = [ "dep:coins-bip39", ] +# Managed Node.js runtime: `runtime::node` (download / verify / extract / install +# a pinned toolchain), the `runtime::javascript` language slot over it, +# `runtime::pool::node`, the `node_exec` / `npm_exec` agent tools, and the +# `node_runtime` harness-init step. Default-ON. Slim builds opt out via +# `--no-default-features --features ""`, which drops +# the exclusive `xz2` AND its static liblzma C build — the FIRST native +# toolchain build this gating program removes (6 native -> 5). +# +# FACADE + STUB, not a leaf gate. `ShellTool` holds `Option>` +# for managed-Node PATH injection and `tools/impl/system/shell.rs` is kernel, so +# deleting the module would take the shell tool with it. `runtime/node/stub.rs` +# carries the type surface; registration sites (`node_runtime_step`, the +# `javascript` controllers, `node_exec`/`npm_exec`) are leaf-gated at their call +# sites, because registration sites want absence. +# +# Off-state: `try_cached`/`probe_installed` return `None`, so the shell never +# prepends a managed bin dir — identical to today's `node.enabled = false` path. +# `tar` and `zip` are NOT shed: `tar` is shared with `inference` (install_piper) +# and `runtime::python`, `zip` with `inference` and the document tools. +runtime-node = ["dep:xz2"] + # macOS Contacts seeding for the people domain: `memory::people::address_book` # reads CNContactStore to seed handles. Default-ON. Slim / headless builds opt # out via `--no-default-features --features ""`, which diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 10a097c579..319ad11d52 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -198,6 +198,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal # transitive resolution, so this needs its own line. "prediction-markets", "contacts", + "runtime-node", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index 20c74eb915..4a2eacfd3e 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -13,6 +13,13 @@ # Simulate with: scripts/dep-sim.py --cut # # History +# 310/283/5 2026-08-04 runtime-node gate (-2, and -1 NATIVE). xz2 + its +# static liblzma C build are exclusive to +# runtime::node::extractor's .tar.xz path. FIRST native +# toolchain build removed by the gating program: lzma-sys +# leaves the list, 6 -> 5. Remaining: aws-lc-sys, +# libgit2-sys, libsqlite3-sys, libz-sys, ring — the target +# keeps the last two. # 312/285/6 2026-08-02 merge origin/main (+5 names) — INHERITED, not introduced here. # A rise in this number is the one thing this file exists # to make someone justify, so: main grew the graph on its @@ -62,4 +69,4 @@ # Native: aws-lc-sys libgit2-sys libsqlite3-sys libz-sys # lzma-sys ring. Target after gating is 222 names / 2 native # (libsqlite3-sys, ring) — see docs/plans MIGRATION-PLAN G6. -flows:312:285:6 +flows:310:283:5 diff --git a/src/core/all.rs b/src/core/all.rs index b95fb6860b..5330a0003d 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -583,7 +583,10 @@ fn build_registered_controllers() -> Vec { DomainGroup::Platform, crate::openhuman::platform::socket::all_socket_registered_controllers(), ); - // Managed Node.js runtime bridge (tool listing + dispatch) + // Managed Node.js runtime bridge (tool listing + dispatch). Registration-site + // gate: with `runtime-node` off the `javascript.*` namespace is absent from + // `/schema` and unknown-method over `/rpc`, rather than registered+failing. + #[cfg(feature = "runtime-node")] push( &mut controllers, DomainGroup::Runtimes, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 8fc9d13fa6..5308d9288e 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -1570,3 +1570,33 @@ fn every_domain_group_is_accounted_for_in_subscriber_plan() { let none = DomainSubscriberPlan::for_domains(crate::core::runtime::DomainSet::none()); assert_ne!(full, none, "full() and none() must differ"); } + +// ---- runtime-node gate ----------------------------------------------------- + +#[test] +#[cfg(feature = "runtime-node")] +fn javascript_controllers_registered_when_feature_on() { + let ns: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.namespace) + .collect(); + assert!( + ns.contains(&"javascript"), + "runtime-node ON must register the `javascript` namespace" + ); +} + +/// The half that proves the gate removes anything: absent, not +/// registered-and-failing. +#[test] +#[cfg(not(feature = "runtime-node"))] +fn javascript_controllers_absent_when_feature_off() { + let ns: Vec<&str> = all_controller_schemas() + .iter() + .map(|s| s.namespace) + .collect(); + assert!( + !ns.contains(&"javascript"), + "runtime-node OFF must not register the `javascript` namespace" + ); +} diff --git a/src/openhuman/agent/harness_init/registry.rs b/src/openhuman/agent/harness_init/registry.rs index 9b3da29c89..65154c5dfd 100644 --- a/src/openhuman/agent/harness_init/registry.rs +++ b/src/openhuman/agent/harness_init/registry.rs @@ -62,6 +62,9 @@ pub fn all_steps() -> Vec { spacy_step(), kompress_step(), runtime_python_server_step(), + // Registration-site gate: no managed toolchain to provision when + // `runtime-node` is compiled out, so the step is absent. + #[cfg(feature = "runtime-node")] node_runtime_step(), ] } @@ -232,6 +235,7 @@ async fn kompress_run(config: &Config) -> Result<(), String> { .map_err(|e| format!("{e:#}")) } +#[cfg(feature = "runtime-node")] fn node_runtime_step() -> HarnessInitStep { HarnessInitStep { id: "node_runtime", @@ -243,6 +247,7 @@ fn node_runtime_step() -> HarnessInitStep { } } +#[cfg(feature = "runtime-node")] fn build_node_bootstrap(config: &Config) -> crate::openhuman::runtime::node::NodeBootstrap { crate::openhuman::runtime::node::NodeBootstrap::new( config.node.clone(), @@ -251,6 +256,7 @@ fn build_node_bootstrap(config: &Config) -> crate::openhuman::runtime::node::Nod ) } +#[cfg(feature = "runtime-node")] async fn node_is_done(config: &Config) -> bool { if !config.node.enabled { return true; @@ -264,6 +270,7 @@ async fn node_is_done(config: &Config) -> bool { .is_some() } +#[cfg(feature = "runtime-node")] async fn node_run(config: &Config) -> Result<(), String> { if !config.node.enabled { return Ok(()); diff --git a/src/openhuman/runtime/javascript/mod.rs b/src/openhuman/runtime/javascript/mod.rs index 9a8730b419..f3b2be0867 100644 --- a/src/openhuman/runtime/javascript/mod.rs +++ b/src/openhuman/runtime/javascript/mod.rs @@ -6,13 +6,27 @@ //! specific backend. That keeps the door open for future sibling modules like //! `python`, `ruby`, or a different JavaScript backend. -pub use crate::openhuman::runtime::node::types::{ExecuteToolOutcome, RuntimeToolSummary}; +//! ## Gating (`runtime-node`) +//! +//! The facade itself is always compiled — `ShellTool` imports `NodeBootstrap` +//! through it — but the re-exports split. The bootstrap type surface comes from +//! `node`'s stub when the feature is off; the download/extract/dispatch +//! machinery and the controller pair are gated, because their only consumers +//! are themselves gated off. + +pub use crate::openhuman::runtime::node::{ + ExecuteToolOutcome, NodeBootstrap, NodeSource, ResolvedNode, +}; + +#[cfg(feature = "runtime-node")] +pub use crate::openhuman::runtime::node::types::RuntimeToolSummary; +#[cfg(feature = "runtime-node")] pub use crate::openhuman::runtime::node::{ all_runtime_node_controller_schemas as all_javascript_controller_schemas, all_runtime_node_registered_controllers as all_javascript_registered_controllers, }; +#[cfg(feature = "runtime-node")] pub use crate::openhuman::runtime::node::{ atomic_install, detect_system_node, download_distribution, execute_tool, extract_distribution, - fetch_shasums, list_tools, parse_node_version, NodeBootstrap, NodeDistribution, NodeSource, - ResolvedNode, SystemNode, + fetch_shasums, list_tools, parse_node_version, NodeDistribution, SystemNode, }; diff --git a/src/openhuman/runtime/node/mod.rs b/src/openhuman/runtime/node/mod.rs index db9b2bc175..27622cd01a 100644 --- a/src/openhuman/runtime/node/mod.rs +++ b/src/openhuman/runtime/node/mod.rs @@ -10,21 +10,51 @@ //! * [`ops`] / [`schemas`] — expose a minimal top-level runtime surface for //! listing agent-callable tools and dispatching a tool by name. +//! ## Gating (`runtime-node`) +//! +//! Facade: this module is always declared, but every submodule below is +//! `#[cfg(feature = "runtime-node")]` and a `stub` takes over when the feature +//! is off. The forcing constraint is `ShellTool`, which holds +//! `Option>` as a field and is kernel — always compiled. + +#[cfg(feature = "runtime-node")] pub mod bootstrap; +#[cfg(feature = "runtime-node")] pub mod downloader; +#[cfg(feature = "runtime-node")] pub mod extractor; +#[cfg(feature = "runtime-node")] pub mod ops; +#[cfg(feature = "runtime-node")] pub mod resolver; +#[cfg(feature = "runtime-node")] pub mod rpc; +#[cfg(feature = "runtime-node")] mod schemas; +#[cfg(feature = "runtime-node")] pub mod types; +#[cfg(not(feature = "runtime-node"))] +mod stub; +#[cfg(not(feature = "runtime-node"))] +pub use stub::{ + ops, ExecuteToolOutcome, NodeBootstrap, NodeSource, ResolvedNode, RUNTIME_NODE_DISABLED_MESSAGE, +}; + +#[cfg(feature = "runtime-node")] pub use bootstrap::{NodeBootstrap, NodeSource, ResolvedNode}; +#[cfg(feature = "runtime-node")] pub use downloader::{download_distribution, fetch_shasums, NodeDistribution}; +#[cfg(feature = "runtime-node")] pub use extractor::{atomic_install, extract_distribution}; +#[cfg(feature = "runtime-node")] pub use ops::{execute_tool, list_tools}; +#[cfg(feature = "runtime-node")] pub use resolver::{detect_system_node, parse_node_version, SystemNode}; +#[cfg(feature = "runtime-node")] pub use schemas::{ all_controller_schemas as all_runtime_node_controller_schemas, all_registered_controllers as all_runtime_node_registered_controllers, }; +#[cfg(feature = "runtime-node")] +pub use types::ExecuteToolOutcome; diff --git a/src/openhuman/runtime/node/stub.rs b/src/openhuman/runtime/node/stub.rs new file mode 100644 index 0000000000..9b23960ebc --- /dev/null +++ b/src/openhuman/runtime/node/stub.rs @@ -0,0 +1,125 @@ +//! `runtime-node` disabled-build stub. +//! +//! Mirrors the *type* surface that always-compiled callers name, with no-op +//! behaviour. Only what is actually reached from outside the gate lives here — +//! the download/extract/resolve machinery is compiled out entirely. +//! +//! Why a stub rather than a leaf gate: [`NodeBootstrap`] appears in the **field +//! type** of `tools::impl::system::ShellTool` (`Option>`, for +//! managed-Node `PATH` injection), and `shell.rs` is kernel — always compiled. +//! Deleting the module would take the shell tool with it. The registration +//! sites (`node_runtime_step`, the `javascript` controllers, `node_exec` / +//! `npm_exec`) are leaf-gated at their call sites instead, because registration +//! sites want absence. +//! +//! Off-state: `try_cached` / `probe_installed` return `None`, so the shell +//! simply never prepends a managed `bin/` dir — the same path taken today when +//! `node.enabled = false`. `resolve()` is the one erroring method and is only +//! reachable from `harness_init`'s bootstrap step, itself gated off; it returns +//! a build fact so a stray caller reports something actionable. + +use std::path::PathBuf; + +use anyhow::Result; + +use crate::openhuman::config::schema::NodeConfig; + +/// Returned by [`NodeBootstrap::resolve`] in a `runtime-node`-less build. +/// Phrased as a build fact, matching the `mcp` / `tui` CLI-arm convention. +pub const RUNTIME_NODE_DISABLED_MESSAGE: &str = + "runtime-node feature disabled at compile time — rebuild with `--features runtime-node` \ + to use the managed Node.js toolchain"; + +/// Origin of a resolved toolchain. Never produced here; kept so caller `match` +/// arms and imports still resolve. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeSource { + System, + Managed, +} + +/// Fully-resolved Node toolchain. Never constructed in a disabled build. +#[derive(Debug, Clone)] +pub struct ResolvedNode { + pub bin_dir: PathBuf, + pub node_bin: PathBuf, + pub npm_bin: PathBuf, + pub version: String, + pub source: NodeSource, +} + +/// Disabled-build bootstrap: constructs, resolves to nothing. +#[derive(Debug)] +pub struct NodeBootstrap { + _config: NodeConfig, + _workspace_dir: PathBuf, +} + +impl NodeBootstrap { + /// Signature-compatible with the real constructor. The `reqwest::Client` is + /// accepted and dropped — a disabled build never downloads. + pub fn new(config: NodeConfig, workspace_dir: PathBuf, _client: reqwest::Client) -> Self { + Self { + _config: config, + _workspace_dir: workspace_dir, + } + } + + /// Always `None` — nothing is cached because nothing resolves. + pub fn try_cached(&self) -> Option { + None + } + + /// Always `None`. The real implementation probes the on-disk install; there + /// is no install path in a disabled build. + pub async fn probe_installed(&self) -> Option { + None + } + + /// Always `Err`. See [`RUNTIME_NODE_DISABLED_MESSAGE`]. + pub async fn resolve(&self) -> Result { + anyhow::bail!(RUNTIME_NODE_DISABLED_MESSAGE) + } +} + +/// Outcome of a runtime tool call. Never constructed here; kept so the +/// `javascript` facade's re-export and any caller binding still resolve. +#[derive(Debug, Clone)] +pub struct ExecuteToolOutcome { + pub tool_name: String, + pub elapsed_ms: u64, + pub result: crate::openhuman::skills::types::ToolResult, +} + +/// Mirrors `node::ops` for the flows capability adapters. +/// +/// Signatures must match `node::ops` exactly — the gated-off build is the only +/// thing that catches drift, and both of these were guessed wrong on a first +/// attempt (`classify_tool_call` returns `Result`, not +/// `Option`; `execute_tool` returns `ExecuteToolOutcome`, not `Value`). +pub mod ops { + use super::RUNTIME_NODE_DISABLED_MESSAGE; + use crate::openhuman::config::Config; + use crate::openhuman::security::CommandClass; + + /// Always `Err`. The flows adapter does `.unwrap_or(CommandClass::Network)`, + /// so this degrades to the most restrictive classification rather than + /// silently widening what a workflow may call. + pub fn classify_tool_call( + _config: &Config, + _tool_name: &str, + _args: &serde_json::Value, + ) -> Result { + Err(RUNTIME_NODE_DISABLED_MESSAGE.to_string()) + } + + /// Always `Err` — the runtime that would execute the call is compiled out. + pub async fn execute_tool( + _config: &Config, + _tool_name: &str, + _args: serde_json::Value, + _prefer_markdown: bool, + ) -> Result { + Err(RUNTIME_NODE_DISABLED_MESSAGE.to_string()) + } +} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 4545445255..11ca974989 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -117,7 +117,12 @@ pub fn all_tools_with_runtime( // NodeExecTool, and NpmExecTool all share the same memoised resolution // state. Disabled when `node.enabled = false` — in that case shell skips // PATH injection and node/npm tools are not registered. - let node_bootstrap: Option> = if root_config.node.enabled { + // `runtime-node` off => never construct a bootstrap: the stub resolves to + // nothing anyway, and this keeps the shell's PATH-injection branch dead + // rather than a silent per-invocation no-op. + let node_bootstrap: Option> = if cfg!(feature = "runtime-node") + && root_config.node.enabled + { tracing::debug!( version = %root_config.node.version, prefer_system = root_config.node.prefer_system, @@ -1030,6 +1035,7 @@ pub fn all_tools_with_runtime( // Managed Node.js exec tools — gated on `root_config.node.enabled`. // Both share the same `NodeBootstrap` as ShellTool so the download + // extract + install pipeline runs at most once per session. + #[cfg(feature = "runtime-node")] if let Some(bootstrap) = node_bootstrap.as_ref() { tools.push(Box::new(NodeExecTool::new( security.clone(), diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index d7a5194778..731149975f 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -720,10 +720,13 @@ fn all_tools_default_registry_contains_expected_baseline_surface() { "gitbooks_search", "gitbooks_get_page", "web_search_tool", - "node_exec", - "npm_exec", "image_info", ]; + // Managed Node tools exist only when the runtime is compiled in — same + // shape as the `channels` conditional just below. + if cfg!(feature = "runtime-node") { + expected.extend(&["node_exec", "npm_exec"]); + } // WhatsApp tools are only registered when channels feature is on if cfg!(feature = "channels") { expected.extend(&[ @@ -1026,6 +1029,7 @@ fn all_tools_excludes_delegate_when_no_agents() { } #[test] +#[cfg(feature = "runtime-node")] fn all_tools_registers_node_exec_when_node_enabled() { // Default NodeConfig has `enabled = true`, so both `node_exec` and // `npm_exec` must appear in the registry. Regression guard for the @@ -2777,3 +2781,21 @@ const TOOL_LESS: &[crate::core::all::DomainGroup] = { use crate::core::all::DomainGroup as G; &[G::Config, G::Security, G::Meet, G::Medulla] }; + +/// `node_exec` / `npm_exec` are absent when the managed Node runtime is +/// compiled out — absent, not present-and-erroring, so the model is never shown +/// a tool it cannot use. +#[test] +#[cfg(not(feature = "runtime-node"))] +fn default_tools_omits_node_tools_when_runtime_node_off() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + let tools = integration_tools_for_config(&tmp, &cfg); + let names = tool_names(&tools); + for absent in ["node_exec", "npm_exec"] { + assert!( + !names.iter().any(|n| n == absent), + "`{absent}` must not be registered with runtime-node compiled out" + ); + } +} From ee03d542561de27d653afcd8d1e1a6e6bf1d370f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 16:13:23 +0300 Subject: [PATCH 2/8] chore: files changed src/openhuman/runtime/node/mod.rs Checkpoint of work in progress, touching src/openhuman/runtime/node/mod.rs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/runtime/node/mod.rs | 36 +++++++++++++++++++------------ 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/openhuman/runtime/node/mod.rs b/src/openhuman/runtime/node/mod.rs index 27622cd01a..e39890ee00 100644 --- a/src/openhuman/runtime/node/mod.rs +++ b/src/openhuman/runtime/node/mod.rs @@ -7,15 +7,26 @@ //! matching toolchain already exists on the host. //! * [`bootstrap`] / [`downloader`] / [`extractor`] — resolve or install the //! managed Node.js toolchain shipped with the core. -//! * [`ops`] / [`schemas`] — expose a minimal top-level runtime surface for -//! listing agent-callable tools and dispatching a tool by name. +//! * [`ops`] / [`types`] — the generic runtime tool bridge: build / list / +//! classify / execute against the native agent tool registry. +//! * [`schemas`] / [`rpc`] — the gated `javascript.*` controller pair. //! ## Gating (`runtime-node`) //! -//! Facade: this module is always declared, but every submodule below is -//! `#[cfg(feature = "runtime-node")]` and a `stub` takes over when the feature -//! is off. The forcing constraint is `ShellTool`, which holds -//! `Option>` as a field and is kernel — always compiled. +//! Facade: this module is always declared, but only the *managed-Node* +//! machinery (`bootstrap` / `downloader` / `extractor` / `resolver` / `rpc` / +//! `schemas`) is `#[cfg(feature = "runtime-node")]`; a `stub` carries +//! `NodeBootstrap`'s type surface when the feature is off. The forcing +//! constraint is `ShellTool`, which holds `Option>` as a +//! field and is kernel — always compiled. +//! +//! [`ops`] and [`types`] are deliberately **not** gated. They are the generic +//! native-tool dispatcher over the agent tool registry (`oh:*` tools such as +//! `memory_search`, file, and shell tools) — they back both the gated +//! `javascript.*` controllers *and* the ungated `flows` `oh:` `NativeToolBackend`, +//! which must keep dispatching native tools even when the managed Node runtime +//! itself is compiled out. Only the JavaScript RPC and the Node-specific +//! `node_exec` / `npm_exec` tools are gated. #[cfg(feature = "runtime-node")] pub mod bootstrap; @@ -24,22 +35,21 @@ pub mod downloader; #[cfg(feature = "runtime-node")] pub mod extractor; #[cfg(feature = "runtime-node")] -pub mod ops; -#[cfg(feature = "runtime-node")] pub mod resolver; #[cfg(feature = "runtime-node")] pub mod rpc; #[cfg(feature = "runtime-node")] mod schemas; -#[cfg(feature = "runtime-node")] + +/// Generic runtime tool bridge. Always compiled — see module docs. +pub mod ops; +/// Inert serde types shared by [`ops`] and the gated JS RPC. Always compiled. pub mod types; #[cfg(not(feature = "runtime-node"))] mod stub; #[cfg(not(feature = "runtime-node"))] -pub use stub::{ - ops, ExecuteToolOutcome, NodeBootstrap, NodeSource, ResolvedNode, RUNTIME_NODE_DISABLED_MESSAGE, -}; +pub use stub::{NodeBootstrap, NodeSource, ResolvedNode, RUNTIME_NODE_DISABLED_MESSAGE}; #[cfg(feature = "runtime-node")] pub use bootstrap::{NodeBootstrap, NodeSource, ResolvedNode}; @@ -47,7 +57,6 @@ pub use bootstrap::{NodeBootstrap, NodeSource, ResolvedNode}; pub use downloader::{download_distribution, fetch_shasums, NodeDistribution}; #[cfg(feature = "runtime-node")] pub use extractor::{atomic_install, extract_distribution}; -#[cfg(feature = "runtime-node")] pub use ops::{execute_tool, list_tools}; #[cfg(feature = "runtime-node")] pub use resolver::{detect_system_node, parse_node_version, SystemNode}; @@ -56,5 +65,4 @@ pub use schemas::{ all_controller_schemas as all_runtime_node_controller_schemas, all_registered_controllers as all_runtime_node_registered_controllers, }; -#[cfg(feature = "runtime-node")] pub use types::ExecuteToolOutcome; From 5809b18ab79f46c67d469ad13cd06dbd7a4652bc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 16:14:18 +0300 Subject: [PATCH 3/8] chore: files changed src/openhuman/runtime/node/stub.rs Checkpoint of work in progress, touching src/openhuman/runtime/node/stub.rs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/runtime/node/stub.rs | 48 ++++-------------------------- 1 file changed, 6 insertions(+), 42 deletions(-) diff --git a/src/openhuman/runtime/node/stub.rs b/src/openhuman/runtime/node/stub.rs index 9b23960ebc..e7832d862b 100644 --- a/src/openhuman/runtime/node/stub.rs +++ b/src/openhuman/runtime/node/stub.rs @@ -17,6 +17,12 @@ //! `node.enabled = false`. `resolve()` is the one erroring method and is only //! reachable from `harness_init`'s bootstrap step, itself gated off; it returns //! a build fact so a stray caller reports something actionable. +//! +//! Note: this stub carries **only** the managed-Node toolchain type surface. +//! [`super::ops`] and [`super::types`] are not stubbed — they are the generic +//! native-tool dispatcher and its inert serde types, always compiled so the +//! ungated `flows` `NativeToolBackend` can keep dispatching `oh:*` tools when +//! the managed Node runtime is off. use std::path::PathBuf; @@ -81,45 +87,3 @@ impl NodeBootstrap { anyhow::bail!(RUNTIME_NODE_DISABLED_MESSAGE) } } - -/// Outcome of a runtime tool call. Never constructed here; kept so the -/// `javascript` facade's re-export and any caller binding still resolve. -#[derive(Debug, Clone)] -pub struct ExecuteToolOutcome { - pub tool_name: String, - pub elapsed_ms: u64, - pub result: crate::openhuman::skills::types::ToolResult, -} - -/// Mirrors `node::ops` for the flows capability adapters. -/// -/// Signatures must match `node::ops` exactly — the gated-off build is the only -/// thing that catches drift, and both of these were guessed wrong on a first -/// attempt (`classify_tool_call` returns `Result`, not -/// `Option`; `execute_tool` returns `ExecuteToolOutcome`, not `Value`). -pub mod ops { - use super::RUNTIME_NODE_DISABLED_MESSAGE; - use crate::openhuman::config::Config; - use crate::openhuman::security::CommandClass; - - /// Always `Err`. The flows adapter does `.unwrap_or(CommandClass::Network)`, - /// so this degrades to the most restrictive classification rather than - /// silently widening what a workflow may call. - pub fn classify_tool_call( - _config: &Config, - _tool_name: &str, - _args: &serde_json::Value, - ) -> Result { - Err(RUNTIME_NODE_DISABLED_MESSAGE.to_string()) - } - - /// Always `Err` — the runtime that would execute the call is compiled out. - pub async fn execute_tool( - _config: &Config, - _tool_name: &str, - _args: serde_json::Value, - _prefer_markdown: bool, - ) -> Result { - Err(RUNTIME_NODE_DISABLED_MESSAGE.to_string()) - } -} From e3abb5141f6fe7f283f2c5268d9e0692546c82c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 16:14:56 +0300 Subject: [PATCH 4/8] chore: files changed src/openhuman/agent/harness_init/registry.rs Checkpoint of work in progress, touching src/openhuman/agent/harness_init/registry.rs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/agent/harness_init/registry.rs | 21 ++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/openhuman/agent/harness_init/registry.rs b/src/openhuman/agent/harness_init/registry.rs index 65154c5dfd..c1f94a8563 100644 --- a/src/openhuman/agent/harness_init/registry.rs +++ b/src/openhuman/agent/harness_init/registry.rs @@ -296,16 +296,17 @@ mod tests { fn all_steps_have_stable_ids_and_are_non_required() { let steps = all_steps(); let ids: Vec<_> = steps.iter().map(|s| s.id).collect(); - assert_eq!( - ids, - vec![ - "python_runtime", - "spacy", - "kompress", - "runtime_python_server", - "node_runtime" - ] - ); + let mut expected = vec![ + "python_runtime", + "spacy", + "kompress", + "runtime_python_server", + ]; + // `node_runtime` is a registration-site gate: it is absent (not + // dead-but-listed) when the managed Node runtime is compiled out. + #[cfg(feature = "runtime-node")] + expected.push("node_runtime"); + assert_eq!(ids, expected); assert!(steps.iter().all(|s| !s.required)); assert!(steps.iter().all(|s| !s.label.is_empty())); } From 977ccba5b61d05831b671c17a9bdf812f3646b11 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 16:16:26 +0300 Subject: [PATCH 5/8] chore: files changed .github/workflows/ci-lite.yml Checkpoint of work in progress, touching .github/workflows/ci-lite.yml. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/workflows/ci-lite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index cf00a1373e..c4f28bd275 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -474,7 +474,7 @@ jobs: # # This asserts the calibration still holds. If it fails, every # projection built on the simulator is suspect until it is fixed. - run: python3 scripts/dep-sim.py --cut-nothing --expect-names 285 + run: python3 scripts/dep-sim.py --cut-nothing --expect-names 283 - name: Guard — new feature-gated test modules must be acknowledged # Self-maintaining coverage: the set of source files that #[cfg]-gate a test on From 5639213361cca5ea81d11d3e4d385c1b02d8b4df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 16:19:09 +0300 Subject: [PATCH 6/8] chore: files changed AGENTS.md Checkpoint of work in progress, touching AGENTS.md. Auto-committed-on: dragonfly Co-authored-by: Medulla --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 06d7b4fcce..f09a6b405a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -335,7 +335,7 @@ whole cohort or expect a delta of 0. | `tui` | ON | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | | `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `channels::webview_accounts` / `webview_apis` / `webview_notifications` / `channels::whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | | `contacts` | ON | `memory::people::address_book`'s macOS CNContactStore reader — the address-book seeding path for the people domain. Leaf gate over a **pre-existing** off-state: the module already shipped a non-macOS `imp` stub returning an empty contact list, so the gate only widens that stub's cfg. `read`/`read_with`/`AddressBookError`/`SystemContactsSource` and the whole `people` RPC surface stay compiled in every build; off ⇒ a refresh seeds nothing instead of failing. | **6** on macOS (`objc2`, `objc2-foundation`, `objc2-contacts`, `block2` + 2 transitive). **No-op on Linux/Windows** — never in those graphs, so the kernel-floor ratchet does not move. Verify cross-target: `cargo tree --target aarch64-apple-darwin -e normal -i objc2-contacts --no-default-features --features tokenjuice-treesitter` (294 → 288 packages). | -| `runtime-node` | ON | `runtime::node` (download / verify / extract / install a pinned Node.js toolchain), the `runtime::javascript` language slot, `runtime::pool::node`, the `node_exec` / `npm_exec` agent tools, and the `node_runtime` harness-init step. **Facade + stub** — `ShellTool` holds `Option>` and `shell.rs` is kernel, so the module cannot simply vanish; `runtime/node/stub.rs` carries the type surface while registration sites are leaf-gated. Off ⇒ `try_cached`/`probe_installed` return `None` and the shell never prepends a managed bin dir, identical to today's `node.enabled = false` path. | **`xz2` + its static liblzma C build.** First gate to remove a NATIVE toolchain build: `lzma-sys` leaves the list, 6 → 5. `tar`/`zip` are NOT shed — shared with `inference` (install_piper), `runtime::python`, and the document tools. | +| `runtime-node` | ON | `runtime::node` (download / verify / extract / install a pinned Node.js toolchain), the `runtime::javascript` language slot, `runtime::pool::node`, the `node_exec` / `npm_exec` agent tools, and the `node_runtime` harness-init step. **Facade + stub** — `ShellTool` holds `Option>` and `shell.rs` is kernel, so the module cannot simply vanish; `runtime/node/stub.rs` carries the `NodeBootstrap` type surface while registration sites are leaf-gated. **The generic native-tool dispatcher (`runtime::node::ops` / `runtime::node::types`) is NOT gated** — it backs both the gated `javascript.*` controllers and the ungated `flows` `oh:` `NativeToolBackend`, so native flow tools (`memory_search`, file, shell, …) keep working when the managed Node runtime is off. Off ⇒ `try_cached`/`probe_installed` return `None` and the shell never prepends a managed bin dir, identical to today's `node.enabled = false` path. | **`xz2` + its static liblzma C build.** First gate to remove a NATIVE toolchain build: `lzma-sys` leaves the list, 6 → 5. `tar`/`zip` are NOT shed — shared with `inference` (install_piper), `runtime::python`, and the document tools. | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. From 3853fdda0eae5a65d7e1e1f0f9de13cf6ee45627 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 16:19:50 +0300 Subject: [PATCH 7/8] chore: files changed Cargo.toml Checkpoint of work in progress, touching Cargo.toml. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f3eff975ae..486f47d947 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -521,10 +521,15 @@ web3 = [ # FACADE + STUB, not a leaf gate. `ShellTool` holds `Option>` # for managed-Node PATH injection and `tools/impl/system/shell.rs` is kernel, so # deleting the module would take the shell tool with it. `runtime/node/stub.rs` -# carries the type surface; registration sites (`node_runtime_step`, the -# `javascript` controllers, `node_exec`/`npm_exec`) are leaf-gated at their call +# carries the `NodeBootstrap` type surface; registration sites (`node_runtime_step`, +# the `javascript` controllers, `node_exec`/`npm_exec`) are leaf-gated at their call # sites, because registration sites want absence. # +# The generic native-tool dispatcher (`runtime::node::ops` / `runtime::node::types`) +# is deliberately NOT gated: it backs both the gated `javascript.*` controllers and +# the ungated `flows` `oh:` `NativeToolBackend`, so native flow tools keep working +# when the managed Node runtime is compiled out. +# # Off-state: `try_cached`/`probe_installed` return `None`, so the shell never # prepends a managed bin dir — identical to today's `node.enabled = false` path. # `tar` and `zip` are NOT shed: `tar` is shared with `inference` (install_piper) From 11fd0e2b14c7c8d80f7ffec51cead4657c14c1b8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 16:29:25 +0300 Subject: [PATCH 8/8] chore: files changed src/openhuman/agent/harness_init/registry.rs Checkpoint of work in progress, touching src/openhuman/agent/harness_init/registry.rs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/agent/harness_init/registry.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/openhuman/agent/harness_init/registry.rs b/src/openhuman/agent/harness_init/registry.rs index c1f94a8563..efd9685441 100644 --- a/src/openhuman/agent/harness_init/registry.rs +++ b/src/openhuman/agent/harness_init/registry.rs @@ -303,9 +303,12 @@ mod tests { "runtime_python_server", ]; // `node_runtime` is a registration-site gate: it is absent (not - // dead-but-listed) when the managed Node runtime is compiled out. - #[cfg(feature = "runtime-node")] - expected.push("node_runtime"); + // dead-but-listed) when the managed Node runtime is compiled out. `cfg!` + // (not `#[cfg]`) keeps `expected` mutable-and-used in both builds — same + // idiom as `tools/ops_tests.rs`. + if cfg!(feature = "runtime-node") { + expected.push("node_runtime"); + } assert_eq!(ids, expected); assert!(steps.iter().all(|s| !s.required)); assert!(steps.iter().all(|s| !s.label.is_empty()));