mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* build(skills): add serde_yaml for SKILL.md frontmatter (#681) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(skills): parse SKILL.md frontmatter and add multi-path discovery (#681) Extends the skills module with agentskills.io-style discovery: * Parses YAML frontmatter in SKILL.md (name, description, version, author, tags, allowed-tools, license) via serde_yaml, with a catch-all extras map for forward-compatible keys. * Adds SkillScope (User / Project / Legacy) and discover_skills(), which scans ~/.openhuman/skills, ~/.agents/skills, <ws>/.openhuman/ skills, <ws>/.agents/skills and the legacy <ws>/skills directory. * Gates project-scope loading behind an explicit <ws>/.openhuman/trust marker (is_workspace_trusted helper). * Resolves name collisions with project > user > legacy precedence and surfaces shadowing as per-skill warnings. * Inventories bundled resources under scripts/, references/, assets/. * Keeps the existing load_skills(workspace_dir) API as a backwards-compatible wrapper for existing callers. * Preserves legacy skill.json fallback (marked legacy = true). * Lenient validation: missing / mismatched / oversized name or description produce warnings instead of errors. * Adds 12 unit tests covering frontmatter parsing, trust gating, collision shadowing, lenient fallbacks, legacy JSON and resource inventory. One existing prompt test updated to use ..Default::default() for the expanded struct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(skills): align frontmatter with agentskills.io spec (#681) Per the agentskills.io SKILL.md spec, only name, description, license, compatibility, metadata, and allowed-tools are valid top-level keys. Our SkillFrontmatter had version, author, and tags as top-level fields, which drifted from the spec and would silently swallow mis-shaped data for skills authored against the canonical schema. Demote version/author/tags into the metadata map. Non-spec top-level keys still parse via #[serde(flatten)] into extra, and when present there we emit a migration warning and still populate the derived Skill fields so existing skills keep working. Add compatibility as an optional string alongside license. New tests cover the spec-compliant metadata shape, the deprecated top-level fallback path, and verify that the full spec frontmatter parses without leaking into extras. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * build(runtime): add tar+xz2+zip for node runtime extraction (#681) Node.js distributions ship as .tar.xz on Unix and .zip on Windows. The upcoming Node runtime bootstrap extracts these in pure Rust; xz2 is built with the `static` feature so liblzma is bundled and not a system dependency. zip is pinned to default-features = false + deflate to avoid pulling in bzip2/zstd/aes which we don't need for Node archives. Deps only — no behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(config): add NodeConfig with env overrides (#681) Introduce managed Node.js runtime configuration so skills that require `node`/`npm` (agentskills.io packages with build steps) can be wired in behind a single config switch. Fields: - `node.enabled` — master switch (default true) - `node.version` — pinned release (default `v22.11.0` LTS) - `node.cache_dir` — absolute path for managed distributions; empty = use workspace default - `node.prefer_system` — reuse matching system `node` when found (default true); set false for reproducible CI / airgapped deploys Env overrides land in load.rs alongside the existing LOCAL_AI_TIER block: - OPENHUMAN_NODE_ENABLED - OPENHUMAN_NODE_VERSION - OPENHUMAN_NODE_CACHE_DIR - OPENHUMAN_NODE_PREFER_SYSTEM No resolver / downloader yet — subsequent commits in the #681 series consume this config to bootstrap the runtime. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(node_runtime): detect compatible system node on PATH (#681) Introduce the `openhuman::node_runtime` module and its first piece: a synchronous resolver that walks `PATH`, probes `node --version`, and returns a `SystemNode` when the host toolchain major-version matches the configured target. Why resolve first: a successful probe lets the bootstrap skip a ~60 MB download per managed install. Matching is intentionally loose on the patch level (Node LTS lines are ABI-stable and skills pin their own deps via package-lock.json); operators needing strict pinning can set `node.prefer_system = false`. Tracing is verbose by design — resolver decisions gate the download path, so operators need a clear breadcrumb trail at `debug`/`info`. Includes unit tests for `parse_node_version` covering `v` prefix, whitespace, major-only, and malformed inputs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(node_runtime): SHASUMS256-verified distribution downloader (#681) Add the second piece of the managed Node.js runtime: streaming download of OS/arch-specific prebuilt archives from nodejs.org, gated by a SHA-256 match against the release's signed `SHASUMS256.txt`. Key contracts: - `NodeDistribution::for_host(version)` picks the right archive for the current OS/arch tuple. Supported matrix: * darwin-{arm64,x64}.tar.xz * linux-{arm64,x64,armv7l}.tar.xz * win-{arm64,x64}.zip Unsupported hosts surface a clear error so the caller can flip `node.enabled = false` or point at a pre-installed toolchain. - `fetch_shasums(client, version)` parses `SHASUMS256.txt` into a filename -> hex digest map. Tolerant of trailing / signature blocks. - `download_distribution(...)` streams chunks into the target path, computes SHA-256 on the fly, and wipes the partial file if the digest does not match. Integrity check is mandatory — skills will execute untrusted code inside the resolved runtime. Verbose tracing at `info` and `debug` follows the repo debug-logging rule; operators can grep `[node_runtime::downloader]` to trace every GET, chunk boundary (via total-bytes log), and hash decision. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(node_runtime): archive extraction + atomic install (#681) Extract downloaded Node.js distributions and move them into the final cache path without leaving the reader observing a half-populated directory. - `extract_distribution(archive, extract_root, is_zip)` — wraps the synchronous `tar`/`xz2`/`zip` crates in `spawn_blocking` and returns the single top-level folder produced by the archive (`node-vX.Y.Z- <os>-<arch>/`). `set_preserve_permissions(true)` + `set_overwrite( true)` on the tar side keeps the `node` binary's `+x` bit. The zip path restores Unix mode bits via `unix_mode()` so cross-OS builds still land correct permissions. - `atomic_install(staged, final_dest)` — renames a staged directory into place via a single `rename(2)`, moving any pre-existing install to a `.old-<pid>` sibling first and cleaning it up on success. This gives concurrent readers a "before" or "after" view, never a partial one. - Unsafe zip paths (`enclosed_name()` rejection) are logged and skipped rather than traversed — defence against malicious archives, even though the digest check in the downloader already rules out tampered official releases. No new tests here: the logic leans on upstream tar/zip crates, and meaningful end-to-end coverage requires a real archive on disk — that comes in the integration-test commit later in this series. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(node_runtime): bootstrap mutex + cache + bin path helper (#681) Stitch resolver, downloader, and extractor into a single idempotent entry point callers use at startup (or lazily before the first `node_exec`/`npm_exec` call). `NodeBootstrap::resolve()` contract: 1. Return cached `ResolvedNode` if a previous call already succeeded. 2. Bail when `node.enabled = false`. 3. If `node.prefer_system`, probe the host PATH. Matching major version wins — return a `System`-sourced `ResolvedNode`. 4. Otherwise compute the install path (`{cache_dir}/node-v{version}-{os}-{arch}/`). If it already contains valid bins, reuse it. This makes the bootstrap ~free across restarts once a managed install lands. 5. Else fetch `SHASUMS256.txt`, locate the expected digest for our archive, stream the download, extract into a `.stage-<pid>` scratch dir, `atomic_install` the top-level folder into the final path, and remove scratch + archive to reclaim disk. Concurrency is handled by a `tokio::sync::Mutex<Option<ResolvedNode>>` — parallel callers queue behind the first one; the winner memoises, the losers pick up the cached result. No race can produce two concurrent downloads of the same archive. Platform-specific bin layout lives in `managed_bin_dir` / `build_ resolved`: - Unix: `<install>/bin/{node,npm}` - Windows: `<install>/{node.exe,npm.cmd}` This closes the resolver/downloader/cache layer promised in the #681 checkpoint. Tools (`node_exec`, `npm_exec`) and shell-PATH injection layer on top in the next batch of commits. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(tools): node_exec runs JS via managed node runtime (#681) Executes `inline_code` (via `node -e`) or a workspace-relative `script_path` through the NodeBootstrap-resolved `node` binary. POSIX single-quote quoting keeps user input inert; `env_clear` + allow-list mirrors the shell tool so secrets never leak. 300s default timeout (capped at 1800s), 1MB stdout/stderr caps. PATH is prepended with the resolved bin dir so child processes see managed `node`/`npm`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(tools): npm_exec runs npm via managed node runtime (#681) Thin npm CLI wrapper paired with node_exec. Subcommands go through `is_sane_subcommand` (alphanumerics + `._-:` only) and a deny-list (publish/adduser/login/token/…) blocks registry-mutation and auth flows. `cwd` is resolved under the workspace — absolute paths and `..` components are rejected. 600s default timeout (1800s ceiling), 1MB stdout/stderr caps. PATH prepended with managed bin dir so npm's own node/corepack lookups hit the managed toolchain. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(tools): shell prepends managed node bin to PATH when cached (#681) Adds a non-blocking `NodeBootstrap::try_cached()` primitive that peeks the memoised `ResolvedNode` without holding the async lock or triggering a download. ShellTool gains an optional `node_bootstrap` field wired through a new `with_node_bootstrap` constructor; when set, each shell invocation consults `try_cached()` and, on a hit, prepends the managed bin dir to the child PATH using the platform separator. Unrelated shell commands stay byte-identical — no download is ever forced from the shell path. Consequence: once `node_exec`/`npm_exec` have resolved the toolchain once, skills that shell out to `node`/`npm`/`npx`/`corepack` transparently pick up the managed install without any per-command coordination. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(tools): register node_exec + npm_exec behind node.enabled (#681) Wires the skills-oriented Node.js toolchain into the tool registry: * Constructs one session-scoped `NodeBootstrap` in `all_tools_with_runtime` when `root_config.node.enabled` is true — all three consumers (ShellTool, NodeExecTool, NpmExecTool) share the same `Arc<NodeBootstrap>` so the download/extract/install pipeline runs at most once per session and the memoised `ResolvedNode` is reused across every shelling-out call. * Swaps ShellTool to `with_node_bootstrap(...)` when the bootstrap exists so shell PATH injection fires automatically once any node/npm tool has resolved the runtime. * Registers `node_exec` and `npm_exec` only when the flag is on — flipping `node.enabled = false` cleanly removes both tools and falls back to the legacy ShellTool construction. * Adds two regression tests (`all_tools_registers_node_exec_when_node_enabled` and `all_tools_excludes_node_exec_when_node_disabled`) so future refactors can't silently drop the wiring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(agents): grant node_exec + npm_exec to code_executor + tool_maker (#681) code_executor is the sandboxed developer sub-agent — it writes, runs, and debugs code — and tool_maker is the narrow self-healing agent that polyfills missing commands. Both now see the managed Node.js tools so skills and polyfills can call into the resolved runtime directly rather than relying on whatever `node`/`npm` happens to be on the host PATH. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style(core): apply cargo fmt auto-fixes (#681) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(cargo): sync Cargo.lock to OpenHuman v0.52.26 after rebase (#681) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(node_runtime): strip leading v/V defensively in resolve_from_system (#681) CodeRabbit R1 flagged that build_resolved receives a raw version string from SystemNode. detect_system_node already trims the prefix, but trim defensively at the resolve_from_system boundary too so any future code path constructing SystemNode with an un-normalised version won't emit "vvX.Y.Z". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(node_runtime): propagate flush errors and clean up partial archives (#681) CodeRabbit R2 flagged that download_distribution silently swallowed flush errors via `.ok()` and left partial files on disk when any chunk/write/flush step errored. Wrap the streaming loop in an async block returning Result<()>, propagate flush errors, and on any failure delete the partial archive so a retry starts clean and callers never see a half-written file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(node_runtime): restore previous install on staged rename failure (#681) CodeRabbit R3 flagged that atomic_install left the user with no Node runtime on disk if the staged->final rename failed after a backup had been taken. Wrap the rename in `if let Err`, restore the backup if present, log restore failures separately (warning), and always return the original error so the caller sees the true failure cause. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(node_runtime): enforce real 5s timeout on node --version probe (#681) CodeRabbit R4 flagged that probe_node_version used Command::output() with no real timeout — a broken shim or FUSE-backed binary could hang the bootstrap forever. Add wait-timeout crate dep and rewrite the probe to spawn with piped stdio, wait_timeout(5s), kill on timeout, and read stdout/stderr from the piped handles after exit. Logs a warning when a probe times out so the install can be diagnosed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tools): reject script_path escapes in node_exec (#681) CodeRabbit R5 flagged that node_exec joined user-supplied script_path onto the workspace without rejecting `..` segments or absolute paths, letting a prompt-injected `../../../etc/passwd` read arbitrary files via node. Add a resolve_script_path helper mirroring npm_exec::resolve_cwd — reject empty, absolute, parent-dir, and Windows-prefix components. Extra positional args stay opaque (shell-quoted, not path-checked) and get an explanatory comment at the call site. Unit tests cover each rejection case plus the relative- subdir happy path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(skills): drop dead SKILL.md branch in legacy manifest loader (#681) CodeRabbit N1 flagged dead code: load_from_legacy_manifest only runs when load_skill_dir already determined SKILL.md is absent, so the fallback that re-checked for SKILL.md and its helper read_skill_md_description could never execute. Simplify to a direct description/location decision and delete the dead helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(config): extract parse_env_bool helper with warn on unknown values (#681) CodeRabbit N2 flagged two near-identical boolean env-override match blocks for OPENHUMAN_NODE_ENABLED and OPENHUMAN_NODE_PREFER_SYSTEM. Extract a module-level parse_env_bool helper that accepts 1/true/yes/on and 0/false/no/off (case-insensitive) and emits a tracing::warn when the value is unrecognised so silent mis-spellings don't invisibly leave the config unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(node_runtime): detect corrupted cache missing npm (#723) `probe_managed_install` now verifies the npm launcher exists before reusing an extracted install. A download interrupted after `node` was extracted but before `npm` would otherwise be cached forever and `npm_exec` could never self-heal — now the corrupted cache forces a fresh download via the normal resolve path. Addresses CodeRabbit review feedback on PR #723. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(node_runtime): skip non-executable PATH shims when probing node (#723) `which_node` previously returned the first matching filename on `PATH` regardless of its execute bit. A non-executable `node` placeholder earlier in `PATH` (e.g. an unprivileged shim left by a failed install) would mask a valid later install and force the managed runtime download. Now checks `file && (mode & 0o111 != 0)` on Unix to mirror shell `which` behaviour. Windows remains unchanged — `.exe` suffix already encodes executability for the loader. Addresses CodeRabbit review feedback on PR #723. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(skills): deterministic entry order in scan_root (#723) `read_dir` order is unspecified by the OS. When two sibling skill directories declare the same logical `frontmatter.name` (which can differ from the folder name), cross-scope/same-scope deduplication downstream would pick a non-deterministic winner across runs. Sort entries by on-disk directory name for a stable, reproducible order so collision resolution is deterministic. Addresses CodeRabbit review feedback on PR #723. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(skills): surface YAML parse errors as skill warnings (#723) `parse_skill_md` previously swallowed the `serde_yaml` error via `log::warn!` and fell back to an empty `SkillFrontmatter`, then the catalog reported a generic "could not parse — exposing directory as placeholder". Skill authors had no way to see the real cause without scraping logs. Return parse-level diagnostics as a third tuple element. `load_from_skill_md` merges them into the skill's user-visible `warnings`, so the catalog now surfaces the actual YAML error (e.g. "frontmatter parse error: mapping values are not allowed here at line 3 column 12"). Addresses CodeRabbit review feedback on PR #723. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(skills): skip symlinks when walking skill resources (#723) `walk_files` used `is_dir()` / `is_file()` which transparently follow symlinks. Two failure modes: 1. **Unbounded recursion** — a skill resource symlink that points back at an ancestor (e.g. `resources/self -> resources/`) would cause infinite traversal, eventually blowing the stack. 2. **Silent out-of-tree leakage** — a symlink pointing at `/`, `/etc`, or another skill's directory would enumerate its contents into the current skill's resource listing. Switch to `entry.file_type()` and skip symlinks before descending. Directories and regular files behave as before. Addresses CodeRabbit review feedback on PR #723. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(node_runtime): default managed cache to user-owned OS cache (#723) Default `cache_root()` to `dirs::cache_dir()/openhuman/node-runtime/` so a repo cannot ship a checked-in `./node-runtime/` and have the bootstrap reuse it as a trusted managed install. Explicit `config.cache_dir` still wins; workspace-local falls back only when `dirs::cache_dir()` is unavailable, and we emit a warning on that fallback. As a second line of defence, `probe_managed_install()` now canonicalises both the install dir and the cache root and refuses any install that escapes the cache tree. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(node_runtime): require npm when accepting system node (#723) On distros that package `node` and `npm` separately (Debian/Ubuntu, Alpine `nodejs-current`, some NixOS setups) the host `node` can be present without `npm`. `npm_exec` then fails every call because the resolved `SystemNode` has no usable npm launcher. Before returning `Some(SystemNode)`, locate `npm` on `PATH` and probe `npm --version` through the same `wait_timeout` path as the node probe. Either missing gate falls back to the managed download flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(skills): surface user-scope skills via load_skills (#723) Existing production callers (`agent::harness::session::builder`, `channels::runtime::startup`) reach the skill catalog via `load_skills(workspace_dir)`. Previously this shim passed `None` for the home directory, so skills installed under `~/.openhuman/skills/` and `~/.agents/skills/` were silently dropped even after the multi-scope discovery landed in `discover_skills`. Delegate to `discover_skills_inner` with `dirs::home_dir()` so user-scope skills reach the runtime; project-scope still wins on name collision. Tests stay hermetic via a `load_skills_ws` helper that preserves the old workspace-only semantics. A new `load_skills_surfaces_user_scope` test drives a tempdir-as-home through `discover_skills` and asserts the user-scope skill is returned with `SkillScope::User`. Addresses CodeRabbit review comment 3116332244. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(skills): skip symlinked skill dirs during scan (#723) `scan_root` previously accepted any entry where `path.is_dir()` returned true. `is_dir()` dereferences symlinks, so a link from `<skills-root>/foo -> /some/external/tree` would load as a legitimate skill even though `walk_files` already rejects symlinks deeper in the resource walker. Attacker-authored symlinks in a skills root would therefore have one remaining escape hatch. Switch to `entry.file_type()` (non-dereferencing) and reject both symlinked and non-directory entries at the top level. Treat a failed `file_type()` probe as "not safe to traverse" and skip. A new `symlinked_skill_dirs_are_skipped` test (Unix-only, since symlinks are the platform guarantee being tested) creates an external skill tempdir, links it into the workspace skills root, and asserts `load_skills_ws` returns empty. Addresses CodeRabbit review comment 3116332252. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(skills): reject symlinked resource roots (#723) `inventory_resources` gated each resource sub-root (`scripts/`, `references/`, `assets/`) on `root.is_dir()`. `is_dir()` follows symlinks, so a `scripts -> /etc` link inside a skill directory would pass the check and `walk_files` would inventory the external tree. Deeper symlinks inside the walk were already rejected by `walk_files`, but the root-level check was the missing layer. Use `std::fs::symlink_metadata` for a non-dereferencing probe and reject roots whose own `file_type().is_symlink()` returns true. Treat any `symlink_metadata` error as a non-existent root and skip. A new `symlinked_resource_roots_are_rejected` test (Unix-only) links a skill's `assets/` sub-root to an external tempdir with a file inside and asserts `inventory_resources` returns empty. Addresses CodeRabbit review comment 3116332260. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5bc799ffeb
commit
3da80d852e
Generated
+153
-1
@@ -189,6 +189,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
@@ -1407,6 +1416,17 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "1.0.0"
|
||||
@@ -1922,6 +1942,17 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -3186,7 +3217,10 @@ version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3273,6 +3307,17 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-sys"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -4438,10 +4483,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde-big-array",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"shellexpand",
|
||||
"socketioxide",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -4465,8 +4512,11 @@ dependencies = [
|
||||
"wa-rs-proto",
|
||||
"wa-rs-tokio-transport",
|
||||
"wa-rs-ureq-http",
|
||||
"wait-timeout",
|
||||
"webpki-roots 1.0.6",
|
||||
"whisper-rs",
|
||||
"xz2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4635,7 +4685,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -4838,6 +4888,12 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
@@ -5374,6 +5430,15 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
@@ -6208,6 +6273,19 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -6520,6 +6598,17 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -7215,6 +7304,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -7572,6 +7667,15 @@ dependencies = [
|
||||
"wa-rs-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -8548,6 +8652,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xkbcommon"
|
||||
version = "0.8.0"
|
||||
@@ -8582,6 +8696,15 @@ version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
|
||||
[[package]]
|
||||
name = "xz2"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
|
||||
dependencies = [
|
||||
"lzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.7.5"
|
||||
@@ -8745,6 +8868,23 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.3"
|
||||
@@ -8757,6 +8897,18 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
|
||||
+10
@@ -16,6 +16,7 @@ crate-type = ["rlib"]
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
# Used by composio post-processing to convert Gmail HTML bodies to
|
||||
# markdown. `html2md` is the canonical pure-Rust crate (no system deps);
|
||||
# `htmd` (>0.5) is more actively maintained but our usage is one
|
||||
@@ -37,6 +38,15 @@ rand = "0.9"
|
||||
dirs = "5"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
# Archive extraction for the Node.js runtime bootstrap. Unix Node
|
||||
# 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"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# Real timeout for `node --version` probes in the runtime resolver. Guards
|
||||
# against a broken shim on PATH hanging the bootstrap forever.
|
||||
wait-timeout = "0.2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -14,4 +14,4 @@ omit_skills_catalog = true
|
||||
hint = "coding"
|
||||
|
||||
[tools]
|
||||
named = ["shell", "file_read", "file_write", "git_operations"]
|
||||
named = ["shell", "file_read", "file_write", "git_operations", "node_exec", "npm_exec"]
|
||||
|
||||
@@ -13,4 +13,4 @@ omit_skills_catalog = true
|
||||
hint = "coding"
|
||||
|
||||
[tools]
|
||||
named = ["file_write", "shell"]
|
||||
named = ["file_write", "shell", "node_exec", "npm_exec"]
|
||||
|
||||
@@ -173,6 +173,7 @@ fn prompt_skills_compact_list() {
|
||||
tools: vec![],
|
||||
prompts: vec!["Long prompt content that should NOT appear in system prompt".into()],
|
||||
location: None,
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, Some("Discord"));
|
||||
|
||||
@@ -21,6 +21,25 @@ fn default_config_and_workspace_dirs() -> Result<(PathBuf, PathBuf)> {
|
||||
Ok((config_dir.clone(), config_dir.join("workspace")))
|
||||
}
|
||||
|
||||
/// Parse a boolean env-var value. Accepts the usual truthy/falsy tokens
|
||||
/// (`1/true/yes/on` and `0/false/no/off`, case-insensitive). Returns `None`
|
||||
/// on unrecognised values and logs a warning so silent mis-spellings don't
|
||||
/// invisibly leave the config unchanged.
|
||||
fn parse_env_bool(name: &str, raw: &str) -> Option<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
env = %name,
|
||||
value = %raw,
|
||||
"invalid boolean env override ignored; expected 1/true/yes/on or 0/false/no/off"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIVE_WORKSPACE_STATE_FILE: &str = "active_workspace.toml";
|
||||
static WARNED_WORLD_READABLE_CONFIGS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
|
||||
@@ -793,6 +812,30 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Node runtime overrides
|
||||
if let Ok(flag) = std::env::var("OPENHUMAN_NODE_ENABLED") {
|
||||
if let Some(enabled) = parse_env_bool("OPENHUMAN_NODE_ENABLED", &flag) {
|
||||
self.node.enabled = enabled;
|
||||
}
|
||||
}
|
||||
if let Ok(version) = std::env::var("OPENHUMAN_NODE_VERSION") {
|
||||
let trimmed = version.trim();
|
||||
if !trimmed.is_empty() {
|
||||
self.node.version = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(dir) = std::env::var("OPENHUMAN_NODE_CACHE_DIR") {
|
||||
let trimmed = dir.trim();
|
||||
if !trimmed.is_empty() {
|
||||
self.node.cache_dir = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(flag) = std::env::var("OPENHUMAN_NODE_PREFER_SYSTEM") {
|
||||
if let Some(prefer_system) = parse_env_bool("OPENHUMAN_NODE_PREFER_SYSTEM", &flag) {
|
||||
self.node.prefer_system = prefer_system;
|
||||
}
|
||||
}
|
||||
|
||||
let dsn_value = std::env::var("OPENHUMAN_SENTRY_DSN")
|
||||
.ok()
|
||||
.or_else(|| option_env!("OPENHUMAN_SENTRY_DSN").map(|s| s.to_string()));
|
||||
|
||||
@@ -19,6 +19,7 @@ pub use load::{
|
||||
user_openhuman_dir, write_active_user_id, PRE_LOGIN_USER_ID,
|
||||
};
|
||||
mod local_ai;
|
||||
mod node;
|
||||
mod observability;
|
||||
mod orchestrator;
|
||||
mod proxy;
|
||||
@@ -44,6 +45,7 @@ pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
|
||||
pub use identity_cost::{CostConfig, ModelPricing, PeripheralBoardConfig, PeripheralsConfig};
|
||||
pub use learning::{LearningConfig, ReflectionSource};
|
||||
pub use local_ai::LocalAiConfig;
|
||||
pub use node::NodeConfig;
|
||||
pub use observability::ObservabilityConfig;
|
||||
pub use orchestrator::OrchestratorConfig;
|
||||
pub use proxy::{
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Node.js managed runtime configuration.
|
||||
//!
|
||||
//! Controls whether the core bootstraps a Node.js toolchain for skills that
|
||||
//! require `node`/`npm` (e.g. agentskills.io packages with build steps).
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NodeConfig {
|
||||
/// Master switch. When `false`, the Node runtime is not resolved and
|
||||
/// `node_exec` / `npm_exec` tools are not registered.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Target Node.js release line (used to build download URLs and bin cache
|
||||
/// directory name, e.g. `v22.11.0`). Pin to a known LTS for reproducibility.
|
||||
#[serde(default = "default_version")]
|
||||
pub version: String,
|
||||
/// Absolute path to a directory where managed Node distributions are
|
||||
/// extracted. Empty string means "use the default workspace cache dir"
|
||||
/// (resolved by the runtime bootstrap).
|
||||
#[serde(default)]
|
||||
pub cache_dir: String,
|
||||
/// When `true` and a system `node` binary is found on `PATH` whose major
|
||||
/// version matches `version`, reuse it instead of downloading. Disable for
|
||||
/// reproducible CI / airgapped deployments.
|
||||
#[serde(default = "default_prefer_system")]
|
||||
pub prefer_system: bool,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
"v22.11.0".to_string()
|
||||
}
|
||||
|
||||
fn default_prefer_system() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for NodeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_enabled(),
|
||||
version: default_version(),
|
||||
cache_dir: String::new(),
|
||||
prefer_system: default_prefer_system(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,10 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub local_ai: LocalAiConfig,
|
||||
|
||||
/// Node.js managed runtime configuration (skills that need `node`/`npm`).
|
||||
#[serde(default)]
|
||||
pub node: NodeConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub voice_server: VoiceServerConfig,
|
||||
|
||||
@@ -247,6 +251,7 @@ impl Default for Config {
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
local_ai: LocalAiConfig::default(),
|
||||
node: NodeConfig::default(),
|
||||
voice_server: VoiceServerConfig::default(),
|
||||
query_classification: QueryClassificationConfig::default(),
|
||||
integrations: IntegrationsConfig::default(),
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod learning;
|
||||
pub mod local_ai;
|
||||
pub mod memory;
|
||||
pub mod migration;
|
||||
pub mod node_runtime;
|
||||
pub mod overlay;
|
||||
pub mod providers;
|
||||
pub mod referral;
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Node.js bootstrap orchestrator.
|
||||
//!
|
||||
//! Ties the [`resolver`](super::resolver), [`downloader`](super::downloader),
|
||||
//! and [`extractor`](super::extractor) modules into a single idempotent
|
||||
//! entry point that callers use at startup (or lazily before the first
|
||||
//! `node_exec` / `npm_exec` call):
|
||||
//!
|
||||
//! ```text
|
||||
//! NodeBootstrap::new(config) -> resolve() -> ResolvedNode { node_bin, npm_bin, .. }
|
||||
//! ```
|
||||
//!
|
||||
//! The bootstrap is **serialised** through a `tokio::sync::Mutex` so that
|
||||
//! concurrent callers never race on the download/extract/install pipeline.
|
||||
//! Once a resolution succeeds the result is memoised — subsequent calls
|
||||
//! return the cached `ResolvedNode` in O(1).
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use reqwest::Client;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::downloader::{download_distribution, fetch_shasums, NodeDistribution};
|
||||
use super::extractor::{atomic_install, extract_distribution};
|
||||
use super::resolver::{detect_system_node, SystemNode};
|
||||
use crate::openhuman::config::schema::NodeConfig;
|
||||
|
||||
/// Origin of the resolved toolchain — feeds into logging and lets the
|
||||
/// caller decide whether to expose a "Node was downloaded to …" message in
|
||||
/// the UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NodeSource {
|
||||
/// Reused a compatible `node` already on the host `PATH`.
|
||||
System,
|
||||
/// Downloaded + extracted a managed distribution.
|
||||
Managed,
|
||||
}
|
||||
|
||||
/// Fully-resolved Node.js toolchain. Callers should only cache this via the
|
||||
/// [`NodeBootstrap`] — constructing one by hand bypasses version pinning.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedNode {
|
||||
/// Directory that should be prepended to `PATH` for child processes so
|
||||
/// `node`, `npm`, `npx`, `corepack` resolve to the managed binaries.
|
||||
pub bin_dir: PathBuf,
|
||||
/// Absolute path to the `node` binary.
|
||||
pub node_bin: PathBuf,
|
||||
/// Absolute path to the `npm` launcher (shell script on Unix, `.cmd`
|
||||
/// shim on Windows). Symlinks on Unix distributions point at a JS file
|
||||
/// in `lib/` — invoking through the launcher is the supported contract.
|
||||
pub npm_bin: PathBuf,
|
||||
/// Version string without the leading `v` (e.g. `"22.11.0"`).
|
||||
pub version: String,
|
||||
/// Where the toolchain came from.
|
||||
pub source: NodeSource,
|
||||
}
|
||||
|
||||
/// Serialised bootstrap entrypoint. Hold one per process (e.g. behind a
|
||||
/// `OnceCell`) — the internal mutex is what makes concurrent `resolve()`
|
||||
/// calls safe.
|
||||
pub struct NodeBootstrap {
|
||||
config: NodeConfig,
|
||||
workspace_dir: PathBuf,
|
||||
client: Client,
|
||||
cached: Arc<Mutex<Option<ResolvedNode>>>,
|
||||
}
|
||||
|
||||
impl NodeBootstrap {
|
||||
/// Build a new bootstrap. `workspace_dir` is used to derive the default
|
||||
/// cache location when `config.cache_dir` is empty.
|
||||
pub fn new(config: NodeConfig, workspace_dir: PathBuf, client: Client) -> Self {
|
||||
Self {
|
||||
config,
|
||||
workspace_dir,
|
||||
client,
|
||||
cached: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Peek at the memoised [`ResolvedNode`] without triggering a download.
|
||||
///
|
||||
/// Returns `Some(..)` only when a previous `resolve()` call succeeded
|
||||
/// and the cache lock is currently free. Returns `None` otherwise —
|
||||
/// e.g. no resolution has happened yet, or another task holds the
|
||||
/// lock doing the initial install. Callers use this for transparent
|
||||
/// PATH injection (shell tool) where a blocking wait or a forced
|
||||
/// download would change the semantics of unrelated commands.
|
||||
pub fn try_cached(&self) -> Option<ResolvedNode> {
|
||||
self.cached.try_lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
/// Resolve the Node.js toolchain, downloading + extracting a managed
|
||||
/// distribution if necessary. Idempotent: the first successful call
|
||||
/// memoises the result; later calls return it without further I/O.
|
||||
pub async fn resolve(&self) -> Result<ResolvedNode> {
|
||||
let mut guard = self.cached.lock().await;
|
||||
if let Some(existing) = guard.as_ref() {
|
||||
tracing::debug!(
|
||||
version = %existing.version,
|
||||
source = ?existing.source,
|
||||
"[node_runtime::bootstrap] returning cached ResolvedNode"
|
||||
);
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
|
||||
if !self.config.enabled {
|
||||
bail!("node runtime is disabled (set node.enabled = true to use skills that require node/npm)");
|
||||
}
|
||||
|
||||
if self.config.prefer_system {
|
||||
if let Some(system) = detect_system_node(&self.config.version) {
|
||||
let resolved = resolve_from_system(system)?;
|
||||
*guard = Some(resolved.clone());
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
let managed = self.install_managed().await?;
|
||||
*guard = Some(managed.clone());
|
||||
Ok(managed)
|
||||
}
|
||||
|
||||
/// Compute the cache root for managed Node.js installs.
|
||||
///
|
||||
/// Resolution order (first hit wins):
|
||||
/// 1. Explicit `config.cache_dir` — an operator/user opted into a specific
|
||||
/// location and we honour it verbatim (including workspace-local paths
|
||||
/// if they set one).
|
||||
/// 2. OS user cache (`dirs::cache_dir()/openhuman/node-runtime`) — the
|
||||
/// default. Lives in the user's home and cannot be spoofed by a
|
||||
/// repository checked-in `./node-runtime/` tree.
|
||||
/// 3. Last-resort `{workspace}/node-runtime/` fallback, emitted with a
|
||||
/// warning for platforms where `dirs::cache_dir()` returns `None`.
|
||||
///
|
||||
/// Note: returning a workspace-local path by default would let a malicious
|
||||
/// repository vendor a fake `node-v*/` tree into the workspace and have
|
||||
/// [`probe_managed_install`] reuse it as a trusted managed runtime (see
|
||||
/// CodeRabbit finding on PR #723). Guarding that path in the probe is the
|
||||
/// second defence; picking a user-owned default here is the first.
|
||||
fn cache_root(&self) -> PathBuf {
|
||||
let configured = self.config.cache_dir.trim();
|
||||
if !configured.is_empty() {
|
||||
return PathBuf::from(configured);
|
||||
}
|
||||
if let Some(user_cache) = dirs::cache_dir() {
|
||||
return user_cache.join("openhuman").join("node-runtime");
|
||||
}
|
||||
tracing::warn!(
|
||||
workspace = %self.workspace_dir.display(),
|
||||
"[node_runtime::bootstrap] dirs::cache_dir() unavailable; falling back to workspace-local node-runtime (less secure — set config.cache_dir to a user-owned path)"
|
||||
);
|
||||
self.workspace_dir.join("node-runtime")
|
||||
}
|
||||
|
||||
/// Full install path for the managed distribution. Matches the
|
||||
/// archive's top-level folder name so `find_single_top_level` picks the
|
||||
/// same directory when re-validating an existing install.
|
||||
fn install_dir(&self, dist: &NodeDistribution) -> PathBuf {
|
||||
// `archive_name` is e.g. `node-v22.11.0-darwin-arm64.tar.xz`.
|
||||
// Strip the extension(s) to get the install folder name.
|
||||
let stem = dist
|
||||
.archive_name
|
||||
.trim_end_matches(".zip")
|
||||
.trim_end_matches(".tar.xz")
|
||||
.trim_end_matches(".tar")
|
||||
.to_string();
|
||||
self.cache_root().join(stem)
|
||||
}
|
||||
|
||||
/// Full managed-install flow:
|
||||
/// 1. Shortcut if an extracted install already exists and has valid
|
||||
/// `node`/`npm` binaries.
|
||||
/// 2. Otherwise fetch `SHASUMS256.txt`, pick the matching digest,
|
||||
/// download the archive, extract it, and atomically install.
|
||||
async fn install_managed(&self) -> Result<ResolvedNode> {
|
||||
let dist = NodeDistribution::for_host(&self.config.version)?;
|
||||
let install_dir = self.install_dir(&dist);
|
||||
|
||||
let cache_root = self.cache_root();
|
||||
if let Some(resolved) =
|
||||
probe_managed_install(&install_dir, &cache_root, &self.config.version)
|
||||
{
|
||||
tracing::info!(
|
||||
install_dir = %install_dir.display(),
|
||||
"[node_runtime::bootstrap] reusing existing managed install"
|
||||
);
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
version = %dist.version,
|
||||
install_dir = %install_dir.display(),
|
||||
"[node_runtime::bootstrap] installing managed node"
|
||||
);
|
||||
|
||||
let shasums = fetch_shasums(&self.client, &self.config.version).await?;
|
||||
let expected = shasums
|
||||
.get(&dist.archive_name)
|
||||
.cloned()
|
||||
.with_context(|| format!("SHASUMS256.txt missing entry for {}", dist.archive_name))?;
|
||||
|
||||
let cache_root = self.cache_root();
|
||||
tokio::fs::create_dir_all(&cache_root)
|
||||
.await
|
||||
.with_context(|| format!("creating cache root {}", cache_root.display()))?;
|
||||
let archive_path = cache_root.join(&dist.archive_name);
|
||||
download_distribution(&self.client, &dist, &archive_path, &expected).await?;
|
||||
|
||||
// Extract into a scratch folder so a partial extraction never
|
||||
// contaminates the cache root; `atomic_install` promotes the
|
||||
// inner top-level folder into the final install path.
|
||||
let scratch = cache_root.join(format!(".stage-{}", std::process::id()));
|
||||
// Wipe any leftover from a previous crashed run.
|
||||
let _ = tokio::fs::remove_dir_all(&scratch).await;
|
||||
let top_level = extract_distribution(&archive_path, &scratch, dist.is_zip).await?;
|
||||
atomic_install(&top_level, &install_dir).await?;
|
||||
let _ = tokio::fs::remove_dir_all(&scratch).await;
|
||||
let _ = tokio::fs::remove_file(&archive_path).await;
|
||||
|
||||
let bin_dir = managed_bin_dir(&install_dir);
|
||||
let version = dist.version.trim_start_matches('v').to_string();
|
||||
build_resolved(bin_dir, version, NodeSource::Managed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-specific bin layout.
|
||||
///
|
||||
/// * macOS/Linux: `<install>/bin/{node,npm}`
|
||||
/// * Windows: `<install>/{node.exe,npm.cmd}` (no `bin/` subdir in the
|
||||
/// official zip distributions)
|
||||
fn managed_bin_dir(install_dir: &Path) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
install_dir.to_path_buf()
|
||||
} else {
|
||||
install_dir.join("bin")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`ResolvedNode`] from a bin directory by filling in the
|
||||
/// platform-specific executable names.
|
||||
fn build_resolved(bin_dir: PathBuf, version: String, source: NodeSource) -> Result<ResolvedNode> {
|
||||
let (node_name, npm_name) = if cfg!(windows) {
|
||||
("node.exe", "npm.cmd")
|
||||
} else {
|
||||
("node", "npm")
|
||||
};
|
||||
let node_bin = bin_dir.join(node_name);
|
||||
let npm_bin = bin_dir.join(npm_name);
|
||||
if !node_bin.is_file() {
|
||||
bail!(
|
||||
"resolved node bin missing: {} — install appears corrupted",
|
||||
node_bin.display()
|
||||
);
|
||||
}
|
||||
if !npm_bin.exists() {
|
||||
tracing::warn!(
|
||||
npm_bin = %npm_bin.display(),
|
||||
"[node_runtime::bootstrap] npm launcher missing; npm_exec tool will fail until reinstall"
|
||||
);
|
||||
}
|
||||
Ok(ResolvedNode {
|
||||
bin_dir,
|
||||
node_bin,
|
||||
npm_bin,
|
||||
version,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrap a detected system node in a [`ResolvedNode`].
|
||||
///
|
||||
/// `detect_system_node` already strips the leading `v` from the probed
|
||||
/// version, but we re-normalise here so the `ResolvedNode::version`
|
||||
/// contract (no leading `v`) cannot be violated by any future code path
|
||||
/// that constructs a `SystemNode` differently.
|
||||
fn resolve_from_system(system: SystemNode) -> Result<ResolvedNode> {
|
||||
let bin_dir = system
|
||||
.path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default();
|
||||
let version = system
|
||||
.version
|
||||
.trim_start_matches(|c: char| c == 'v' || c == 'V')
|
||||
.trim()
|
||||
.to_string();
|
||||
build_resolved(bin_dir, version, NodeSource::System)
|
||||
}
|
||||
|
||||
/// Check whether `install_dir` already contains a usable managed install
|
||||
/// for `target_version`. Cheap enough to run on every `resolve()` because
|
||||
/// it never touches the network — just a few `stat()` calls.
|
||||
///
|
||||
/// Also guards against **cache-root escape**: callers derive `install_dir`
|
||||
/// from `cache_root` via [`NodeBootstrap::install_dir`], but a symlinked or
|
||||
/// out-of-tree `install_dir` (e.g. a committed workspace `./node-runtime/`
|
||||
/// tree when `cache_root` resolves to the user cache) must not be treated
|
||||
/// as a trusted install. We canonicalise both paths and require the install
|
||||
/// to live under the cache root; mismatches force a fresh, verified
|
||||
/// download via `install_managed()`.
|
||||
///
|
||||
/// A managed install is only "usable" when both `node` and `npm` launchers
|
||||
/// are present. `build_resolved` only hard-fails on missing `node`, so we
|
||||
/// re-check `npm_bin` here and return `None` on absence — forcing a fresh
|
||||
/// download via the normal resolve path. Without this, a corrupted cache
|
||||
/// (e.g. download interrupted after node was extracted but before npm)
|
||||
/// would be reused forever and `npm_exec` could never self-heal.
|
||||
fn probe_managed_install(
|
||||
install_dir: &Path,
|
||||
cache_root: &Path,
|
||||
target_version: &str,
|
||||
) -> Option<ResolvedNode> {
|
||||
if !install_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
// Canonicalise both sides so a symlink inside the install can't smuggle
|
||||
// a repo-controlled tree past the `starts_with` check. `cache_root` must
|
||||
// exist because the caller created `install_dir` under it, but be
|
||||
// defensive: treat a failed canonicalize as "not trustworthy".
|
||||
let canon_install = match std::fs::canonicalize(install_dir) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
install_dir = %install_dir.display(),
|
||||
error = %err,
|
||||
"[node_runtime::bootstrap] canonicalize(install_dir) failed; treating as unusable"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let canon_cache = match std::fs::canonicalize(cache_root) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
cache_root = %cache_root.display(),
|
||||
error = %err,
|
||||
"[node_runtime::bootstrap] canonicalize(cache_root) failed; treating managed install as unusable"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !canon_install.starts_with(&canon_cache) {
|
||||
tracing::warn!(
|
||||
install_dir = %canon_install.display(),
|
||||
cache_root = %canon_cache.display(),
|
||||
"[node_runtime::bootstrap] refusing to reuse managed install outside the resolved cache root (possible spoof)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let bin_dir = managed_bin_dir(install_dir);
|
||||
let version = target_version.trim_start_matches('v').to_string();
|
||||
let resolved = build_resolved(bin_dir, version, NodeSource::Managed).ok()?;
|
||||
if !resolved.npm_bin.is_file() {
|
||||
tracing::warn!(
|
||||
npm_bin = %resolved.npm_bin.display(),
|
||||
"[node_runtime::bootstrap] managed install missing npm; forcing reinstall"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(resolved)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Node.js distribution downloader with SHASUMS256 verification.
|
||||
//!
|
||||
//! Resolves the right archive for the current OS/arch off nodejs.org,
|
||||
//! streams it to a caller-supplied temp path, and validates the SHA-256
|
||||
//! against the official `SHASUMS256.txt` for the release. Keeps everything
|
||||
//! in one place so the bootstrap caller only needs to know "download this
|
||||
//! version, give me the bytes on disk".
|
||||
//!
|
||||
//! ## Security
|
||||
//!
|
||||
//! We **require** a SHA-256 match before returning success — a corrupted or
|
||||
//! tampered archive is treated the same as a failed download and the file
|
||||
//! is deleted. There is no opt-out; skills will run untrusted code inside
|
||||
//! the resolved Node runtime, so the integrity check is load-bearing.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Base URL for official Node.js release artifacts.
|
||||
const NODEJS_DIST_BASE: &str = "https://nodejs.org/dist";
|
||||
|
||||
/// Describes a single downloadable Node.js distribution for the host triple.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeDistribution {
|
||||
/// Version string including the leading `v` (e.g. `v22.11.0`).
|
||||
pub version: String,
|
||||
/// Archive filename as it appears in `SHASUMS256.txt`
|
||||
/// (e.g. `node-v22.11.0-darwin-arm64.tar.xz`).
|
||||
pub archive_name: String,
|
||||
/// Full download URL.
|
||||
pub url: String,
|
||||
/// Whether the archive is a zip (Windows) or tar.xz (everything else).
|
||||
/// Drives which extraction path the caller invokes.
|
||||
pub is_zip: bool,
|
||||
}
|
||||
|
||||
impl NodeDistribution {
|
||||
/// Build the distribution descriptor for the current host OS/arch.
|
||||
///
|
||||
/// Supported triples mirror the officially-prebuilt Node.js binaries:
|
||||
///
|
||||
/// | OS | Arch | Archive suffix |
|
||||
/// |----------|--------------------------------------|-----------------------------|
|
||||
/// | macOS | aarch64, x86_64 | `-darwin-{arm64,x64}.tar.xz`|
|
||||
/// | Linux | aarch64, x86_64, arm, armv7 | `-linux-{arm64,x64,armv7l}.tar.xz` |
|
||||
/// | Windows | aarch64, x86_64 | `-win-{arm64,x64}.zip` |
|
||||
///
|
||||
/// Everything else yields an error — the caller should surface it as a
|
||||
/// "Node runtime unavailable on this host" message.
|
||||
pub fn for_host(version: &str) -> Result<Self> {
|
||||
let version = normalize_version(version);
|
||||
let (suffix, is_zip) = host_archive_suffix()?;
|
||||
let archive_name = format!("node-{version}-{suffix}");
|
||||
let url = format!("{NODEJS_DIST_BASE}/{version}/{archive_name}");
|
||||
tracing::debug!(
|
||||
version = %version,
|
||||
url = %url,
|
||||
"[node_runtime::downloader] resolved distribution for host"
|
||||
);
|
||||
Ok(Self {
|
||||
version,
|
||||
archive_name,
|
||||
url,
|
||||
is_zip,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise a version string to the canonical `vX.Y.Z` form used by
|
||||
/// nodejs.org. Config allows `22.11.0` or `v22.11.0`; we always emit the
|
||||
/// `v`-prefixed variant because it is what appears in the URL path.
|
||||
fn normalize_version(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.starts_with('v') {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("v{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Return `(archive_suffix, is_zip)` for the current host. The suffix omits
|
||||
/// the `node-vX.Y.Z-` prefix because callers always interpolate the version.
|
||||
fn host_archive_suffix() -> Result<(&'static str, bool)> {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
match (os, arch) {
|
||||
("macos", "aarch64") => Ok(("darwin-arm64.tar.xz", false)),
|
||||
("macos", "x86_64") => Ok(("darwin-x64.tar.xz", false)),
|
||||
("linux", "aarch64") => Ok(("linux-arm64.tar.xz", false)),
|
||||
("linux", "x86_64") => Ok(("linux-x64.tar.xz", false)),
|
||||
("linux", "arm") | ("linux", "armv7") => Ok(("linux-armv7l.tar.xz", false)),
|
||||
("windows", "aarch64") => Ok(("win-arm64.zip", true)),
|
||||
("windows", "x86_64") => Ok(("win-x64.zip", true)),
|
||||
_ => Err(anyhow!(
|
||||
"no prebuilt Node.js distribution for host {os}/{arch} — set node.enabled=false or install node manually"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `SHASUMS256.txt` for the release and return a
|
||||
/// `archive_name -> sha256_hex` map. The hex digest is lowercase.
|
||||
pub async fn fetch_shasums(client: &Client, version: &str) -> Result<HashMap<String, String>> {
|
||||
let version = normalize_version(version);
|
||||
let url = format!("{NODEJS_DIST_BASE}/{version}/SHASUMS256.txt");
|
||||
tracing::debug!(url = %url, "[node_runtime::downloader] fetching SHASUMS256.txt");
|
||||
|
||||
let body = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("non-success status on {url}"))?
|
||||
.text()
|
||||
.await
|
||||
.with_context(|| format!("reading body of {url}"))?;
|
||||
|
||||
let map = parse_shasums(&body);
|
||||
tracing::debug!(
|
||||
entries = map.len(),
|
||||
"[node_runtime::downloader] parsed SHASUMS256.txt"
|
||||
);
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Parse the `SHASUMS256.txt` body into a lookup table. The format is one
|
||||
/// entry per line: `<hex-sha256> <filename>` (two spaces). Unknown / blank
|
||||
/// lines are skipped to be robust against trailing newlines or signature
|
||||
/// blocks that may appear in future releases.
|
||||
fn parse_shasums(body: &str) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
for line in body.lines() {
|
||||
let mut parts = line.split_whitespace();
|
||||
let (Some(hash), Some(name)) = (parts.next(), parts.next()) else {
|
||||
continue;
|
||||
};
|
||||
if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
out.insert(name.to_string(), hash.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Stream `dist.url` to `target_path`, computing the SHA-256 on the fly and
|
||||
/// comparing against the digest supplied in `expected_sha256`.
|
||||
///
|
||||
/// On mismatch or any I/O error the partial file at `target_path` is
|
||||
/// removed — we never leave half-written / tampered archives on disk.
|
||||
pub async fn download_distribution(
|
||||
client: &Client,
|
||||
dist: &NodeDistribution,
|
||||
target_path: &Path,
|
||||
expected_sha256: &str,
|
||||
) -> Result<()> {
|
||||
tracing::info!(
|
||||
url = %dist.url,
|
||||
target = %target_path.display(),
|
||||
"[node_runtime::downloader] starting download"
|
||||
);
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("creating cache dir {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut response = client
|
||||
.get(&dist.url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {}", dist.url))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("non-success status on {}", dist.url))?;
|
||||
|
||||
let total_bytes = response.content_length();
|
||||
let mut file = File::create(target_path)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", target_path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut written: u64 = 0;
|
||||
|
||||
// Stream into `file`. On any chunk / write / flush failure we remove
|
||||
// the partial file on disk so a retry starts clean and callers never
|
||||
// see a half-written archive.
|
||||
let stream_result: Result<()> = async {
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.with_context(|| format!("streaming {}", dist.url))?
|
||||
{
|
||||
hasher.update(&chunk);
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.with_context(|| format!("writing chunk to {}", target_path.display()))?;
|
||||
written = written.saturating_add(chunk.len() as u64);
|
||||
}
|
||||
file.flush()
|
||||
.await
|
||||
.with_context(|| format!("flushing {}", target_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
drop(file);
|
||||
|
||||
if let Err(err) = stream_result {
|
||||
tracing::warn!(
|
||||
target = %target_path.display(),
|
||||
error = %err,
|
||||
"[node_runtime::downloader] streaming failed — removing partial archive"
|
||||
);
|
||||
let _ = tokio::fs::remove_file(target_path).await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let actual_hex = hex::encode(hasher.finalize());
|
||||
let expected = expected_sha256.trim().to_ascii_lowercase();
|
||||
|
||||
if actual_hex != expected {
|
||||
tracing::error!(
|
||||
expected = %expected,
|
||||
actual = %actual_hex,
|
||||
target = %target_path.display(),
|
||||
"[node_runtime::downloader] SHA-256 mismatch — deleting partial archive"
|
||||
);
|
||||
let _ = tokio::fs::remove_file(target_path).await;
|
||||
bail!(
|
||||
"SHA-256 mismatch for {} (expected {expected}, got {actual_hex})",
|
||||
dist.archive_name
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target = %target_path.display(),
|
||||
bytes = written,
|
||||
total = ?total_bytes,
|
||||
"[node_runtime::downloader] download complete, hash verified"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_version_with_and_without_prefix() {
|
||||
assert_eq!(normalize_version("22.11.0"), "v22.11.0");
|
||||
assert_eq!(normalize_version("v22.11.0"), "v22.11.0");
|
||||
assert_eq!(normalize_version(" v22.11.0\n"), "v22.11.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_shasums_text() {
|
||||
let body = "\
|
||||
abc123def4567890abc123def4567890abc123def4567890abc123def4567890 node-v22.11.0-darwin-arm64.tar.xz
|
||||
1111222233334444555566667777888899990000111122223333444455556666 node-v22.11.0-linux-x64.tar.xz
|
||||
garbage line
|
||||
BADHASHNOTHEX node-v22.11.0-win-x64.zip
|
||||
";
|
||||
let map = parse_shasums(body);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(
|
||||
map.get("node-v22.11.0-darwin-arm64.tar.xz").unwrap(),
|
||||
"abc123def4567890abc123def4567890abc123def4567890abc123def4567890"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribution_for_host_returns_sensible_url() {
|
||||
let dist = NodeDistribution::for_host("v22.11.0").expect("host supported in CI");
|
||||
assert!(dist.url.starts_with("https://nodejs.org/dist/v22.11.0/"));
|
||||
assert!(dist.archive_name.starts_with("node-v22.11.0-"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Archive extraction for downloaded Node.js distributions.
|
||||
//!
|
||||
//! Handles both shapes that nodejs.org ships:
|
||||
//!
|
||||
//! * `.tar.xz` on macOS and Linux — decoded via `xz2` then unpacked through
|
||||
//! the `tar` crate.
|
||||
//! * `.zip` on Windows — unpacked through the `zip` crate.
|
||||
//!
|
||||
//! All archives are "single-rooted": they expand into one top-level folder
|
||||
//! like `node-v22.11.0-darwin-arm64/`. We extract into a caller-supplied
|
||||
//! staging directory, then return the absolute path of that inner folder so
|
||||
//! the bootstrap layer can rename/move it into the cache atomically.
|
||||
//!
|
||||
//! Extraction is CPU/IO-bound and the underlying crates are synchronous, so
|
||||
//! we wrap the real work in `tokio::task::spawn_blocking` to keep the
|
||||
//! runtime responsive.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::fs::{self, File};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Extract `archive` into `extract_root` and return the absolute path of the
|
||||
/// single top-level folder produced by the archive.
|
||||
///
|
||||
/// `is_zip = true` selects the zip path, otherwise the tar.xz path runs.
|
||||
/// On any error the caller should treat `extract_root` as contaminated and
|
||||
/// remove it before retrying — we do not auto-clean because the caller
|
||||
/// typically owns a fresh temp dir.
|
||||
pub async fn extract_distribution(
|
||||
archive: &Path,
|
||||
extract_root: &Path,
|
||||
is_zip: bool,
|
||||
) -> Result<PathBuf> {
|
||||
let archive = archive.to_path_buf();
|
||||
let extract_root = extract_root.to_path_buf();
|
||||
|
||||
tracing::info!(
|
||||
archive = %archive.display(),
|
||||
extract_root = %extract_root.display(),
|
||||
is_zip,
|
||||
"[node_runtime::extractor] starting extraction"
|
||||
);
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
|
||||
fs::create_dir_all(&extract_root)
|
||||
.with_context(|| format!("creating extract root {}", extract_root.display()))?;
|
||||
|
||||
if is_zip {
|
||||
extract_zip(&archive, &extract_root)?;
|
||||
} else {
|
||||
extract_tar_xz(&archive, &extract_root)?;
|
||||
}
|
||||
|
||||
let top_level = find_single_top_level(&extract_root)?;
|
||||
tracing::info!(
|
||||
top_level = %top_level.display(),
|
||||
"[node_runtime::extractor] extraction complete"
|
||||
);
|
||||
Ok(top_level)
|
||||
})
|
||||
.await
|
||||
.context("spawn_blocking join failure during extraction")?
|
||||
}
|
||||
|
||||
/// Extract a `.tar.xz` archive into `extract_root`.
|
||||
fn extract_tar_xz(archive: &Path, extract_root: &Path) -> Result<()> {
|
||||
let file =
|
||||
File::open(archive).with_context(|| format!("opening archive {}", archive.display()))?;
|
||||
let decoder = xz2::read::XzDecoder::new(file);
|
||||
let mut tar = tar::Archive::new(decoder);
|
||||
// `set_preserve_permissions(true)` is the default on Unix; we restate
|
||||
// it so the `node` binary keeps its `+x` bit after extraction.
|
||||
tar.set_preserve_permissions(true);
|
||||
tar.set_overwrite(true);
|
||||
tar.unpack(extract_root)
|
||||
.with_context(|| format!("unpacking tar.xz into {}", extract_root.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract a `.zip` archive into `extract_root`. Handles directory entries,
|
||||
/// file entries, and restores Unix mode bits where present (no-op on
|
||||
/// Windows hosts, which is where `.zip` actually matters).
|
||||
fn extract_zip(archive: &Path, extract_root: &Path) -> Result<()> {
|
||||
let file =
|
||||
File::open(archive).with_context(|| format!("opening archive {}", archive.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file)
|
||||
.with_context(|| format!("opening zip archive {}", archive.display()))?;
|
||||
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip
|
||||
.by_index(i)
|
||||
.with_context(|| format!("reading zip entry {i}"))?;
|
||||
let Some(relative) = entry.enclosed_name() else {
|
||||
tracing::warn!(
|
||||
name = entry.name(),
|
||||
"[node_runtime::extractor] skipping zip entry with unsafe path"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let out_path = extract_root.join(relative);
|
||||
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out_path)
|
||||
.with_context(|| format!("creating {}", out_path.display()))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let mut out = File::create(&out_path)
|
||||
.with_context(|| format!("creating {}", out_path.display()))?;
|
||||
io::copy(&mut entry, &mut out)
|
||||
.with_context(|| format!("writing {}", out_path.display()))?;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(mode) = entry.unix_mode() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))
|
||||
.with_context(|| format!("chmod {}", out_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate the single top-level directory inside `extract_root`. Node.js
|
||||
/// archives always produce one root folder; anything else (multiple
|
||||
/// entries, only files) is a contract violation from our side and we
|
||||
/// surface it as an error rather than guessing.
|
||||
fn find_single_top_level(extract_root: &Path) -> Result<PathBuf> {
|
||||
let mut entries = fs::read_dir(extract_root)
|
||||
.with_context(|| format!("listing {}", extract_root.display()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.with_context(|| format!("reading entries of {}", extract_root.display()))?;
|
||||
|
||||
// Stable order for deterministic logging.
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
let mut dirs: Vec<PathBuf> = entries
|
||||
.into_iter()
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
|
||||
match dirs.len() {
|
||||
1 => Ok(dirs.pop().unwrap()),
|
||||
0 => Err(anyhow!(
|
||||
"expected one top-level folder under {}, found none",
|
||||
extract_root.display()
|
||||
)),
|
||||
n => Err(anyhow!(
|
||||
"expected one top-level folder under {}, found {n}: {:?}",
|
||||
extract_root.display(),
|
||||
dirs
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically move `staged` into place at `final_dest`.
|
||||
///
|
||||
/// Strategy:
|
||||
/// 1. If `final_dest` already exists, move it to a sibling `.old-<pid>`
|
||||
/// path so we never lose a working install even if a later step fails.
|
||||
/// 2. Rename `staged` -> `final_dest`. On the same filesystem this is a
|
||||
/// single `rename(2)` and is atomic from the reader's perspective.
|
||||
/// 3. Best-effort cleanup of the `.old-*` directory.
|
||||
///
|
||||
/// Returns the `final_dest` path on success.
|
||||
pub async fn atomic_install(staged: &Path, final_dest: &Path) -> Result<PathBuf> {
|
||||
let staged = staged.to_path_buf();
|
||||
let final_dest = final_dest.to_path_buf();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
|
||||
if let Some(parent) = final_dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating parent {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut backup: Option<PathBuf> = None;
|
||||
if final_dest.exists() {
|
||||
let ts = std::process::id();
|
||||
let candidate = final_dest.with_extension(format!("old-{ts}"));
|
||||
fs::rename(&final_dest, &candidate).with_context(|| {
|
||||
format!(
|
||||
"moving existing install {} aside to {}",
|
||||
final_dest.display(),
|
||||
candidate.display()
|
||||
)
|
||||
})?;
|
||||
backup = Some(candidate);
|
||||
}
|
||||
|
||||
if let Err(err) = fs::rename(&staged, &final_dest).with_context(|| {
|
||||
format!(
|
||||
"renaming staged {} -> {}",
|
||||
staged.display(),
|
||||
final_dest.display()
|
||||
)
|
||||
}) {
|
||||
// Stage->final rename failed; restore the previous install from
|
||||
// backup so the working runtime stays in place. Surface any
|
||||
// restore failure separately (as a warning) but always return
|
||||
// the original error.
|
||||
if let Some(backup_path) = backup.as_ref() {
|
||||
if let Err(restore_err) = fs::rename(backup_path, &final_dest) {
|
||||
tracing::warn!(
|
||||
backup = %backup_path.display(),
|
||||
final_dest = %final_dest.display(),
|
||||
error = %restore_err,
|
||||
"[node_runtime::extractor] failed to restore backup after staged rename failure"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
final_dest = %final_dest.display(),
|
||||
"[node_runtime::extractor] restored previous install after staged rename failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Some(path) = backup {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
tracing::info!(
|
||||
final_dest = %final_dest.display(),
|
||||
"[node_runtime::extractor] atomic install complete"
|
||||
);
|
||||
Ok(final_dest)
|
||||
})
|
||||
.await
|
||||
.context("spawn_blocking join failure during atomic install")?
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Managed Node.js runtime for skills that require `node` / `npm`.
|
||||
//!
|
||||
//! Responsibilities are split across submodules:
|
||||
//!
|
||||
//! * [`resolver`] — detect a compatible system `node` on `PATH`. Cheap,
|
||||
//! synchronous, called first so we can skip the download path when a
|
||||
//! matching toolchain already exists on the host.
|
||||
//!
|
||||
//! Later commits layer on a downloader, archive extractor, cache manager,
|
||||
//! and a bootstrap entry point that returns the resolved `node`/`npm`
|
||||
//! binary paths for `node_exec` / `npm_exec` tools.
|
||||
|
||||
pub mod bootstrap;
|
||||
pub mod downloader;
|
||||
pub mod extractor;
|
||||
pub mod resolver;
|
||||
|
||||
pub use bootstrap::{NodeBootstrap, NodeSource, ResolvedNode};
|
||||
pub use downloader::{download_distribution, fetch_shasums, NodeDistribution};
|
||||
pub use extractor::{atomic_install, extract_distribution};
|
||||
pub use resolver::{detect_system_node, parse_node_version, SystemNode};
|
||||
@@ -0,0 +1,290 @@
|
||||
//! System-node resolver.
|
||||
//!
|
||||
//! Walks `PATH`, probes `node --version`, and returns a [`SystemNode`] when
|
||||
//! the host-installed binary matches the configured target major version.
|
||||
//! Runs synchronously because it blocks on one short-lived subprocess and is
|
||||
//! called exactly once per bootstrap — pushing it onto the Tokio runtime
|
||||
//! would add noise without benefit.
|
||||
//!
|
||||
//! Target-version matching is intentionally loose: we only compare **major**
|
||||
//! versions. Point releases of Node.js are ABI-stable, and skills pin their
|
||||
//! own dependency versions via `package.json` / `package-lock.json`, so a
|
||||
//! host `v22.8.0` is accepted when `node.version = "v22.11.0"`. If a user
|
||||
//! needs strict pinning they can set `node.prefer_system = false`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A usable Node.js toolchain discovered on the host `PATH`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemNode {
|
||||
/// Absolute path to the `node` executable.
|
||||
pub path: PathBuf,
|
||||
/// Parsed major version (e.g. `22`).
|
||||
pub major: u32,
|
||||
/// Raw version string reported by `node --version`, trimmed of the
|
||||
/// leading `v` and trailing whitespace (e.g. `"22.11.0"`).
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Parse a version string like `v22.11.0` / `22.11.0` / `v22` and return the
|
||||
/// numeric major component.
|
||||
///
|
||||
/// Returns `None` when the input is malformed. Tolerant of surrounding
|
||||
/// whitespace and an optional leading `v` prefix so it can accept both the
|
||||
/// config value (`node.version = "v22.11.0"`) and the raw `node --version`
|
||||
/// output (`v22.11.0\n`).
|
||||
pub fn parse_node_version(raw: &str) -> Option<u32> {
|
||||
let trimmed = raw.trim();
|
||||
let stripped = trimmed.strip_prefix('v').unwrap_or(trimmed);
|
||||
let major = stripped.split('.').next()?;
|
||||
major.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// Probe the host for a `node` binary on `PATH` whose major version matches
|
||||
/// `target_version`. Returns `Some(SystemNode)` on success, `None` when no
|
||||
/// compatible toolchain is found.
|
||||
///
|
||||
/// Heavy tracing is intentional — resolver decisions drive whether we skip a
|
||||
/// multi-hundred-MB download, so operators need a clear breadcrumb trail.
|
||||
pub fn detect_system_node(target_version: &str) -> Option<SystemNode> {
|
||||
let Some(target_major) = parse_node_version(target_version) else {
|
||||
tracing::warn!(
|
||||
target_version,
|
||||
"[node_runtime::resolver] invalid target_version, skipping system-node probe"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(path) = which_node() else {
|
||||
tracing::debug!(
|
||||
"[node_runtime::resolver] no `node` found on PATH — will fall back to download"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
path = %path.display(),
|
||||
target_major,
|
||||
"[node_runtime::resolver] probing system node"
|
||||
);
|
||||
|
||||
let Some(version) = probe_node_version(&path) else {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"[node_runtime::resolver] `node --version` failed; treating as unavailable"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(host_major) = parse_node_version(&version) else {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
version = %version,
|
||||
"[node_runtime::resolver] could not parse `node --version` output"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
if host_major != target_major {
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
host_major,
|
||||
target_major,
|
||||
"[node_runtime::resolver] host node major mismatch — will download managed runtime"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// `npm_exec` rides on the same resolved toolchain. On distros that
|
||||
// package `nodejs` and `npm` separately (Debian/Ubuntu default,
|
||||
// Alpine's `nodejs-current`, some NixOS setups) the `node` binary can
|
||||
// be present without `npm`. If we cached `NodeSource::System` here
|
||||
// every `npm_exec` call would break with an obscure error. Require a
|
||||
// usable `npm --version` probe before accepting the system toolchain;
|
||||
// on failure, return `None` so the managed download path takes over.
|
||||
let Some(npm_path) = which_npm() else {
|
||||
tracing::info!(
|
||||
node_path = %path.display(),
|
||||
"[node_runtime::resolver] compatible system node found but `npm` is missing on PATH — falling back to managed runtime"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
if probe_subcommand_version(&npm_path, "npm").is_none() {
|
||||
tracing::warn!(
|
||||
npm_path = %npm_path.display(),
|
||||
"[node_runtime::resolver] `npm --version` failed; falling back to managed runtime"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let normalized = version.trim_start_matches('v').trim().to_string();
|
||||
tracing::info!(
|
||||
path = %path.display(),
|
||||
npm_path = %npm_path.display(),
|
||||
version = %normalized,
|
||||
"[node_runtime::resolver] reusing compatible system node (npm verified)"
|
||||
);
|
||||
Some(SystemNode {
|
||||
path,
|
||||
major: host_major,
|
||||
version: normalized,
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate a `node` binary on `PATH`. Cross-platform: appends the host
|
||||
/// executable suffix (`.exe` on Windows) so callers receive a path that can
|
||||
/// be invoked directly.
|
||||
///
|
||||
/// Unix command lookup skips non-executable entries. A non-executable
|
||||
/// placeholder earlier in `PATH` (e.g. an unprivileged `node` shim left by
|
||||
/// a failed install) would otherwise mask a valid later install and force
|
||||
/// the managed runtime download. We mirror the shell behaviour by checking
|
||||
/// the execute bit before returning.
|
||||
fn which_node() -> Option<PathBuf> {
|
||||
let exe_name = format!("node{}", std::env::consts::EXE_SUFFIX);
|
||||
which_exe(&exe_name)
|
||||
}
|
||||
|
||||
/// Locate an `npm` binary on `PATH`. Applies the same execute-bit filter
|
||||
/// as [`which_node`]. On Windows we look for `npm.cmd` first (the official
|
||||
/// installer ships a batch shim; there is no `npm.exe`) and fall back to
|
||||
/// `npm` for unusual setups that expose a bare binary.
|
||||
fn which_npm() -> Option<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(p) = which_exe("npm.cmd") {
|
||||
return Some(p);
|
||||
}
|
||||
which_exe("npm")
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
which_exe("npm")
|
||||
}
|
||||
}
|
||||
|
||||
/// `PATH` search helper shared by `which_node` / `which_npm`. Applies the
|
||||
/// platform-specific executability check so a non-executable placeholder
|
||||
/// earlier in `PATH` doesn't shadow a valid later entry.
|
||||
fn which_exe(exe_name: &str) -> Option<PathBuf> {
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
let candidate = dir.join(exe_name);
|
||||
if is_executable_candidate(&candidate) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_executable_candidate(path: &std::path::Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(path)
|
||||
.map(|meta| meta.is_file() && (meta.permissions().mode() & 0o111 != 0))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_executable_candidate(path: &std::path::Path) -> bool {
|
||||
// On Windows, the `.exe` suffix already encodes executability for the
|
||||
// loader; any regular file matching `node.exe` is a valid candidate.
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
/// Invoke `<path> --version` with a real 5-second timeout and return the raw
|
||||
/// version string on success. The timeout guards against a broken shim on
|
||||
/// `PATH` hanging the bootstrap indefinitely.
|
||||
fn probe_node_version(path: &std::path::Path) -> Option<String> {
|
||||
probe_subcommand_version(path, "node")
|
||||
}
|
||||
|
||||
/// Same semantics as [`probe_node_version`], but usable for arbitrary
|
||||
/// toolchain binaries. `label` is only used for log attribution.
|
||||
fn probe_subcommand_version(path: &std::path::Path, label: &str) -> Option<String> {
|
||||
use std::io::Read;
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
let mut child = Command::new(path)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let timeout = Duration::from_secs(5);
|
||||
let status = match child.wait_timeout(timeout).ok()? {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
label,
|
||||
timeout_secs = 5,
|
||||
"[node_runtime::resolver] `<bin> --version` timed out; killing process"
|
||||
);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
let mut stderr_buf = String::new();
|
||||
if let Some(mut s) = child.stderr.take() {
|
||||
let _ = s.read_to_string(&mut stderr_buf);
|
||||
}
|
||||
tracing::debug!(
|
||||
status = ?status,
|
||||
label,
|
||||
stderr = %stderr_buf,
|
||||
"[node_runtime::resolver] `<bin> --version` exited non-zero"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut stdout_buf = String::new();
|
||||
if let Some(mut s) = child.stdout.take() {
|
||||
let _ = s.read_to_string(&mut stdout_buf);
|
||||
}
|
||||
let trimmed = stdout_buf.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_version_with_v_prefix() {
|
||||
assert_eq!(parse_node_version("v22.11.0"), Some(22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_without_v_prefix() {
|
||||
assert_eq!(parse_node_version("22.11.0"), Some(22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_major_only() {
|
||||
assert_eq!(parse_node_version("v22"), Some(22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_surrounding_whitespace() {
|
||||
assert_eq!(parse_node_version(" v22.11.0\n"), Some(22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage() {
|
||||
assert_eq!(parse_node_version("not-a-version"), None);
|
||||
assert_eq!(parse_node_version(""), None);
|
||||
assert_eq!(parse_node_version("v"), None);
|
||||
}
|
||||
}
|
||||
+913
-146
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
mod current_time;
|
||||
mod insert_sql_record;
|
||||
mod node_exec;
|
||||
mod npm_exec;
|
||||
mod proxy_config;
|
||||
mod pushover;
|
||||
mod schedule;
|
||||
@@ -9,6 +11,8 @@ mod workspace_state;
|
||||
|
||||
pub use current_time::CurrentTimeTool;
|
||||
pub use insert_sql_record::InsertSqlRecordTool;
|
||||
pub use node_exec::NodeExecTool;
|
||||
pub use npm_exec::NpmExecTool;
|
||||
pub use proxy_config::ProxyConfigTool;
|
||||
pub use pushover::PushoverTool;
|
||||
pub use schedule::ScheduleTool;
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
//! `node_exec` — execute JavaScript via the managed (or system) Node.js
|
||||
//! toolchain.
|
||||
//!
|
||||
//! Sibling to [`crate::openhuman::tools::impl::system::shell::ShellTool`]: same
|
||||
//! security gates, same env hygiene, but the command is pinned to the `node`
|
||||
//! binary resolved by
|
||||
//! [`crate::openhuman::node_runtime::NodeBootstrap`].
|
||||
//!
|
||||
//! Two input modes:
|
||||
//!
|
||||
//! | Mode | Params | Resulting invocation |
|
||||
//! |---------------|------------------------------------------|-------------------------------------|
|
||||
//! | Inline code | `inline_code: "console.log(1+1)"` | `node -e '<code>'` |
|
||||
//! | Script path | `script_path: "scripts/run.js"`, `args` | `node <path> <args...>` |
|
||||
//!
|
||||
//! Exactly one of `inline_code` / `script_path` must be supplied. Scripts are
|
||||
//! resolved relative to the workspace; paths escaping the workspace are
|
||||
//! rejected by the filesystem helpers.
|
||||
//!
|
||||
//! The bootstrap is resolved **on first invocation**, which will download +
|
||||
//! extract a managed Node.js distribution if no compatible `node` is on
|
||||
//! `PATH`. Subsequent calls reuse the cached install.
|
||||
|
||||
use crate::openhuman::agent::host_runtime::RuntimeAdapter;
|
||||
use crate::openhuman::node_runtime::NodeBootstrap;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
use crate::openhuman::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Maximum node process wall-clock before we kill it. Longer than the shell
|
||||
/// tool because `npm install` / bundler steps can legitimately exceed 60s,
|
||||
/// and `node_exec` is often the launcher for those flows.
|
||||
const NODE_TIMEOUT_SECS: u64 = 300;
|
||||
/// Maximum combined stdout/stderr size (1 MB each) — same cap as shell.
|
||||
const MAX_OUTPUT_BYTES: usize = 1_048_576;
|
||||
/// Env allow-list for child processes. Matches shell.rs — secrets never leak
|
||||
/// into spawned node processes. `PATH` gets a prepend of the managed bin
|
||||
/// dir before being forwarded.
|
||||
const SAFE_ENV_VARS: &[&str] = &[
|
||||
"HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
|
||||
];
|
||||
|
||||
/// `node_exec` — execute JavaScript through the resolved Node.js runtime.
|
||||
pub struct NodeExecTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
bootstrap: Arc<NodeBootstrap>,
|
||||
}
|
||||
|
||||
impl NodeExecTool {
|
||||
pub fn new(
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
bootstrap: Arc<NodeBootstrap>,
|
||||
) -> Self {
|
||||
Self {
|
||||
security,
|
||||
runtime,
|
||||
bootstrap,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for NodeExecTool {
|
||||
fn name(&self) -> &str {
|
||||
"node_exec"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute JavaScript through Node.js. Pass either `inline_code` (runs via `node -e`) or `script_path` (runs a file in the workspace). Optional `args` forwards positional arguments to the script."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inline_code": {
|
||||
"type": "string",
|
||||
"description": "JavaScript source passed to `node -e`. Mutually exclusive with script_path."
|
||||
},
|
||||
"script_path": {
|
||||
"type": "string",
|
||||
"description": "Path (relative to workspace) to a .js/.mjs/.cjs file. Mutually exclusive with inline_code."
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Positional arguments appended after the script. Ignored for inline_code."
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
"description": "Optional override for the default 300s timeout. Capped at 1800s."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let inline_code = args
|
||||
.get("inline_code")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let script_path = args
|
||||
.get("script_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
let extra_args: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let timeout_secs = args
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(NODE_TIMEOUT_SECS)
|
||||
.min(1800);
|
||||
|
||||
if inline_code.is_some() == script_path.is_some() {
|
||||
return Ok(ToolResult::error(
|
||||
"node_exec requires exactly one of `inline_code` or `script_path`",
|
||||
));
|
||||
}
|
||||
|
||||
if self.security.is_rate_limited() {
|
||||
return Ok(ToolResult::error(
|
||||
"Rate limit exceeded: too many actions in the last hour",
|
||||
));
|
||||
}
|
||||
if !self.security.record_action() {
|
||||
return Ok(ToolResult::error(
|
||||
"Rate limit exceeded: action budget exhausted",
|
||||
));
|
||||
}
|
||||
|
||||
let resolved = match self.bootstrap.resolve().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "[node_exec] failed to resolve node runtime");
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Node.js runtime unavailable: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
version = %resolved.version,
|
||||
source = ?resolved.source,
|
||||
node_bin = %resolved.node_bin.display(),
|
||||
"[node_exec] starting invocation"
|
||||
);
|
||||
|
||||
let command = if let Some(code) = inline_code.as_deref() {
|
||||
format!(
|
||||
"{} -e {}",
|
||||
shell_quote(&resolved.node_bin.to_string_lossy()),
|
||||
shell_quote(code)
|
||||
)
|
||||
} else if let Some(path) = script_path.as_deref() {
|
||||
let resolved_script = match resolve_script_path(&self.security.workspace_dir, path) {
|
||||
Ok(p) => p,
|
||||
Err(msg) => return Ok(ToolResult::error(msg)),
|
||||
};
|
||||
let mut parts: Vec<String> = Vec::with_capacity(extra_args.len() + 2);
|
||||
parts.push(shell_quote(&resolved.node_bin.to_string_lossy()));
|
||||
parts.push(shell_quote(&resolved_script.to_string_lossy()));
|
||||
// `extra_args` are opaque positional arguments forwarded to the
|
||||
// script. They are shell-quoted below so no shell metacharacter
|
||||
// can escape, but we do NOT treat them as workspace paths — the
|
||||
// script itself is responsible for any path validation it does
|
||||
// on its own arguments.
|
||||
for a in &extra_args {
|
||||
parts.push(shell_quote(a));
|
||||
}
|
||||
parts.join(" ")
|
||||
} else {
|
||||
unreachable!("guarded above")
|
||||
};
|
||||
|
||||
let mut cmd = match self
|
||||
.runtime
|
||||
.build_shell_command(&command, &self.security.workspace_dir)
|
||||
{
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Failed to build runtime command: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
cmd.env_clear();
|
||||
|
||||
let host_path = std::env::var("PATH").unwrap_or_default();
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let prepended_path = if host_path.is_empty() {
|
||||
resolved.bin_dir.to_string_lossy().into_owned()
|
||||
} else {
|
||||
format!("{}{}{}", resolved.bin_dir.display(), sep, host_path)
|
||||
};
|
||||
cmd.env("PATH", &prepended_path);
|
||||
|
||||
for var in SAFE_ENV_VARS {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if stdout.len() > MAX_OUTPUT_BYTES {
|
||||
stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES));
|
||||
stdout.push_str("\n... [stdout truncated at 1MB]");
|
||||
}
|
||||
if stderr.len() > MAX_OUTPUT_BYTES {
|
||||
stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES));
|
||||
stderr.push_str("\n... [stderr truncated at 1MB]");
|
||||
}
|
||||
|
||||
if output.status.success() {
|
||||
if stderr.is_empty() {
|
||||
Ok(ToolResult::success(stdout))
|
||||
} else {
|
||||
Ok(ToolResult::success(format!("{stdout}\n[stderr]\n{stderr}")))
|
||||
}
|
||||
} else {
|
||||
let err_msg = if stderr.is_empty() { stdout } else { stderr };
|
||||
Ok(ToolResult::error(err_msg))
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Ok(ToolResult::error(format!("Failed to execute node: {e}"))),
|
||||
Err(_) => Ok(ToolResult::error(format!(
|
||||
"node_exec timed out after {timeout_secs}s and was killed"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX-safe single-quote escaping. Wraps `s` in `'…'`, turning any embedded
|
||||
/// single-quote into the four-char sequence `'\''`. Node bin paths and user
|
||||
/// code pass through untouched semantically, but no shell metacharacter can
|
||||
/// escape the quoted string.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
let escaped = s.replace('\'', "'\\''");
|
||||
format!("'{escaped}'")
|
||||
}
|
||||
|
||||
/// Resolve a caller-supplied `script_path` against the workspace. Mirrors
|
||||
/// `npm_exec::resolve_cwd` — rejects absolute paths and any component that
|
||||
/// could escape the workspace (`..`, Windows drive prefixes). Scripts
|
||||
/// themselves must live inside the workspace.
|
||||
fn resolve_script_path(
|
||||
workspace: &std::path::Path,
|
||||
raw: &str,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("node_exec `script_path` cannot be empty".to_string());
|
||||
}
|
||||
let candidate = std::path::Path::new(raw);
|
||||
if candidate.is_absolute() {
|
||||
return Err(format!(
|
||||
"node_exec `script_path` must be relative to workspace; got absolute {raw:?}"
|
||||
));
|
||||
}
|
||||
if candidate.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
std::path::Component::ParentDir | std::path::Component::Prefix(_)
|
||||
)
|
||||
}) {
|
||||
return Err(format!(
|
||||
"node_exec `script_path` must not escape workspace; got {raw:?}"
|
||||
));
|
||||
}
|
||||
Ok(workspace.join(candidate))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shell_quote_wraps_plain_strings() {
|
||||
assert_eq!(shell_quote("node"), "'node'");
|
||||
assert_eq!(shell_quote("/opt/bin/node"), "'/opt/bin/node'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_escapes_single_quotes() {
|
||||
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||
assert_eq!(
|
||||
shell_quote("console.log('hi')"),
|
||||
"'console.log('\\''hi'\\'')'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_neutralises_metacharacters() {
|
||||
// $, backticks, && — all inert once wrapped in single quotes.
|
||||
assert_eq!(shell_quote("$(rm -rf /)"), "'$(rm -rf /)'");
|
||||
assert_eq!(shell_quote("a && b"), "'a && b'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_script_path_rejects_empty() {
|
||||
let ws = std::path::Path::new("/ws");
|
||||
assert!(resolve_script_path(ws, "").is_err());
|
||||
assert!(resolve_script_path(ws, " ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_script_path_rejects_absolute() {
|
||||
let ws = std::path::Path::new("/ws");
|
||||
assert!(resolve_script_path(ws, "/etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_script_path_rejects_parent_dir() {
|
||||
let ws = std::path::Path::new("/ws");
|
||||
assert!(resolve_script_path(ws, "../evil.js").is_err());
|
||||
assert!(resolve_script_path(ws, "scripts/../../evil.js").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_script_path_accepts_relative_subdir() {
|
||||
let ws = std::path::Path::new("/ws");
|
||||
let resolved = resolve_script_path(ws, "scripts/run.js").unwrap();
|
||||
assert_eq!(resolved, std::path::Path::new("/ws/scripts/run.js"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! `npm_exec` — invoke the npm CLI through the managed (or system) Node.js
|
||||
//! toolchain.
|
||||
//!
|
||||
//! Thin wrapper over `npm <subcommand> <args...>` that piggybacks on
|
||||
//! [`crate::openhuman::node_runtime::NodeBootstrap`] for binary resolution.
|
||||
//! Same security posture as
|
||||
//! [`crate::openhuman::tools::impl::system::shell::ShellTool`] and
|
||||
//! [`crate::openhuman::tools::impl::system::node_exec::NodeExecTool`]:
|
||||
//!
|
||||
//! * Host env is cleared before spawning; only functional vars (`HOME`,
|
||||
//! `TERM`, `LANG`, …) are forwarded.
|
||||
//! * `PATH` is rebuilt with the resolved bin dir prepended so `npm`'s own
|
||||
//! `node`/`corepack` lookups hit the managed toolchain first.
|
||||
//! * Rate limits + action budget tracking piggyback on `SecurityPolicy`.
|
||||
//!
|
||||
//! The `subcommand` parameter is required and cannot contain shell
|
||||
//! metacharacters (guarded server-side). Free-form args go through
|
||||
//! POSIX-safe single-quoting.
|
||||
|
||||
use crate::openhuman::agent::host_runtime::RuntimeAdapter;
|
||||
use crate::openhuman::node_runtime::NodeBootstrap;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
use crate::openhuman::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default wall-clock budget for an npm invocation. `npm install` on a cold
|
||||
/// cache can legitimately take several minutes on slow networks.
|
||||
const NPM_TIMEOUT_SECS: u64 = 600;
|
||||
/// Absolute ceiling callers can request via `timeout_secs`.
|
||||
const NPM_TIMEOUT_MAX_SECS: u64 = 1800;
|
||||
/// Output cap per stream (1 MB).
|
||||
const MAX_OUTPUT_BYTES: usize = 1_048_576;
|
||||
/// Env allow-list — matches the shell / node_exec tools.
|
||||
const SAFE_ENV_VARS: &[&str] = &[
|
||||
"HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
|
||||
];
|
||||
|
||||
/// Subcommands we outright refuse to run. These either break the managed
|
||||
/// cache (`uninstall` of tooling bundled with the install) or perform
|
||||
/// write actions outside the workspace (`publish` to a registry, `adduser`
|
||||
/// / `login` / `logout` which mutate `~/.npmrc`).
|
||||
const DISALLOWED_SUBCOMMANDS: &[&str] = &[
|
||||
"publish",
|
||||
"unpublish",
|
||||
"adduser",
|
||||
"login",
|
||||
"logout",
|
||||
"token",
|
||||
"star",
|
||||
"unstar",
|
||||
"owner",
|
||||
"access",
|
||||
"team",
|
||||
"hook",
|
||||
"profile",
|
||||
];
|
||||
|
||||
/// `npm_exec` — run npm subcommands (install, run, ci, test, …).
|
||||
pub struct NpmExecTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
bootstrap: Arc<NodeBootstrap>,
|
||||
}
|
||||
|
||||
impl NpmExecTool {
|
||||
pub fn new(
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
bootstrap: Arc<NodeBootstrap>,
|
||||
) -> Self {
|
||||
Self {
|
||||
security,
|
||||
runtime,
|
||||
bootstrap,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for NpmExecTool {
|
||||
fn name(&self) -> &str {
|
||||
"npm_exec"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Run an npm subcommand (install, ci, run, test, exec, …) in the workspace. Dangerous registry/auth commands (publish, login, adduser, token, …) are blocked."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subcommand": {
|
||||
"type": "string",
|
||||
"description": "npm subcommand, e.g. `install`, `ci`, `run`, `test`, `exec`."
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Arguments appended after the subcommand (e.g. [\"build\"] for `npm run build`)."
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"description": "Optional sub-directory (relative to workspace) to run npm in. Defaults to the workspace root."
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
"description": "Optional override for the default 600s timeout. Capped at 1800s."
|
||||
}
|
||||
},
|
||||
"required": ["subcommand"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let subcommand = match args.get("subcommand").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.trim().to_string(),
|
||||
None => {
|
||||
return Ok(ToolResult::error(
|
||||
"npm_exec requires a `subcommand` (e.g. install, ci, run).",
|
||||
));
|
||||
}
|
||||
};
|
||||
if subcommand.is_empty() {
|
||||
return Ok(ToolResult::error("npm_exec `subcommand` cannot be empty"));
|
||||
}
|
||||
if !is_sane_subcommand(&subcommand) {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"npm_exec rejected subcommand {subcommand:?}: only alphanumeric/._- characters allowed"
|
||||
)));
|
||||
}
|
||||
if DISALLOWED_SUBCOMMANDS
|
||||
.iter()
|
||||
.any(|d| d.eq_ignore_ascii_case(&subcommand))
|
||||
{
|
||||
return Ok(ToolResult::error(format!(
|
||||
"npm_exec refuses to run `npm {subcommand}` — registry/auth mutations are blocked"
|
||||
)));
|
||||
}
|
||||
|
||||
let extra_args: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let cwd_override = args.get("cwd").and_then(|v| v.as_str()).map(str::to_string);
|
||||
|
||||
let timeout_secs = args
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(NPM_TIMEOUT_SECS)
|
||||
.min(NPM_TIMEOUT_MAX_SECS);
|
||||
|
||||
if self.security.is_rate_limited() {
|
||||
return Ok(ToolResult::error(
|
||||
"Rate limit exceeded: too many actions in the last hour",
|
||||
));
|
||||
}
|
||||
if !self.security.record_action() {
|
||||
return Ok(ToolResult::error(
|
||||
"Rate limit exceeded: action budget exhausted",
|
||||
));
|
||||
}
|
||||
|
||||
let cwd = match resolve_cwd(&self.security.workspace_dir, cwd_override.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(msg) => return Ok(ToolResult::error(msg)),
|
||||
};
|
||||
|
||||
let resolved = match self.bootstrap.resolve().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "[npm_exec] failed to resolve node runtime");
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Node.js runtime unavailable: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
version = %resolved.version,
|
||||
source = ?resolved.source,
|
||||
npm_bin = %resolved.npm_bin.display(),
|
||||
subcommand = %subcommand,
|
||||
"[npm_exec] starting invocation"
|
||||
);
|
||||
|
||||
let mut parts: Vec<String> = Vec::with_capacity(extra_args.len() + 2);
|
||||
parts.push(shell_quote(&resolved.npm_bin.to_string_lossy()));
|
||||
parts.push(shell_quote(&subcommand));
|
||||
for a in &extra_args {
|
||||
parts.push(shell_quote(a));
|
||||
}
|
||||
let command = parts.join(" ");
|
||||
|
||||
let mut cmd = match self.runtime.build_shell_command(&command, &cwd) {
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Failed to build runtime command: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
cmd.env_clear();
|
||||
|
||||
let host_path = std::env::var("PATH").unwrap_or_default();
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let prepended_path = if host_path.is_empty() {
|
||||
resolved.bin_dir.to_string_lossy().into_owned()
|
||||
} else {
|
||||
format!("{}{}{}", resolved.bin_dir.display(), sep, host_path)
|
||||
};
|
||||
cmd.env("PATH", &prepended_path);
|
||||
|
||||
for var in SAFE_ENV_VARS {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), cmd.output()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if stdout.len() > MAX_OUTPUT_BYTES {
|
||||
stdout.truncate(stdout.floor_char_boundary(MAX_OUTPUT_BYTES));
|
||||
stdout.push_str("\n... [stdout truncated at 1MB]");
|
||||
}
|
||||
if stderr.len() > MAX_OUTPUT_BYTES {
|
||||
stderr.truncate(stderr.floor_char_boundary(MAX_OUTPUT_BYTES));
|
||||
stderr.push_str("\n... [stderr truncated at 1MB]");
|
||||
}
|
||||
|
||||
if output.status.success() {
|
||||
if stderr.is_empty() {
|
||||
Ok(ToolResult::success(stdout))
|
||||
} else {
|
||||
Ok(ToolResult::success(format!("{stdout}\n[stderr]\n{stderr}")))
|
||||
}
|
||||
} else {
|
||||
let err_msg = if stderr.is_empty() { stdout } else { stderr };
|
||||
Ok(ToolResult::error(err_msg))
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Ok(ToolResult::error(format!("Failed to execute npm: {e}"))),
|
||||
Err(_) => Ok(ToolResult::error(format!(
|
||||
"npm_exec timed out after {timeout_secs}s and was killed"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX-safe single-quote escaping (mirrors the helper in `node_exec`).
|
||||
/// Wraps `s` in `'…'`, turning any embedded single-quote into `'\''` so no
|
||||
/// shell metacharacter can escape the quoted string.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
let escaped = s.replace('\'', "'\\''");
|
||||
format!("'{escaped}'")
|
||||
}
|
||||
|
||||
/// Subcommands must be plain identifiers (`install`, `run`, `ci`, `exec`,
|
||||
/// `test:watch`) — never a command substitution or redirection payload.
|
||||
fn is_sane_subcommand(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':'))
|
||||
}
|
||||
|
||||
/// Resolve an optional `cwd` override against the workspace. Rejects any
|
||||
/// path that escapes the workspace via `..` or absolute components.
|
||||
fn resolve_cwd(
|
||||
workspace: &std::path::Path,
|
||||
override_path: Option<&str>,
|
||||
) -> Result<std::path::PathBuf, String> {
|
||||
match override_path {
|
||||
None => Ok(workspace.to_path_buf()),
|
||||
Some(raw) => {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() || raw == "." {
|
||||
return Ok(workspace.to_path_buf());
|
||||
}
|
||||
let candidate = std::path::Path::new(raw);
|
||||
if candidate.is_absolute() {
|
||||
return Err(format!(
|
||||
"npm_exec `cwd` must be relative to workspace; got absolute path {raw:?}"
|
||||
));
|
||||
}
|
||||
if candidate.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
std::path::Component::ParentDir | std::path::Component::Prefix(_)
|
||||
)
|
||||
}) {
|
||||
return Err(format!(
|
||||
"npm_exec `cwd` must not escape workspace; got {raw:?}"
|
||||
));
|
||||
}
|
||||
Ok(workspace.join(candidate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_sane_subcommand_accepts_common_npm_verbs() {
|
||||
for v in &[
|
||||
"install",
|
||||
"ci",
|
||||
"run",
|
||||
"exec",
|
||||
"test",
|
||||
"test:watch",
|
||||
"run-script",
|
||||
] {
|
||||
assert!(is_sane_subcommand(v), "{v} should be accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sane_subcommand_rejects_metacharacters() {
|
||||
for v in &["install; rm -rf /", "run && echo", "|cat", "$(whoami)", ""] {
|
||||
assert!(!is_sane_subcommand(v), "{v} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_cwd_defaults_to_workspace() {
|
||||
let ws = std::path::Path::new("/tmp/ws");
|
||||
assert_eq!(resolve_cwd(ws, None).unwrap(), ws);
|
||||
assert_eq!(resolve_cwd(ws, Some("")).unwrap(), ws);
|
||||
assert_eq!(resolve_cwd(ws, Some(".")).unwrap(), ws);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_cwd_rejects_absolute_and_parent() {
|
||||
let ws = std::path::Path::new("/tmp/ws");
|
||||
assert!(resolve_cwd(ws, Some("/etc")).is_err());
|
||||
assert!(resolve_cwd(ws, Some("../other")).is_err());
|
||||
assert!(resolve_cwd(ws, Some("sub/../../../etc")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_cwd_allows_relative_subdir() {
|
||||
let ws = std::path::Path::new("/tmp/ws");
|
||||
let got = resolve_cwd(ws, Some("app")).unwrap();
|
||||
assert_eq!(got, std::path::PathBuf::from("/tmp/ws/app"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::openhuman::agent::host_runtime::RuntimeAdapter;
|
||||
use crate::openhuman::node_runtime::NodeBootstrap;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
use crate::openhuman::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
@@ -20,11 +21,37 @@ const SAFE_ENV_VARS: &[&str] = &[
|
||||
pub struct ShellTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
/// Optional managed Node.js bootstrap. When provided **and** a prior
|
||||
/// `NodeBootstrap::resolve()` has already succeeded, every shell invocation
|
||||
/// transparently prepends the managed `bin/` dir to `PATH` — so skills
|
||||
/// shelling out to `node`/`npm`/`npx`/`corepack` resolve to the managed
|
||||
/// toolchain. Non-blocking: never triggers a download for unrelated
|
||||
/// commands (we use `try_cached()`).
|
||||
node_bootstrap: Option<Arc<NodeBootstrap>>,
|
||||
}
|
||||
|
||||
impl ShellTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
|
||||
Self { security, runtime }
|
||||
Self {
|
||||
security,
|
||||
runtime,
|
||||
node_bootstrap: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as `new` but attaches a managed Node.js bootstrap for transparent
|
||||
/// `PATH` injection. The bootstrap is consulted via `try_cached()` on each
|
||||
/// invocation, so calling a non-node shell command never forces a download.
|
||||
pub fn with_node_bootstrap(
|
||||
security: Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
bootstrap: Arc<NodeBootstrap>,
|
||||
) -> Self {
|
||||
Self {
|
||||
security,
|
||||
runtime,
|
||||
node_bootstrap: Some(bootstrap),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +134,28 @@ impl Tool for ShellTool {
|
||||
}
|
||||
}
|
||||
|
||||
// If a managed Node.js install has already been resolved, transparently
|
||||
// prepend its bin dir to PATH so this shell sees the managed toolchain.
|
||||
// `try_cached()` never blocks and never triggers a download — unrelated
|
||||
// commands (e.g. `ls`) stay fast and byte-identical to before.
|
||||
if let Some(bootstrap) = self.node_bootstrap.as_ref() {
|
||||
if let Some(resolved) = bootstrap.try_cached() {
|
||||
let host_path = std::env::var("PATH").unwrap_or_default();
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let prepended = if host_path.is_empty() {
|
||||
resolved.bin_dir.to_string_lossy().into_owned()
|
||||
} else {
|
||||
format!("{}{}{}", resolved.bin_dir.display(), sep, host_path)
|
||||
};
|
||||
tracing::debug!(
|
||||
bin_dir = %resolved.bin_dir.display(),
|
||||
version = %resolved.version,
|
||||
"[shell] prepending managed node bin to PATH"
|
||||
);
|
||||
cmd.env("PATH", prepended);
|
||||
}
|
||||
}
|
||||
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(SHELL_TIMEOUT_SECS), cmd.output()).await;
|
||||
|
||||
|
||||
+134
-1
@@ -3,6 +3,7 @@ use super::*;
|
||||
use crate::openhuman::agent::host_runtime::{NativeRuntime, RuntimeAdapter};
|
||||
use crate::openhuman::config::{Config, DelegateAgentConfig};
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::node_runtime::NodeBootstrap;
|
||||
use crate::openhuman::security::SecurityPolicy;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -71,8 +72,40 @@ pub fn all_tools_with_runtime(
|
||||
fallback_api_key: Option<&str>,
|
||||
root_config: &crate::openhuman::config::Config,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
// Build a session-scoped managed Node.js bootstrap once, so ShellTool,
|
||||
// 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<Arc<NodeBootstrap>> = if root_config.node.enabled {
|
||||
tracing::debug!(
|
||||
version = %root_config.node.version,
|
||||
prefer_system = root_config.node.prefer_system,
|
||||
"[tools::ops] node runtime enabled — constructing shared NodeBootstrap"
|
||||
);
|
||||
Some(Arc::new(NodeBootstrap::new(
|
||||
root_config.node.clone(),
|
||||
workspace_dir.to_path_buf(),
|
||||
reqwest::Client::new(),
|
||||
)))
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[tools::ops] node runtime disabled — shell PATH injection + node_exec/npm_exec suppressed"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
let shell: Box<dyn Tool> = if let Some(bootstrap) = node_bootstrap.as_ref() {
|
||||
Box::new(ShellTool::with_node_bootstrap(
|
||||
security.clone(),
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(bootstrap),
|
||||
))
|
||||
} else {
|
||||
Box::new(ShellTool::new(security.clone(), Arc::clone(&runtime)))
|
||||
};
|
||||
|
||||
let mut tools: Vec<Box<dyn Tool>> = vec![
|
||||
Box::new(ShellTool::new(security.clone(), runtime)),
|
||||
shell,
|
||||
Box::new(FileReadTool::new(security.clone())),
|
||||
Box::new(FileWriteTool::new(security.clone())),
|
||||
Box::new(CsvExportTool::new(security.clone())),
|
||||
@@ -156,6 +189,23 @@ pub fn all_tools_with_runtime(
|
||||
root_config.web_search.timeout_secs,
|
||||
)));
|
||||
|
||||
// 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.
|
||||
if let Some(bootstrap) = node_bootstrap.as_ref() {
|
||||
tools.push(Box::new(NodeExecTool::new(
|
||||
security.clone(),
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(bootstrap),
|
||||
)));
|
||||
tools.push(Box::new(NpmExecTool::new(
|
||||
security.clone(),
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(bootstrap),
|
||||
)));
|
||||
tracing::debug!("[tools::ops] registered node_exec + npm_exec");
|
||||
}
|
||||
|
||||
// Vision tools are always available
|
||||
tools.push(Box::new(ScreenshotTool::new(security.clone())));
|
||||
tools.push(Box::new(ImageInfoTool::new(security.clone())));
|
||||
@@ -670,6 +720,89 @@ mod tests {
|
||||
assert!(!names.contains(&"delegate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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
|
||||
// skills integration — if this fires, managed-node skills silently
|
||||
// lose both tools.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let mem_cfg = MemoryConfig {
|
||||
backend: "markdown".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
let cfg = test_config(&tmp);
|
||||
|
||||
let tools = all_tools(
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(
|
||||
names.contains(&"node_exec"),
|
||||
"node_exec must be registered when node.enabled=true; got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"npm_exec"),
|
||||
"npm_exec must be registered when node.enabled=true; got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_excludes_node_exec_when_node_disabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let mem_cfg = MemoryConfig {
|
||||
backend: "markdown".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
let mut cfg = test_config(&tmp);
|
||||
cfg.node.enabled = false;
|
||||
|
||||
let tools = all_tools(
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(
|
||||
!names.contains(&"node_exec"),
|
||||
"node_exec must NOT be registered when node.enabled=false; got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"npm_exec"),
|
||||
"npm_exec must NOT be registered when node.enabled=false; got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_excludes_computer_control_when_disabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user