From 732504c8d69022e4324688b6e8ecac53e283c2b7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:22:01 -0700 Subject: [PATCH] fix(memory/byterover): brv child carries the served profile's cloud key, never the launch profile's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under gateway.multiplex_profiles os.environ holds the default profile's .env, so `_run_brv` building the child env from raw os.environ curated a secondary profile's turns into the DEFAULT profile's ByteRover cloud account (and prefetched the default's memories into the secondary's context). The local half was already profile-scoped (`_get_brv_cwd`). The child env now comes from `build_subprocess_env` and, under multiplex, strips the launch profile's residue and sets BRV_API_KEY only from the served profile's secret scope — a miss means no cloud key. Single-profile installs pass the process env through unchanged. Closes #108993 (report and fix direction by @jonpol01). --- plugins/memory/byterover/__init__.py | 27 ++++++- .../test_byterover_multiplex_child_env.py | 77 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/plugins/memory/test_byterover_multiplex_child_env.py diff --git a/plugins/memory/byterover/__init__.py b/plugins/memory/byterover/__init__.py index 42c2684b48..3cfdcd2125 100644 --- a/plugins/memory/byterover/__init__.py +++ b/plugins/memory/byterover/__init__.py @@ -76,6 +76,31 @@ def _resolve_brv_path() -> Optional[str]: return _cached_brv_path or None +def _brv_child_env(brv_path: str) -> Dict[str, str]: + """Env for the ``brv`` child. Under gateway.multiplex_profiles ``os.environ`` holds the + LAUNCH profile's ``.env``; the served profile's ``BRV_*`` (cloud identity) live only in its + secret scope, so they are resolved through the scope — a miss means no cloud key, never + another profile's — and the launch profile's ``.env`` residue is stripped. Outside multiplex + the process env IS this profile's own and is passed through unchanged.""" + from agent.secret_scope import UnscopedSecretError, get_secret, is_multiplex_active + from hermes_constants import get_hermes_home_override + from tools.environments.local import build_subprocess_env, strip_launch_profile_env + + env = build_subprocess_env(scrub_secrets=False) + if is_multiplex_active(): + env = strip_launch_profile_env(env, get_hermes_home_override()) + for key in [k for k in env if k.startswith("BRV_")]: + env.pop(key, None) + try: + api_key = get_secret("BRV_API_KEY", "") + except UnscopedSecretError: + api_key = "" + if api_key: + env["BRV_API_KEY"] = api_key + env["PATH"] = str(Path(brv_path).parent) + os.pathsep + env.get("PATH", "") + return env + + def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT, cwd: str = None) -> dict: """Run a brv CLI command. Returns {success, output, error}.""" global _cached_brv_path @@ -84,7 +109,7 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT, cwd: str = None) -> return {"success": False, "error": "brv CLI not found. Install: npm install -g byterover-cli"} effective_cwd = cwd or str(_get_brv_cwd()) Path(effective_cwd).mkdir(parents=True, exist_ok=True) - env = {**os.environ, "PATH": str(Path(brv_path).parent) + os.pathsep + os.environ.get("PATH", "")} + env = _brv_child_env(brv_path) try: result = subprocess.run( [brv_path] + args, capture_output=True, text=True, encoding='utf-8', errors='replace', diff --git a/tests/plugins/memory/test_byterover_multiplex_child_env.py b/tests/plugins/memory/test_byterover_multiplex_child_env.py new file mode 100644 index 0000000000..b7bdb6c85a --- /dev/null +++ b/tests/plugins/memory/test_byterover_multiplex_child_env.py @@ -0,0 +1,77 @@ +"""ByteRover's ``brv`` child carries the SERVED profile's cloud identity, never the launch profile's. + +Regression for #108993: ``_run_brv`` built the child env from raw ``os.environ``, which under +``gateway.multiplex_profiles`` is the default profile's ``.env`` — a secondary's turn curated into +the default's ByteRover cloud account while its local context tree was already profile-scoped.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from agent import secret_scope +from hermes_constants import reset_hermes_home_override, set_hermes_home_override +from plugins.memory import byterover + + +@pytest.fixture +def two_profiles(tmp_path, monkeypatch): + root = tmp_path / ".hermes" + prof_b = root / "profiles" / "b" + prof_b.mkdir(parents=True) + (root / ".env").write_text("BRV_API_KEY=DEFAULT-PROFILE-KEY\nHERMES_MODEL=default-model\n") + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setenv("BRV_API_KEY", "DEFAULT-PROFILE-KEY") # the gateway loaded default's .env at boot + monkeypatch.setenv("HERMES_MODEL", "default-model") + monkeypatch.setattr(byterover, "_resolve_brv_path", lambda: "/opt/brv/bin/brv") + captured = {} + + def fake_run(cmd, **kwargs): + captured["env"] = kwargs["env"] + + class _R: + returncode, stdout, stderr = 0, "", "" + + return _R() + + monkeypatch.setattr(byterover.subprocess, "run", fake_run) + return root, prof_b, captured + + +def _served_turn(prof_home: Path, scope: dict): + secret_scope.set_multiplex_active(True) + home_tok = set_hermes_home_override(str(prof_home)) + scope_tok = secret_scope.set_secret_scope(scope) + return home_tok, scope_tok + + +def _end_turn(tokens): + home_tok, scope_tok = tokens + secret_scope.reset_secret_scope(scope_tok) + reset_hermes_home_override(home_tok) + secret_scope.set_multiplex_active(False) + + +def test_secondary_profile_child_uses_its_own_key_not_defaults(two_profiles): + _root, prof_b, captured = two_profiles + tokens = _served_turn(prof_b, {"BRV_API_KEY": "PROFILE-B-KEY"}) + try: + byterover._run_brv(["query", "--", "hello"], cwd=str(prof_b / "byterover")) + finally: + _end_turn(tokens) + env = captured["env"] + assert env["BRV_API_KEY"] == "PROFILE-B-KEY" + assert env["HERMES_HOME"] == str(prof_b) + assert "HERMES_MODEL" not in env # launch profile's .env residue is stripped too + assert env["PATH"].startswith("/opt/brv/bin") + + +def test_secondary_without_key_gets_no_key_never_defaults(two_profiles): + _root, prof_b, captured = two_profiles + tokens = _served_turn(prof_b, {"OTHER": "x"}) + try: + byterover._run_brv(["curate", "--", "note"], cwd=str(prof_b / "byterover")) + finally: + _end_turn(tokens) + assert "BRV_API_KEY" not in captured["env"]