Environment Variables

This page lists every HORUS_* variable that shipped, non-test code reads, and every one HORUS sets for a process it starts — a node under horus launch, a Python node under horus run, an exec driver, a CLI plugin. The ones you would set deliberately come first, grouped by area; the internal and test-only ones are at the bottom so you can recognise one if you see one.

There is deliberately no count in this sentence. The version of this page that carried one opened with "HORUS reads 47 HORUS_* variables" while its own tables listed 43 distinct names.

What this page does not cover

The integration-test tree carries its own environment surface — names of the shape HORUS_<SUITE>_CHILD, _TOPIC, _ROLE, _COUNT, whose entire job is to let a test coordinate with a process it spawns. They outnumber the real ones: of the 199 distinct HORUS_* string literals in the repository outside target/, 94 appear somewhere under a src/ directory and 105 appear only in test files. None of those 105 is reachable from an installed HORUS, none has a default worth knowing, and listing them would bury the page. They are not here, and horus_manager/tests/docs_parity.rs:150-154 records the same reason for keeping them out of the check that guards it.

Two automated checks stand behind the rest, and it is worth knowing what each one can and cannot see. Both run in the contract-live job of .github/workflows/docs-contract.yml, on every pull request, against a pinned horus-docs commit.

docs_contract::every_environment_variable_is_in_the_reference_page builds a surface from four scans and fails when a name in it is absent from this page:

  • Rust files under a src/ path, where the name is a literal directly inside env::var(, env::var_os(, env::set_var(, .env( or env.insert( — matches inside a #[cfg(test)] module are dropped;
  • .cpp, .cc, .hpp and .h outside tests/, for getenv( and secure_getenv(;
  • .py outside tests/, for os.environ.get(, os.environ[ and os.getenv(;
  • install.sh and uninstall.sh, for any $HORUS_* the script references and never assigns.

The first scan is the one doing the work. The C++ and Python scans match nothing in this repository today — no shipped .cpp or .hpp calls getenv at all, and no shipped .py file names a HORUS_* variable — and the non-vacuity guard at docs_contract.rs:908-926 probes only three of the four, so if either of them ever did match and then stopped, nothing would say so.

Inside the Rust scan, four shapes are invisible, and all four are in use:

  • a read through a helper — csv_env("HORUS_NET_EXPORT_STREAM") at horus_net/src/config.rs:257;
  • a read through a constant — env::var(LEVEL_ENV) at horus_core/src/core/hlog.rs:123, where LEVEL_ENV is HORUS_LOG_LEVEL. HORUS_NO_PROXY_NOTICE and HORUS_SHELL_PROXY reach the code the same way, through the constants at horus_manager/src/commands/proxy.rs:47 and :55;
  • a name built at runtime — format!("HORUS_REAL_{}", tool_name.to_uppercase()) at horus_manager/src/commands/proxy.rs:417;
  • a read in generated code — HORUS_MSGS_FFI and HORUS_MSGS_FFI_BUILD are read by Python that lives inside a Rust string literal (horus_manager/src/msgspec/emit_python.rs:140 and :180), which the Rust scan does not parse and the Python scan never opens.

HORUS_NET_EXPORT_STREAM went undocumented behind exactly the first of those. If you add a variable through a helper, a constant, a format! or a generated file, no check will remind you; add it here by hand.

The second check, docs_manifest::documented_env_vars_are_read, runs the other way: a HORUS_* name written on any documentation page must appear somewhere in shipped source — every .rs, .toml, .sh, .h, .hpp, .hh, .cpp, .cc, .py and .yml file outside target/ and outside every directory named tests. A variable invented in prose therefore fails the build. Three things soften it, and this page depends on all three:

  • a heading containing internal or test-only exempts the section under it, which is why a genuinely test-only variable has to live in the last section here rather than in a table above;
  • a line that disclaims a variable — "nothing reads", "there is no", "does not" — is skipped, which is what lets the Set but not read table below name variables and then say nothing reads them;
  • a name matching a prefix the source builds at runtime is accepted, so HORUS_PARAM_MAX_SPEED is covered by the format!("HORUS_PARAM_{}", …) in commands/launch.rs and HORUS_REAL_CARGO by the one in commands/proxy.rs.

It matches on the name appearing, not on a read, so it catches invention rather than staleness.

Variables that relax a check

Every one of these has a safe default and a reason someone turns it off. They are collected here so an operator hardening a deployment has one list to audit; each also appears in its subsystem's table below with the full detail.

VariableWhat it gives up
HORUS_NET_ALLOW_PEERSSource-address admission for inbound UDP. any accepts the public internet
HORUS_NET_IMPORTWhich remote topics may be written into local shared memory. The data plane is unauthenticated, so this is reach reduction, not authorization
HORUS_ESTOP_REMOTEWhether an authenticated remote emergency stop is acted on at all
HORUS_TRUST_PROXYIgnoring X-Forwarded-For / X-Real-IP, which is what stops a remote client spoofing an address past per-IP rate limiting
HORUS_ALLOW_LOCAL_PLUGINSThe rule that cloning a repository is not enough to run its code
HORUS_AUTH_ALLOW_MISSING_STATEThe OAuth CSRF check during horus login, when the registry returns no state
HORUS_AUTO_INSTALLThe prompt before sudo apt install during a C++ build
HORUS_INSTALL_BRANCHInstalling from a release tag, in favour of an arbitrary branch

HORUS_ESTOP_KEY is the opposite case: it is the only variable here that adds a check, and until it is set every remote emergency stop is rejected.

Logging

VariableDefaultEffect
HORUS_LOG_FILEunsetExactly 1 or true starts the file drain. Without it nothing is written to disk and the other HORUS_LOG_* variables have no effect
HORUS_LOG_DIR.horus/logsDirectory for rotated log files
HORUS_LOG_MAX_SIZE10485760 (10 MB)Rotate a log file once it exceeds this many bytes
HORUS_LOG_MAX_FILES5How many rotated files to keep
HORUS_LOG_BUFFER_SIZE5000Entries held in the in-memory ring the monitor reads. Clamped to 100–50000 — a value outside that range is silently adjusted, not rejected
HORUS_ERROR_BUFFER_SIZE500Entries in the dedicated error ring, which survives a flood of ordinary logs. Clamped to 50–5000
HORUS_REMOTE_LOG_SIZE2000Entries in the buffer holding logs received from other machines. Clamped to 100–50000
HORUS_LOG_LEVELdebugHighest level hlog! will emit. Accepts off/none/silent, error/err, warn/warning, info, debug/trace/all, case-insensitively. An unrecognised value warns on stderr and falls back to the default. The default is debug, so nothing is suppressed until you set this — a production image should set info or warn to actually save the per-line cost

The error buffer is separate on purpose: a node logging at high rate can evict its own errors from the main buffer. horus log --level warn and --level error read the error ring instead of the main one, which is the longer retention (horus_manager/src/commands/log.rs:201-206); anything below warn reads the main ring. Logs replicated from peers are merged into the same output from a third buffer.

HORUS_LOG_LEVEL is read once per process and latched, on the first hlog!. The filter lives in the macro so a suppressed line never evaluates its arguments. There is no HORUS_LOG; nothing reads that name.

Networking

Only meaningful with the net capability enabled — see Configuration.

VariableDefaultEffect
HORUS_NET_ENABLEDenabledOpt-out only. Once the binary is built with the net capability, the umbrella crate registers an auto-wire hook and scheduler.run() starts the replicator; 0 or false (case-insensitive) turns it off. Any other value, including true, leaves it on
HORUS_NO_NETWORKunset1 or true disables replication for one run — wins over the above, because the replicator refuses to start on a config with enabled = false however it was wired
HORUS_NET_PORT9100UDP port the replicator binds. A value that does not parse as a u16 falls back to the default
HORUS_NET_MULTICAST224.0.69.72Discovery multicast group
HORUS_NET_PEERemptyComma-separated peers to unicast directly, skipping multicast discovery
HORUS_NET_ALLOW_PEERS127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10Relaxes a check. Which source addresses inbound datagrams may come from. any, *, all or 0.0.0.0/0 accepts every routable host and prints a warning saying so. Otherwise a comma-separated list of addresses and CIDR blocks; an unparseable entry warns and is skipped, and if every entry is unparseable the default list is used rather than any
HORUS_NET_SECRETunsetShared token peers must match. Peer filtering, not authentication — the source says so at the field. Used verbatim; [network] secret in horus.toml is passed through unchanged, with no env: indirection
HORUS_NET_IMPORTautoRelaxes a check. Which remote topics may be imported. auto (also unset or empty) admits any topic this process subscribes to; deny/none/off/false admits none; anything else is a comma-separated allowlist of topic patterns. A spec that parses to no patterns becomes deny, not auto
HORUS_NET_DENY_EXPORTemptyComma-separated topic patterns that must never leave this machine
HORUS_NET_EXPORT_STREAMemptyComma-separated topic patterns (globs allowed) exported as a full stream rather than one sample per export tick. See below
HORUS_NET_OPTIMIZERSemptyComma-separated: fusion, spatial, predict. An unknown name warns and installs nothing. delta is recognised but refused — it encodes with no decoder on the receive side, so naming it warns and installs nothing rather than corrupting replicated data
HORUS_NET_HEARTBEAT_MS50 (200 under WSL2)How often the safety heartbeat is sent to matched peers, for link-loss detection. It does not affect discovery announcements
HORUS_NET_MISSED_THRESHOLD3 (5 under WSL2)Missed heartbeats before the link counts as lost
HORUS_NET_ON_LINK_LOSTwarnAction on link loss: warn, safe_state (alias safestate), stop. Anything else silently means warn
HORUS_NET_DISCOVERY_MISSES3 (6 under WSL2)Missed announcements before a peer is considered gone. Announcements go out on a fixed 1-second interval that no setting changes, so the default is a 3-second peer timeout (6 s under WSL2)
HORUS_NET_LOG_LEVELwarnWhich logs replicate to peers: none/off, error, warn/warning, info, debug/trace/all. An unrecognised value means warn

WSL2 is detected by looking for microsoft in /proc/version, because its virtualised network adapter adds tens to hundreds of milliseconds of UDP jitter and the native timings produce false link-lost reports.

HORUS_NET_EXPORT_STREAM, and the 20 Hz you did not ask for

Outbound replication is driven from the replicator's timer, which fires every 50 ms (horus_net/src/replicator.rs:44). The default export takes the newest sample on each tick and leaves the rest, so every replicated topic crosses the network at roughly 20 Hz however fast its publisher runs. That is correct for state — a pose, a battery level — and wrong for a measurement stream, where the receiver integrates every sample. A 500 Hz odometry stream crossed the LAN at 20 Hz and looked, at the far end, exactly like a 20 Hz odometry stream, and nothing counted or said so (horus_net/src/replicator.rs:904-926).

List the streaming topics and every message crosses:

HORUS_NET_EXPORT_STREAM='odom,joint_states,lidar.*' horus run

A streaming topic contributes at most 64 messages to one export tick (MAX_EXPORT_BATCH, replicator.rs:57); the reader resumes from its own cursor, so the remainder goes out on the next tick rather than being dropped. At the 50 ms timer that is 1280 messages per second per topic sustained.

Whatever a topic is set to, what it drops is now counted and reported; the warning that names the drops also names this variable (horus_net/src/shm_reader.rs:140).

This is the one networking variable with no horus.toml equivalent. The [network] table has fields enabled, import, deny_export, secret, optimize and safety and no export_stream (horus_manager/src/manifest.rs:276-303), and the per-topic export_sampling override that would express the same thing (horus_net/src/config.rs:217) lives in a map that nothing outside the two test suites ever populates (horus_net/src/config.rs:44, :261). The environment variable is the only route there is.

How [network] in horus.toml reaches the runtime

horus run translates the [network] table into these variables for the child process, in horus_manager/src/commands/run/mod.rs:79-142: HORUS_NO_NETWORK, HORUS_NET_SECRET, HORUS_NET_DENY_EXPORT, HORUS_NET_OPTIMIZERS, HORUS_NET_IMPORT, HORUS_NET_HEARTBEAT_MS, HORUS_NET_MISSED_THRESHOLD and HORUS_NET_ON_LINK_LOST. A variable already set in your environment wins — an explicit HORUS_NET_*=… on the command line is never overridden by a project file.

Two consequences worth knowing before you rely on the manifest. The translation runs on one path only, execute_multiple_files, which is reached when horus run is given more than one file or a glob that matches more than one (mod.rs:236-238 and :834); a single file, a directory and a horus.toml run get none of it. And horus launch performs no such translation at all — horus_manager/src/commands/launch.rs sets no HORUS_NET_* name. Where the manifest route does not reach, the environment variable does.

Safety

VariableDefaultEffect
HORUS_ESTOP_REMOTEwarnRelaxes a check. What a remote emergency stop does locally. off, ignore, 0 or false disable it; anything else warns. Trimmed and lowercased before matching
HORUS_ESTOP_KEYunsetHMAC-SHA256 key authenticating the networked e-stop. 64 hex characters are used as raw 32-byte key material; anything else is stretched with PBKDF2-HMAC-SHA256 at 600,000 iterations. An empty or all-whitespace value counts as unset. While unset, every remote e-stop packet is rejected — see Authenticating the networked e-stop

Note the default on HORUS_ESTOP_REMOTE: an unrecognised value warns rather than disabling, so a typo cannot silently switch off remote e-stop. The key is read once per process and cached, so the KDF cost is paid at first use and never on the packet path. The salt is fixed and domain-separated; there is nowhere to put a per-deployment one when both ends must derive the same bytes from a bare environment variable.

Runtime

VariableDefaultEffect
HORUS_SIM_MODEunsetRuns drivers in simulation. Any value except empty, 0 or false enables it — presence alone used to be enough, so HORUS_SIM_MODE=0 turned simulation on and a deploy script forcing real hardware got inert stub nodes. Set by horus run --sim
HORUS_SIM_TARGETSunsetComma-separated driver names to simulate, when only some should be. Read only when HORUS_SIM_MODE is on
HORUS_DRIVERSunsetComma-separated driver list. Replaces horus.toml's when non-empty; an empty list falls back to the manifest. Set by horus run --drivers
HORUS_ENABLEunsetComma-separated capabilities. net and network are both accepted for networking. Replaces horus.toml's capability list when non-empty. Set by horus run --enable, and by --net, which appends net
HORUS_TELEMETRY_ENDPOINTunsetWhere the scheduler exports telemetry — a UDP or file URI, e.g. udp://localhost:9999. Ignored when the scheduler config already names an endpoint, and an empty value is ignored
HORUS_NAMESPACEdefaultIsolates shared memory. Two processes with different namespaces cannot see each other's topics — the equivalent of a ROS 2 domain ID. Sanitised, and a value that sanitises to nothing but underscores is discarded. Read once per process and cached, so changing it mid-run has no effect. A test binary that has not set it gets a private namespace derived from its cargo target directory instead
HORUS_NODE_NAMESchedulerNames the scheduler in horus node list and in the presence file it writes under scheduler/. Unset or empty leaves the literal name Scheduler — the binary name is never consulted; Scheduler::new().name("...") overrides both
HORUS_RT_CORESunsetComma-separated core IDs to pin real-time threads to, e.g. 2,3. Ignored when the scheduler already has .cores(&[..]), so it is an override for deployment rather than a default. Entries that do not parse are dropped
HORUS_RECORD_SESSIONunsetA non-empty value enables recording for the run. Set by horus run --record <session>; setting it by hand has the same effect
HORUS_RT_WAITsleepHow the RT tick loop waits out a period. spin opts into a busy-wait: median wake jitter improves to about 100 ns, but under a real RT policy Linux RT bandwidth control dequeues the thread for ~50 ms once its share is spent, and the loop warns about that unconditionally on every boot. An unrecognised value warns and keeps the sleeping wait
HORUS_RT_SPIN_GUARD_US20Microseconds of spin at the end of a sleep-based wait, to absorb timer slack. 0 disables the spin entirely. Capped at 1/16 of the tick period regardless of what you set, so the loop's unconditional CPU draw stays at or below 6.25 % at every rate
HORUS_RT_ALLOW_CORE_SHARINGunsetAny value except empty, 0 or false — matched case-sensitively, unlike the flags above — downgrades a refusal to share one core between RT chains into a warning. For the deliberate case: two light chains, one core, and a measurement behind the decision
HORUS_SHM_PREFAULTenabledPre-fault a shared-memory mapping at attach so the first store in a control loop does not take a page fault. 0/false/off/no disables; 1/true/on/yes enables; an unrecognised value keeps the default rather than meaning off. Read once per process and cached
HORUS_SHM_PREFAULT_MAX_BYTES67108864 (64 MiB)Upper bound on how much of a mapping is pre-faulted. A value that does not parse as a number falls back to the default
HORUS_SHM_MLOCKdisabled1/true/on/yes locks shared-memory pages resident so they cannot be swapped. Off by default because it needs the memlock limit raised, and locking trades swappability for a likelier OOM kill. It is mlock on the region, not mlockall, so ordinary heap allocations still fail the way they always did

Parameters

HORUS_PARAM_<NAME> sets the runtime parameter <name>, lowercased:

HORUS_PARAM_MAX_SPEED=2.5 HORUS_PARAM_DEBUG=true horus run

reaches your node as self.params.get::<f64>("max_speed") and get::<bool>("debug").

VariableDefaultEffect
HORUS_PARAM_<NAME>unsetSets parameter <name> (lowercased). Values parse as integer, then float, then bool, then string — integer first, so 42 is the integer 42 and not 42.0. The bool match is exactly true or false; True is the string "True"

This is the highest-precedence layer: it beats .horus/config/params.yaml, which beats the built-in defaults. Each layer overrides individual keys of the one below it rather than replacing the whole map — before that was true, a single HORUS_PARAM_CAMERA_FPS=60 from a launch file suppressed the entire built-in set, including emergency_stop_distance and max_speed.

horus launch and the exec driver both build these names from your parameter keys, uppercasing them; horus launch also replaces - with _, so a launch key max-speed arrives as HORUS_PARAM_MAX_SPEED. The exec driver does not, so a driver parameter with a hyphen in its key produces a name a shell cannot export. A variable set by hand overrides a launch file for that run.

CLI and toolchain

VariableDefaultEffect
HORUS_ASSUME_YESunset1, true or yes covers three prompts. In horus new it suppresses them and takes the documented defaults; in horus setup-rt and horus env --init it answers yes. horus new -y/--yes sets it for you; the other two commands have no such flag — setup-rt takes only --check and --undo, env only --init and --uninstall — so the variable is the only way to accept setup-rt's kernel-install and limits-file prompts without a terminal
HORUS_AUTO_INSTALLunsetRelaxes a check. 1, true or yes installs missing C++ system dependencies with sudo apt without asking. Without it, a pcl = "1.13" line in [dependencies] prompts before touching the machine
HORUS_CPP_LINKstaticHow the HORUS C++ runtime is linked into a node binary: static, or shared (dynamic is accepted as a synonym). Static puts the whole runtime in every binary — 78 MB in Debug and 2.0 MB stripped in Release, per binary — so a robot running a dozen nodes pays it a dozen times. shared links libhorus_cpp.so: measured on the generated template, Release, x86-64, 2,074,176 bytes static against 47,392 bytes plus one 8.9 MB shared library, so it pays from the second node. Overrides [cpp] link in horus.toml for one build; an unrecognised value is an error rather than a silent fall back to static
HORUS_VERBOSEunsetAny value, including empty, shows the full compiler and CMake output during a C++ build. Debug-level logging has the same effect
HORUS_SOURCE_DIRunsetPath to a HORUS checkout, used by horus msg to resolve message types from the linked message crates of that tree and nothing else
HORUS_SOURCEunsetPath to a HORUS checkout, used to resolve the framework itself when horus run builds a single Rust file. Distinct from HORUS_SOURCE_DIR, which resolves message definitions — a name that is a prefix of another is exactly the kind of pair a substring check treats as one variable, and this one went undocumented behind HORUS_SOURCE_DIR for that reason. Ignored unless the path exists and contains horus/Cargo.toml
HORUS_REGISTRY_URLhttps://api.horusrobotics.devPackage registry, for self-hosting or offline mirrors
HORUS_PLUGIN_REGISTRY_URLhttps://plugins.horusrobotics.dev/api/v1Plugin registry
HORUS_TRUST_PROXYfalseRelaxes a check. 1, true or yes makes horus-monitor trust an HTTP proxy's X-Forwarded-For / X-Real-IP headers. Off by default so a remote client cannot spoof an address and bypass per-IP rate limiting. Set it only when the monitor really is behind a proxy that rewrites those headers
HORUS_ALLOW_LOCAL_PLUGINSfalseRelaxes a check. 1 or true allows horus monitor to run the project-local .horus/bin/horus-monitor, and warns when it does. Off by default — that binary is not trust-checked and gets none of the plugin sandbox, and cloning a repo must not be enough to run its code
HORUS_AUTH_ALLOW_MISSING_STATEfalseRelaxes a check. 1 or true lets horus login complete when the registry returns no OAuth state parameter, printing a warning that the login was not CSRF-verified. Absent state is fatal by default because the attacker picks the branch: "warn and continue when absent" is defeated by omitting the parameter. A mismatched state is never accepted, with or without this variable. This is the documented opt-out for the window before the registry echoes state back
HORUS_NO_SHELL_INTEGRATIONunsetAny non-empty value makes the installer skip horus env --init, so no shell rc file is touched and the cargo/pip/pip3/cmake/conan/vcpkg proxies are not installed. install.ps1 reads it the same way and leaves the PowerShell profile alone
HORUS_SHELL_PROXYunsetSet for you by the shell functions horus env installs, carrying the name you actually typed (pip3 rather than pip) so the notice names the right command. Read, not written, by hand
HORUS_NO_PROXY_NOTICEunsetAny non-empty value silences the first-use notice those shell proxies print
HORUS_REAL_<TOOL>unsetAbsolute path to the real cargo, pip, cmake, conan or vcpkg, so the proxy does not have to find it on PATH. HORUS_REAL_CARGO, HORUS_REAL_PIP, HORUS_REAL_CMAKE, HORUS_REAL_CONAN, HORUS_REAL_VCPKG — one per tool, uppercased, and there is no HORUS_REAL_PIP3 because the pip3 function delegates to horus pip. A path that does not exist is ignored and the PATH search runs as usual. Nothing in HORUS sets these; the source comment at proxy.rs:416 saying horus env.sh does is stale, and the shell functions set only HORUS_SHELL_PROXY
HORUS_NETunset1 or true reports networking as enabled. horus doctor prints "use --net or HORUS_NET=1 to enable". horus doctor is the only reader, so setting it by hand only changes what doctor says. See below for what actually builds a networked binary
HORUS_MSGS_FFIunsetPath to a prebuilt generated-message library. .horus/generated/python/msgs.py hands it to ctypes.CDLL verbatim instead of searching the generated crate's target/ for a debug/release build
HORUS_MSGS_FFI_BUILDunset0, no, off or never (trimmed and lowercased) makes msgs.py refuse the one-off cargo build of the generated message crate it would otherwise run on the first publisher or subscriber, raising MessageLibraryError instead. Reading and constructing message classes needs neither the library nor the build
HORUS_STRICT_VERSIONunset1, true or yes turns the CLI-vs-installed version mismatch from a warning into a hard failure. For CI, which wants a mismatched toolchain to stop the job. The default is a warning on purpose — the unconditional failure it replaced printed a remedy that could not clear it, and left a whole release cohort unable to run horus new

HORUS_NET on its own does not build a networked binary, and neither does the capability list on its own. horus run --net sets HORUS_NET=1 and appends net to the capability list, which is written to HORUS_ENABLE (main.rs:2534-2542). The net capability deliberately contributes no Cargo feature to your own crate — enable_to_features returns an empty list for it, because your crate has no such feature and passing it produces "the package does not contain this feature: net". What turns it on is cargo_gen, which reads HORUS_ENABLE (or the top-level enable = [...] key in horus.toml) itself and adds net to the horus dependency in the generated manifest (horus_manager/src/cargo_gen.rs:1025-1037).

Installer

Read by install.sh and by install.ps1, which takes the same six names.

VariableDefaultEffect
HORUS_VERSIONunsetInstall exactly that release — binary and source, both from that one tag. Accepted with or without the leading v, so 0.4.0 and v0.4.0 are the same request. This is how you install a specific version, reinstall the current one, or roll back. See the callout below: HORUS also sets this name for plugins
HORUS_BUILD_FROM_SOURCEunsetExactly 1 makes the installer skip the prebuilt binary and compile the source at the resolved tag — the same tree the binary would have come from, not whatever is on main. The installer names it in its own error messages: when neither sha256sum nor shasum is available to verify a download, when the download fails, and when the installed binary will not run because of a glibc floor. It is the documented escape hatch from a failed install
HORUS_INSTALL_BRANCHunsetRelaxes a check. Developer escape hatch: build from source at that branch. Implies HORUS_BUILD_FROM_SOURCE=1 — a branch has no matching release binary, and pairing one with a branch tree is exactly the version skew this variable used to manufacture. It no longer changes only the clone; it forces the whole install to come from that branch. The installer refuses a value that is not a usable git ref. To pin a release, use HORUS_VERSION
HORUS_LOCAL_SOURCEunsetPath to a HORUS source tree already on disk. The installer builds that tree instead of cloning — no network fetch for the source, which is what an air-gapped robot needs. It is symlinked into the cache rather than copied, so horus clean -a removes the link and never the tree behind it. Refused if it is not a directory, or if it lacks either horus/Cargo.toml or horus_core/Cargo.toml (the message names only the first). Implies a source build: a local tree has no release tag, so there is no matching binary to pair with it
HORUS_PREFIXunsetInstall root override. The binary goes to $HORUS_PREFIX/bin, and $HORUS_PREFIX replaces ~/.horus for the state files and the source cache. Also how a root install says where it means to go: running as root with no SUDO_USER and no HORUS_PREFIX is refused rather than silently installing everything under /root. Pass the same value to uninstall.sh, which cannot otherwise find the install root or the manifest
HORUS_NO_SHELL_INTEGRATIONunsetAs above: skip horus env --init and touch no shell rc file

HORUS_PREFIX is read by the CLI as well as by the installer, and in two places. horus run searches $HORUS_PREFIX/cache for the installer-cached horus@<version> tree before ~/.cache/horus and ~/.horus/cache (horus_manager/src/commands/run/run_rust.rs:1023-1042), so a prefix install can build a single Rust file without you exporting HORUS_SOURCE by hand. And the version gate resolves its state root from the same variable (horus_manager/src/version.rs:63-76, :106-112), so installed_version and install_manifest.toml are read from under the prefix.

install.sh has not caught up with either. A prefix install still ends by telling you to export HORUS_SOURCE=… and by saying "the version gate is inactive for a prefix install: version.rs looks in ~/.horus" (install.sh:1108-1126). Neither statement is true of the Rust code cited above. Follow the code, not the epilogue.

These are read from the installer's environment, so with a pipe they belong on the right-hand side — HORUS_VERSION=v0.4.0 curl ... | bash puts the variable in curl's environment, where nothing reads it:

curl -fsSL https://github.com/softmata/horus/raw/main/install.sh | HORUS_VERSION=v0.4.0 bash

PowerShell has no VAR=value command prefix form at all, so there they are set as their own statement first:

$env:HORUS_VERSION = 'v0.4.0'
irm https://github.com/softmata/horus/raw/main/install.ps1 | iex
⚠️`HORUS_VERSION` is two different contracts

The installer reads HORUS_VERSION as a version pin. The CLI sets the same name in every plugin's environment, to the version of the horus that launched it (see the table below). They do not interact in normal use, but a plugin or script that shells out to the installer inherits the value HORUS set and silently pins the install to the version already running. Unset it first if you mean "latest":

env -u HORUS_VERSION sh -c 'curl -fsSL https://github.com/softmata/horus/raw/main/install.sh | bash'

Set by HORUS for the processes it starts

You do not set these; HORUS puts them into the environment of a process it spawns, so they are readable from your node, driver or plugin. They are listed because they are a contract: your code may read them, and a release that renamed one would break that code.

VariableSet byValue
HORUS_NODE_NAMEhorus launch (per node)The node's name from the launch file. Also read by the scheduler to name itself in horus node list
HORUS_NAMESPACEhorus launch, from the launch file's top-level namespaceThe shared-memory namespace every node in that launch shares. Also set by a test binary for its children, and only then — the write is confined to a test binary that resolved a test_-prefixed namespace and found the variable unset — so a spawned helper or interpreter lands in the same private namespace as the test that spawned it
HORUS_NODE_FILEhorus run <file>.pyAbsolute path to the Python file being run. The generated wrapper reads it with os.environ.get('HORUS_NODE_FILE') and exits 2 with an error if it is missing, so it is the contract behind every horus run of a Python node
HORUS_PARAM_<KEY>horus launch (per node), and every exec driverOne variable per parameter, uppercased. horus launch also maps - to _. The exec driver skips its three reserved keys, max_retries, restart_delay_ms and shutdown_timeout_ms, which it consumes itself
HORUS_PLUGINevery CLI pluginAlways 1. A plugin can use it to tell "invoked by horus" from "invoked directly by a user"
HORUS_VERSIONevery CLI pluginThe horus version that launched the plugin, for compatibility checks. The same name is a version pin when the installer reads it — see the callout above

horus run additionally injects the eight networking names listed under the manifest translation. The CLI sets HORUS_DRIVERS and HORUS_ENABLE in its own process environment from --drivers/--enable on horus run, horus build and horus test; HORUS_RECORD_SESSION from horus run --record; HORUS_NET from --net; HORUS_SIM_MODE and HORUS_SIM_TARGETS from --sim; and HORUS_ASSUME_YES from horus new --yes. A child inherits all of them.

Set but not read

These are put into a child's environment and nothing in HORUS reads them back. They are documented rather than hidden because they are visible to your code and because their emptiness is the answer to "why did setting this in my launch file do nothing?".

VariableSet byStatus
HORUS_NODE_RATE_HZhorus launch, when a node declares rate_hzNothing reads it. A node's rate comes from its own .rate() call; the launch file value is passed along but does not override it
HORUS_NODE_PRIORITYhorus launch, when a node declares priorityNothing reads it. The source says so at the assignment (launch.rs:1155-1158): kept only so a configured value leaves a trace
HORUS_NODE_NAMESPACEhorus launch, when a node declares namespaceNothing reads it. Shared-memory isolation is keyed off HORUS_NAMESPACE, which horus launch sets from the launch file's top-level namespace — so a per-node namespace: does not isolate that node
HORUS_INJECT_SESSIONhorus record inject --scriptThe session name, passed to the horus run child that executes the script. Nothing reads it
HORUS_INJECT_NODEShorus record inject --scriptComma-separated injected node names, or * for --all. Nothing reads it
HORUS_DRIVER_NAMEnothing on the live pathIntended to give an exec driver its own name from horus.toml. It is set only by ExecDriver::from_params (horus_core/src/drivers/exec_driver.rs:139), whose sole caller is a unit test at :355; the path horus.toml actually takes is ExecDriver::from_config (horus_core/src/drivers/mod.rs:229), which injects HORUS_PARAM_* and no name. An exec driver that reads HORUS_DRIVER_NAME today gets nothing. Earlier versions of this page listed it as set by every exec driver, which was wrong, and the comment at docs_contract.rs:774 still says the same thing

Internal and test-only

Set by HORUS itself or by its own test suite. Listed so you can recognise them, not so you can set them. Nothing here is reachable from an installed HORUS.

VariableWhereWhat it does
HORUS_CI_TOPIC_NAMEhorus_manager/src/bin/ci_test_node.rsTopic the CI test node publishes on. Default ci_test_topic
HORUS_CI_READY_FILEsameFile the CI test node touches once it is up, so a harness can wait on it
HORUS_CI_DURATION_MSsameHow long the CI test node runs, in milliseconds. Default 5000
HORUS_CI_BEST_EFFORTsamePresent, with any value: build the node without .rate(), so it stays on the main thread instead of moving to the RT executor
HORUS_CI_TOPIC_IN_CTORsamePresent, with any value: build the node's Topic in the constructor rather than in init()
HORUS_ESTOP_STDERR_CHILDhorus_core/src/scheduling/safety_monitor.rsMarks the child half of the test that points stderr at /dev/full and asserts the e-stop latch still engages
HORUS_CPP_FFI_EMIT_CHILDhorus_cpp/src/c_api.rsMarks the child half of the test that checks a C++ node's log actually reaches the terminal, which libtest cannot capture in-process
HORUS_LIVE5_GHOST_TOPIChorus_core/src/communication/topic/header.rsTopic name handed to the child that registers as a subscriber and is then killed with SIGKILL, to strand a participant entry
HORUS_LIVE5_GHOST_CAPACITYsameRing capacity that child must open with. Unset means the auto_capacity default, which is what a live reproduction against a real node wants
HORUS_TEST_REQUIRE_TOOLShorus_manager/src/commands/fmt.rsTurns "the formatter is not installed, skip" into a failure, so a runner without ruff cannot quietly make the formatting contracts vacuous
HORUS_TEST_BUILD_EXAMPLEShorus_manager/tests/examples_contract.rsOpts into the slow every-example-builds test
HORUS_DOCS_DIRthe docs_* contract suitesPoints them at a horus-docs checkout. Must contain content/docs, or it is ignored and ../horus-docs beside the repository is used
HORUS_DOCS_FILTERhorus_manager/tests/docs_examples*.rsRuns only the documentation examples whose name matches, while you are fixing one
HORUS_SOAK_DURATION_SEChorus_cpp/tests/cpp_soak_test.cppLength of the C++ soak test in seconds. Default 7200 (two hours); the test refuses anything at or below 60

The first four are read by the CI test node in src/bin/, which is shipped code by path but exists only to be driven by the introspection contract tests. HORUS_CI_BEST_EFFORT and HORUS_CI_TOPIC_IN_CTOR shape the two arrangements that report their metrics through different mechanisms and have failed independently of each other, which is why both exist.

The integration-test tree carries around a hundred further names of the same kind — a test, the process it spawns, and the topic or role they agree on. They are excluded on purpose, both from this page and from the check that guards it.

(HORUS_INSTALL_BRANCH used to be listed here. It is set by the person running install.sh, not by HORUS, so it belongs in the installer table above.)

There is no HORUS_LOG and no HORUS_API_KEY: the first is spelled HORUS_LOG_LEVEL, and the registry key is read from auth.json and nowhere else.