0085 — Managed Node.js runtime, provisioned on demand
- Status: accepted
- Date: 2026-07-17
- Deciders: Josh, agent
1. Context & problem statement
A whole class of protoAgent extensions launches through Node's npx: the ACP coding agents driven via the fetch form (npx -y @agentclientprotocol/claude-agent-acp for Claude Code, npx -y @zed-industries/codex-acp for Codex — runtime/acp_agents.py), and most of the community MCP servers in config/mcp-catalog.json. All of them need node/npx on the PATH of the process that spawns them.
The desktop app doesn't provide one. It bundles only the Python server sidecar (tauri.conf.json externalBin), and the existing PATH machinery — augmented_sidecar_path() in the Tauri shell (#1299) and acp_client._discovered_node_dirs() on the Python side — can only find a Node the user already installed (Homebrew, nvm, fnm, Volta, asdf). On a clean machine with no developer toolchain there is nothing to find, so those features dead-end with "agent binary not found: 'npx'". A desktop user reasonably expects "npx functionality" to work on download without hand-installing Node into their shell.
The default experience does not need Node — the native runtime is the LangGraph loop talking to the LiteLLM gateway, entirely inside the Python sidecar. Node is an opt-in advanced dependency (ACP coding agents + npx MCP servers). So the bar isn't "the app is broken", it's "an opt-in capability silently requires a toolchain the user doesn't have, and fails opaquely".
2. Decision drivers
- Meet the "works on download" expectation for the npx-based features without making every user pay for a runtime they may never use.
- Keep the installer lean and side-step signing/notarizing a second bundled Mach-O binary inside the hardened-runtime app.
- One consumption seam, not a patch at every subprocess launch site — ACP (
acp_client._launch_env) and MCP (mcp_tools._inherited_env) build their child env independently today, and future spawn sites shouldn't have to know. - A real integrity gate on a downloaded, executed runtime — not trust-on-first-use.
- Respect the import-layering invariant:
infra/runtimemust not reach intoserver/operator_api.
3. Considered options
- Bundle Node in the desktop app — ship a Node runtime as a second Tauri sidecar. Most literal answer, but grows every installer by the runtime, adds a binary to sign/notarize per platform, and pays the cost for users who never touch the feature.
- Bundle Node + pre-install the adapters — fully offline, but 100 MB+ (the Claude adapter carries the whole Code SDK) and makes us own those adapters' version + security cadence, adjacent to the "don't vendor Anthropic assets" line we already hold.
- Provision on demand (chosen) — download a pinned Node into the box-shared data dir when the user asks for it (one CLI command / one console click), and teach the existing PATH seam about it. Lean installer, no bundled binary to notarize, we control the exact version. Costs a one-time download when the user opts in.
4. Decision
Adopt option 3.
Where it lives. The runtime is extracted to box_root/runtime/node/current (box tier, not instance tier — one machine provisions Node once and the default instance, the dev sandbox, and every fleet member share it; a per-instance copy would waste ~150 MB each and re-download on every dev-reset). current/ is a real directory (not a symlink — Windows symlink creation needs elevation), so consumers resolve current/bin (POSIX) / current (Windows) without knowing the pinned version.
Two halves, split by layer.
infra/node_runtime.py— the light, dependency-free half every layer may import:managed_node_bin_dir()(the bin dir of a working install, else None) andaugment_path_with_managed_node(), the single consumption seam.runtime/node_install.py— the installer: download a pinned release from nodejs.org, verify it against an in-repo SHA256 table, extract, and swap it intocurrent/(old install kept until the new one is in place). Consumed by the CLI and, later, an operator endpoint.
The consumption seam is a boot-time PATH append. At server start (server.agent_init) we call augment_path_with_managed_node(): if node isn't already resolvable and a managed install exists, we append its bin dir to the process os.environ["PATH"]. Append (not prepend) so a user's own Node always wins; process-level (not per-launch) so ACP, MCP, delegates, and gh/git inherit it uniformly. The installer re-runs the same augmentation on success, so a live server hot-adopts a freshly provisioned runtime without a restart.
Integrity. We pin NODE_VERSION and a SHA256 per supported (platform, arch) in _SHA256, checked in from https://nodejs.org/dist/<version>/SHASUMS256.txt. Verifying against a digest we already committed — rather than the SHASUMS256.txt the same host serves — is what makes this a real gate: a compromised mirror can't swap the binary without also matching a hash in the repo. Archive members are additionally guarded against absolute/.. paths, and tar extraction uses filter='data' where available.
Surfaces. protoagent runtime install-node provisions it; runtime list shows Node status (system / managed / not-found) and reflects the managed runtime in the per-agent install probes. Supported targets: darwin arm64/x64, linux arm64/x64, win x64. A one-click console button + operator endpoint is a follow-up.
5. Consequences
- Positive. The npx-based coding agents and MCP servers work on a clean desktop machine after one opt-in step; installer stays lean; no second binary to notarize; the version is ours to pin and bump; one seam covers every current and future subprocess launch.
- Negative / trade-offs. The first use still needs network (the Node download, and
npx -ystill fetches the adapter on first launch, then caches in~/.npm/_npx); bumping Node means updatingNODE_VERSIONand the_SHA256table together; the on-demand step is a real (if one-time) wait, not invisible. - Not chosen. We do not vendor the ACP adapters —
npx -yfetch-on-demand keeps us out of owning the Claude/Codex SDK release cadence.
6. References
- ADR 0024 / 0025 — CLI coding agents over ACP; the unified delegate registry.
- ADR 0033 — ACP-as-runtime.
- ADR 0004 — instance/box path scoping (
infra/paths.py). - #1299 — the Tauri sidecar login-shell PATH fix (finds an existing Node; this ADR provides one when none exists).
docs/guides/coding-agents.md,docs/guides/mcp.md.