Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions plugins/google_meet/meet_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
from pathlib import Path
from typing import Optional

from tools.environments.local import build_subprocess_env

# Match ``https://meet.google.com/abc-defg-hij`` or ``.../lookup/...`` — the
# short three-segment code or a lookup URL. Anything else is rejected.
MEET_URL_RE = re.compile(
Expand Down Expand Up @@ -444,6 +446,34 @@ def _mac_audio_device_index(device_name: str) -> str:
return "0"


def _build_chrome_env(rt: dict) -> dict:
"""Build the Chromium child environment for the bot's Chrome launch.

Starts from the sanitized subprocess env factory (which scrubs gateway
credentials such as provider keys, the vault token and ``*_PASSWORD``
vars) instead of copying ``os.environ`` raw, then adds the bot's own
non-secret ``PULSE_SOURCE`` override when realtime audio is live on
Linux so Chrome's fake mic reads the audio we generate.
"""
chrome_env = build_subprocess_env()
if rt.get("enabled") and rt.get("bridge_info") and rt["bridge_info"].get("platform") == "linux":
chrome_env["PULSE_SOURCE"] = rt["bridge_info"].get("device_name", "")
return chrome_env


def _apply_chrome_env(chrome_env: dict) -> None:
"""Install the sanitized Chrome env as the process env.

Playwright's ``launch()`` doesn't take an env dict, so the child
Chromium inherits whatever the bot's process env holds at launch time.
Replace ``os.environ`` wholesale (rather than merging key-by-key) so
credentials that ``_build_chrome_env`` scrubbed cannot linger in the
merged result.
"""
os.environ.clear()
os.environ.update(chrome_env)


def run_bot() -> int: # noqa: C901 — orchestration, explicit branches
url = os.environ.get("HERMES_MEET_URL", "").strip()
out_dir_env = os.environ.get("HERMES_MEET_OUT_DIR", "").strip()
Expand Down Expand Up @@ -528,7 +558,7 @@ def _on_signal(_sig, _frame):

# Chrome env: if realtime is live on Linux, point PULSE_SOURCE at the
# virtual source so Chrome's fake mic reads the audio we generate.
chrome_env = os.environ.copy()
chrome_env = _build_chrome_env(rt)
chrome_args = [
"--use-fake-ui-for-media-stream",
"--disable-blink-features=AutomationControlled",
Expand All @@ -544,8 +574,7 @@ def _on_signal(_sig, _frame):
with sync_playwright() as pw:
# Playwright's launch() doesn't take env; we set PULSE_SOURCE
# via the process env before launch so the child Chrome inherits it.
for k, v in chrome_env.items():
os.environ[k] = v
_apply_chrome_env(chrome_env)
browser = pw.chromium.launch(
headless=not headed,
args=chrome_args,
Expand Down
5 changes: 4 additions & 1 deletion plugins/google_meet/process_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from typing import Any, Dict, Optional

from hermes_constants import get_hermes_home
from tools.environments.local import build_subprocess_env

# File + directory layout (under $HERMES_HOME):
#
Expand Down Expand Up @@ -132,7 +133,9 @@ def start(
except OSError:
pass

env = os.environ.copy()
# Build the meet_bot child env from the sanitized subprocess env factory
# (scrubs gateway credentials) and pass the meeting's own config on top.
env = build_subprocess_env()
env["HERMES_MEET_URL"] = url
env["HERMES_MEET_OUT_DIR"] = str(out)
env["HERMES_MEET_GUEST_NAME"] = guest_name
Expand Down
5 changes: 4 additions & 1 deletion plugins/memory/byterover/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from typing import Any, Dict, List, Optional

from agent.memory_provider import MemoryProvider
from tools.environments.local import build_subprocess_env
from tools.registry import tool_error

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -134,7 +135,9 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
effective_cwd = cwd or str(_get_brv_cwd())
Path(effective_cwd).mkdir(parents=True, exist_ok=True)

env = os.environ.copy()
# Sanitized child env (scrubs gateway credentials), then put the brv CLI's
# own bin dir first on PATH so the resolved CLI wins over any other copy.
env = build_subprocess_env()
brv_bin_dir = str(Path(brv_path).parent)
env["PATH"] = brv_bin_dir + os.pathsep + env.get("PATH", "")

Expand Down
5 changes: 4 additions & 1 deletion plugins/platforms/buzz/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@

from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
from tools.environments.local import build_subprocess_env


def _get_scoped_secret(name, default=None):
Expand Down Expand Up @@ -290,7 +291,9 @@ async def _exec_buzz(
The private key travels via the subprocess environment only — it never
appears in argv, so process listings and error logs stay clean.
"""
env = os.environ.copy()
# Build the CLI child env from the sanitized subprocess env factory
# (scrubs gateway credentials), then add the relay + key the CLI needs.
env = build_subprocess_env()
env["BUZZ_RELAY_URL"] = relay_url
env["BUZZ_PRIVATE_KEY"] = private_key
proc = await asyncio.create_subprocess_exec(
Expand Down
5 changes: 4 additions & 1 deletion plugins/platforms/photon/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@

from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
from tools.environments.local import build_subprocess_env


def _get_scoped_secret(name, default=None):
Expand Down Expand Up @@ -1595,7 +1596,9 @@ async def _start_sidecar(self) -> None:
await asyncio.to_thread(_reinstall_sidecar_deps)
await self._reap_stale_sidecar()

env = os.environ.copy()
# Sidecar child env: sanitized factory (scrubs gateway credentials),
# then the photon-specific values the sidecar is entitled to.
env = build_subprocess_env()
env["PHOTON_PROJECT_ID"] = self._project_id
env["PHOTON_PROJECT_SECRET"] = self._project_secret
env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port)
Expand Down
48 changes: 48 additions & 0 deletions tests/gateway/test_buzz_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,51 @@ async def fake_exec(cli_path, args, *, relay_url, private_key, input_text=None,
assert all("nsec1x" not in str(a) for a in captured["args"])


# ── Sidecar env hygiene ──────────────────────────────────────────────────


class TestExecBuzzEnv:

@pytest.mark.asyncio
async def test_exec_buzz_child_env_scrubbed_keeps_relay_and_key(self, monkeypatch):
"""The buzz CLI child must not inherit gateway credentials; the
relay URL and the plugin's own private key still travel via env."""
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "relay-secret")
monkeypatch.setenv("EMAIL_PASSWORD", "mail-pass")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")

captured = {}

class _FakeProc:
returncode = 0

async def communicate(self, input=None):
return b"", b""

async def _fake_exec(*args, **kwargs):
captured["args"] = args
captured["env"] = kwargs.get("env")
return _FakeProc()

monkeypatch.setattr(_buzz_mod.asyncio, "create_subprocess_exec", _fake_exec)

rc, out, err = await _buzz_mod._exec_buzz(
"buzz",
["messages", "send", "--channel", CHANNEL, "--content", "-"],
relay_url="wss://relay.test",
private_key="nsec1test",
input_text="hello",
)
assert rc == 0
assert out == "" and err == ""

env = captured["env"]
assert "GATEWAY_RELAY_SECRET" not in env
assert "EMAIL_PASSWORD" not in env
assert "OPENAI_API_KEY" not in env
# The plugin's own child-only values are applied on top of the
# sanitized env.
assert env["BUZZ_RELAY_URL"] == "wss://relay.test"
assert env["BUZZ_PRIVATE_KEY"] == "nsec1test"


41 changes: 41 additions & 0 deletions tests/plugins/memory/test_byterover_provider.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Tests for the ByteRover memory provider config gates."""

import os
from pathlib import Path

from plugins.memory.byterover import ByteRoverMemoryProvider


Expand All @@ -16,3 +19,41 @@ def test_auto_extract_false_skips_sync_turn(monkeypatch):
assert provider._sync_thread is None


def test_run_brv_child_env_scrubbed_keeps_path_prepend(monkeypatch):
"""The brv CLI child must not inherit gateway credentials, while the
CLI's own bin dir stays first on PATH."""
import plugins.memory.byterover as brv_mod

monkeypatch.setenv("GATEWAY_RELAY_SECRET", "relay-secret")
monkeypatch.setenv("EMAIL_PASSWORD", "mail-pass")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(
brv_mod, "_resolve_brv_path", lambda: "C:/tmp/fake-brv/brv.cmd"
)

captured = {}

class _Result:
returncode = 0
stdout = "ok"
stderr = ""

def _fake_run(cmd, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs["env"]
return _Result()

monkeypatch.setattr(brv_mod.subprocess, "run", _fake_run)

res = brv_mod._run_brv(["query", "x"])
assert res["success"] is True

env = captured["env"]
assert "GATEWAY_RELAY_SECRET" not in env
assert "EMAIL_PASSWORD" not in env
assert "OPENAI_API_KEY" not in env
# The plugin's own PATH prepend still applies on top of the sanitized env.
brv_bin_dir = str(Path("C:/tmp/fake-brv/brv.cmd").parent)
assert env["PATH"].startswith(brv_bin_dir + os.pathsep)


69 changes: 69 additions & 0 deletions tests/plugins/platforms/photon/test_sidecar_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,72 @@ def poll(self) -> int:
"dispatched via asyncio.to_thread so a 10s node spawn can't freeze "
"every other platform on the gateway loop"
)


@pytest.mark.asyncio
async def test_start_sidecar_env_scrubbed_keeps_photon_vars(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""The Node sidecar child must not inherit gateway credentials; the
photon-specific values it is entitled to still travel via env."""
adapter = _make_adapter(monkeypatch)

async def _no_reap() -> None:
pass

monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "relay-secret")
monkeypatch.setenv("EMAIL_PASSWORD", "mail-pass")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
(tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True)
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)

spawned: Dict[str, Any] = {}
monkeypatch.setattr(
"hermes_cli._subprocess_compat.windows_hide_flags",
lambda: 0x08000000,
)

class _PatchResult:
returncode = 0
stdout = ""
stderr = ""

monkeypatch.setattr(
photon_adapter.subprocess, "run", lambda cmd, **kwargs: _PatchResult()
)

class _FakeProc:
pid = 999
stdout = None
stdin = None

@staticmethod
def poll() -> None:
return None

def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc:
spawned["kwargs"] = kwargs
return _FakeProc()

monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen)

class _HealthyClient(_ProbeClient):
async def post(self, *a: Any, **k: Any) -> Any:
class _Resp:
status_code = 200

return _Resp()

monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient)

await adapter._start_sidecar()

env = spawned["kwargs"]["env"]
assert "GATEWAY_RELAY_SECRET" not in env
assert "EMAIL_PASSWORD" not in env
assert "OPENAI_API_KEY" not in env
assert env["PHOTON_PROJECT_ID"] == "test-project-id"
assert env["PHOTON_PROJECT_SECRET"] == "test-project-secret"
assert env["PHOTON_SIDECAR_TOKEN"] == adapter._sidecar_token
assert env["PHOTON_SIDECAR_WATCH_STDIN"] == "1"
Loading
Loading