Skip to content

fix(env): scope terminal config re-bridge to the true process profile - #97014

Closed
Bergmann89 wants to merge 1 commit into
NousResearch:mainfrom
Bergmann89:fix/terminal-bridge-profile-scope
Closed

Bergmann89 wants to merge 1 commit into
NousResearch:mainfrom
Bergmann89:fix/terminal-bridge-profile-scope

Conversation

@Bergmann89

Copy link
Copy Markdown

What does this PR do?

In a multiplexed dashboard/gateway (one process serving every profile on the host), a secondary profile's terminal.backend could leak into the shared os.environ and silently override the launch profile's terminal backend. A profile configured for terminal.backend: ssh would end up running every command on the local host because a sibling profile with backend: local had polluted the shared environment.

The tell is an asymmetric env state on the launch session:

TERMINAL_ENV=local             # leaked from a sibling profile
TERMINAL_SSH_HOST=10.10.0.103  # still the launch profile's — untouched

A restart does not help: as soon as the sibling profile is touched again (a turn, a cron tick), the shared env is re-poisoned.

Root cause

env_loader._reapply_terminal_config_bridge() guards "only re-bridge config into the shared os.environ when this load is for the process's own profile":

if Path(home_path).resolve() != _process_hermes_home().resolve():
    return

_process_hermes_home() delegated to get_hermes_home(), which follows the context-local set_hermes_home_override a per-request task installs. When a per-turn handler is scoped to a secondary profile (via _config_profile_scope / set_hermes_home_override) and triggers a load_hermes_dotenv() reload, both sides of the comparison resolve to that secondary profile — the guard passes, and the bridge runs apply_terminal_config_to_env(env=None) against the shared os.environ, writing the wrong profile's terminal.backend.

Because the secondary profile's config.yaml carries no TERMINAL_SSH_* keys, only TERMINAL_ENV is overwritten (explicit-keys-only bridge semantics), producing the asymmetric state above.

The helper was originally introduced for the config-cache key (where following the active home is correct) and later reused for the terminal bridge guard, where the override-following semantics are wrong.

The fix

Delegate _process_hermes_home() to get_process_hermes_home(), the override-immune resolver built for exactly this — it reflects the scope the process was launched under, ignoring per-task overrides. Both call sites (the bridge guard and the secrets-cache reuse check in _load_secrets_config) want the true process home.

This is the correct boundary: in a multiplex process os.environ belongs to the launch profile. A secondary profile's terminal config must reach its own sessions through session-scoped mechanisms (see Related), never by mutating the shared process environment.

Related Issue

Same bug class (cross-profile terminal-backend leak under multiplexing), addressed here at a bridge site that no open PR touches:

Each of those fixes a different bridge site. This PR fixes a third, independent one — env_loader._reapply_terminal_config_bridge() — which runs on every load_hermes_dotenv() and is not covered by any of the above. Fixing only the others leaves this path as a live leak (and vice versa).

Happy to fold this into #96992's tracking or coordinate with #94206's author if the maintainers prefer to consolidate the cluster.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • hermes_cli/env_loader.py — _process_hermes_home() now delegates to hermes_constants.get_process_hermes_home() (override-immune) instead of get_hermes_home() (which follows the per-task context override). Docstring expanded to explain why both callers require the true process home.
  • tests/hermes_cli/test_terminal_bridge_profile_scope.py — new regression test:
    • test_process_hermes_home_ignores_task_override — the resolver ignores an active set_hermes_home_override.
    • test_secondary_profile_reload_does_not_bridge_into_shared_env — a secondary-profile reload leaves the launch profile's TERMINAL_ENV and TERMINAL_SSH_* in os.environ untouched.

How to Test

Reproduce the leak and confirm the fix:

  1. Configure two profiles: laptop (terminal.backend: ssh, with ssh_host/ssh_user) and tommy (terminal.backend: local, no ssh keys).

  2. Launch under laptop (HERMES_HOME=…/laptop), so the shared env carries TERMINAL_ENV=ssh + TERMINAL_SSH_*.

  3. Simulate a per-turn handler scoped to the secondary profile and drive a reload for its home:

    from hermes_constants import set_hermes_home_override, reset_hermes_home_override
    import hermes_cli.env_loader as env_loader
    
    tok = set_hermes_home_override(str(tommy_home))
    try:
        env_loader._reapply_terminal_config_bridge(tommy_home)
    finally:
        reset_hermes_home_override(tok)
  4. Before: os.environ["TERMINAL_ENV"] == "local" (tommy's backend leaked), TERMINAL_SSH_HOST still laptop's — the launch session silently runs locally. After: TERMINAL_ENV stays "ssh"; nothing leaks.

Automated:

pytest tests/hermes_cli/test_terminal_bridge_profile_scope.py -q

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(env): …)
  • I searched for existing PRs/issues to make sure this isn't a duplicate (found a related cluster — see Related Issue; this bridge site is uncovered)
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes
  • I've tested on my platform: Linux (aarch64, Docker)

Documentation & Housekeeping

  • I've updated relevant documentation (docstrings) — expanded _process_hermes_home docstring; no user-facing docs affected
  • cli-config.yaml.example — N/A (no config keys added/changed)
  • CONTRIBUTING.md / AGENTS.md — N/A (no architecture/workflow change)
  • Cross-platform impact considered — resolver swap is platform-agnostic; single-profile / CLI behaviour is unchanged (with no active override the two resolvers are identical)
  • Tool descriptions/schemas — N/A (no tool behaviour change)

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard backend/ssh SSH remote execution area/profiles Multi-profile isolation, HERMES_HOME scoping area/sessions Session lifecycle, resume, persistence, history sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 28, 2026

@monerostar monerostar left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ubuntu 26.04 on linux-5800x (kernel 7.0.0-30-generic). This box runs more than one Hermes profile in one process family, so the shared os.environ leak is the class we care about.

Copied the PR tests to /tmp so pytest would import each tree:

  • main 8c098e9e8: both fail. _process_hermes_home() follows set_hermes_home_override (get_hermes_home()). After a secondary-profile reload, TERMINAL_ENV goes ssh -> local.
  • this PR 6eb4b5050: 2 passed in 0.03s. Process home stays the launch home. TERMINAL_ENV stays ssh.

Python 3.11.15. Looks good.

muhifni added a commit to muhifni/hermes-agent that referenced this pull request Sep 2, 2026
A multiplexed Hermes process (gateway.multiplex_profiles, unified
dashboard/TUI, or cron) can serve several profiles at once. Terminal
settings used to resolve through process-global TERMINAL_* env vars plus
the one-shot _ensure_terminal_env_bridged() guard. The first profile to
touch a terminal tool after startup therefore pinned its backend and
other policy (mounts, SSH target, network, cwd, resources) onto every
later profile until restart.

This is a correctness and sandbox-boundary bug: a local profile can run
inside another profile's docker sandbox, and a docker/ssh profile can be
dropped onto the launch host. Repro lineages: canonical NousResearch#68559, gateway
backend latch NousResearch#94200, dashboard/container symptoms NousResearch#98581/NousResearch#96992.

Fix with an authoritative profile terminal policy seam, analogous to
agent/secret_scope.py:

- tools/terminal_scope.py adds a ContextVar that holds the active
  profile's complete effective terminal policy. Projection order is
  defined defaults + supplemental tool defaults <- profile .env
  TERMINAL_* values <- explicit terminal: config.yaml keys. Once a scope
  is bound, missing values never fall through to ambient os.environ.
- install_profile_terminal_scope() fail-closes: unreadable/malformed
  policy installs a refusal scope; terminal_tool / execute_code refuse
  execution instead of inheriting launch-process policy.
- tools/terminal_tool.py routes TERMINAL_* reads through the scope-aware
  _tenv() helper and suppresses the process-env bridge while scoped.
- gateway/run.py, tui_gateway/server.py and cron/scheduler.py install the
  same profile terminal scope at their in-process profile boundaries.
- Other scoped readers from the first patch (prompt/cwd/media/footer)
  remain routed through the same seam.

Tests now cover the review-requested matrix: polluted launch profile A
with sensitive mounts/SSH/CWD/network/resource policy; profile B with
backend-only, empty terminal config, or .env-only selections cannot
observe A's values. They also cover malformed/unreadable policy refusal,
terminal_tool refusal, gateway cleanup including error paths,
dashboard/TUI session scope, and cron install/reset lifecycles.

Prior art / lineage: x7peeps NousResearch#68611 (original ContextVar direction),
100yenadmin NousResearch#79117/NousResearch#78030, liuhao1024 NousResearch#94206/NousResearch#98589, and complementary
Bergmann89 NousResearch#97014 (env_loader re-bridge containment).

Fixes NousResearch#68559
Refs NousResearch#94200, NousResearch#98581, NousResearch#96992
@Bergmann89
Bergmann89 force-pushed the fix/terminal-bridge-profile-scope branch from 6eb4b50 to 5c82f91 Compare September 4, 2026 18:53
In a multiplex dashboard (one process serving every profile on the host),
a secondary profile's terminal.backend could leak into the shared
os.environ and silently override the launch profile's terminal backend.
A profile configured for terminal.backend: ssh would find itself running
every command locally because a sibling profile with backend: local had
polluted the shared environment.

The tell is an asymmetric env state on the launch session:

    TERMINAL_ENV=local            # leaked from a sibling profile
    TERMINAL_SSH_HOST=10.10.0.103 # still the launch profile's, untouched

A restart does not help: as soon as the sibling profile is touched again
(a turn, a cron tick), the shared env is re-poisoned.

Root cause: env_loader._reapply_terminal_config_bridge() guards "only
re-bridge config into the shared os.environ when this load is for the
process's own profile" via _process_hermes_home(). That helper delegated
to get_hermes_home(), which follows the context-local
set_hermes_home_override a per-request task installs. When a per-turn
handler is scoped to a secondary profile and triggers a dotenv reload,
both sides of the comparison resolve to that secondary profile, the guard
passes, and the bridge writes the wrong profile's terminal.backend into
the shared environment. Because the secondary profile's config carries no
TERMINAL_SSH_* keys, only TERMINAL_ENV is overwritten, producing the
asymmetric state above.

The helper was introduced for the config-cache key (where following the
active home is correct) and later reused for the terminal bridge guard,
where the override-following semantics are wrong.

Fix: delegate _process_hermes_home() to get_process_hermes_home(), the
override-immune resolver built for exactly this. It reflects the scope the
process was launched under, ignoring per-task overrides. Both call sites
(the bridge guard and the secrets-cache reuse check) want the true process
home. Single-profile / CLI behaviour is unchanged: with no active
override the two resolvers are identical.

Complements the terminal_tool._active_environments session-key fix: that
scopes the per-session environment cache; this keeps the shared os.environ
the cache reads TERMINAL_ENV from uncontaminated in the first place.

Adds tests/hermes_cli/test_terminal_bridge_profile_scope.py covering both
the override-immune resolver and the no-leak reload behaviour.
@kshitijk4poor

Copy link
Copy Markdown

Landed on main via #103625 as 2e24e06e55 — your commit cherry-picked unchanged with authorship preserved, @Bergmann89. Live-verified: scoping to a secondary profile and reloading its .env (what the multiplex cron tick does) leaked TERMINAL_ENV=docker + that profile's TERMINAL_DOCKER_VOLUMES into the shared env on main; with your fix both stay unset. Thanks — closing in favour of the merged salvage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping area/sessions Session lifecycle, resume, persistence, history backend/ssh SSH remote execution comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants