diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 84f7cfb479..ca16ba5251 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -485,7 +485,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 286 + run: python3 scripts/dep-sim.py --cut-nothing --expect-names 282 - name: Guard — new feature-gated test modules must be acknowledged # Self-maintaining coverage: the set of source files that #[cfg]-gate a test on diff --git a/AGENTS.md b/AGENTS.md index 9995d902b0..1716b8306b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -414,6 +414,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` (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 `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`) 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 089e38f2d7..7d66a5357b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -221,7 +221,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 @@ -429,7 +429,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla", "scheduler-gate", "file-logging", "contacts"] +default = ["inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla", "scheduler-gate", "file-logging", "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 @@ -530,6 +530,32 @@ 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 `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) +# 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 7f40b77c81..3a5e3e59e0 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -173,6 +173,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal # comes back with nothing to attach — and the absence is silent. "file-logging", "contacts", + "runtime-node", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/app/src-tauri/vendor/tauri-cef b/app/src-tauri/vendor/tauri-cef new file mode 160000 index 0000000000..455b47debb --- /dev/null +++ b/app/src-tauri/vendor/tauri-cef @@ -0,0 +1 @@ +Subproject commit 455b47debb9d1f5d0f1527266e48af1670ad4bc5 diff --git a/app/src-tauri/vendor/tauri-plugin-notification b/app/src-tauri/vendor/tauri-plugin-notification new file mode 160000 index 0000000000..5f61969018 --- /dev/null +++ b/app/src-tauri/vendor/tauri-plugin-notification @@ -0,0 +1 @@ +Subproject commit 5f619690188c6dbad58ceb170e9a86d61971c334 diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index 7833d19beb..0010a4cd1f 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -13,6 +13,17 @@ # Simulate with: scripts/dep-sim.py --cut # # History +# 305/282/4 2026-08-09 runtime-node gate, measured on top of the +# upstream/main merge below (307/284/5 -> 305/282/4). +# `xz2` + its static liblzma C build are exclusive to +# runtime::node::extractor's .tar.xz path, so gating the +# managed Node toolchain removes them: -2 packages, -2 +# names, and lzma-sys leaves the NATIVE list (5 -> 4) — +# the first native toolchain build removed by the gating +# program. Remaining native: libgit2-sys, libsqlite3-sys, +# libz-sys, ring — the target keeps the last two. +# Measured with `scripts/kernel-floor.sh flows`, and +# `scripts/dep-sim.py --cut-nothing` agrees (282 names). # 307/284/5 2026-08-09 merge upstream/main + PR #5446 memory-subsystem work. # Measured, not derived: `scripts/kernel-floor.sh flows` # reports 307/284/5 on this merge. +3 names over the @@ -147,4 +158,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:307:284:5 +flows:305:282:4 diff --git a/src/core/all.rs b/src/core/all.rs index bf96252892..5e6029b0a6 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -675,7 +675,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 88d6322c68..c43b2965a6 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -2408,3 +2408,33 @@ fn sole_capability_for_namespace_is_none_for_mixed_and_unknown_namespaces() { assert_eq!(sole_capability_for_namespace("people"), None); assert_eq!(sole_capability_for_namespace("not_a_namespace"), None); } + +// ---- 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 f38d2fea16..48ceb533b4 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(()); @@ -289,16 +296,20 @@ 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!` + // (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())); } 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..e39890ee00 100644 --- a/src/openhuman/runtime/node/mod.rs +++ b/src/openhuman/runtime/node/mod.rs @@ -7,24 +7,62 @@ //! 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 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; +#[cfg(feature = "runtime-node")] pub mod downloader; +#[cfg(feature = "runtime-node")] pub mod extractor; -pub mod ops; +#[cfg(feature = "runtime-node")] pub mod resolver; +#[cfg(feature = "runtime-node")] pub mod rpc; +#[cfg(feature = "runtime-node")] mod schemas; + +/// 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::{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}; 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, }; +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..e7832d862b --- /dev/null +++ b/src/openhuman/runtime/node/stub.rs @@ -0,0 +1,89 @@ +//! `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. +//! +//! 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; + +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) + } +} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index a8e01fd68b..087f77452c 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 bc99323639..a10f3cdbe6 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -707,10 +707,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(&[ @@ -1013,6 +1016,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 @@ -3035,3 +3039,21 @@ async fn narrow_capabilities_do_not_narrow_the_domain_axis() { ); } } + +/// `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" + ); + } +}