Phase 2 review follow-ups: - OpenViking overlaid the bound profile's whole .env after the scrub, so that profile's bot, dashboard and relay tokens reached the server again. The overlaid env is now scrubbed a second time for Tier 1. Provider keys still pass, and they still come from the bound profile. - The strict per-home manifest read raised on any unreadable entry under <home>/plugins, which failed every child spawn for that profile. It is now strict only where the manifest is known to be a platform's: bundled or plugins/platforms/. Dunder and dot directories, unsearchable plugin directories and unreadable flat plugins/* manifests are skipped with a warning, because they can't load as plugins either. - The per-home cache is keyed on hermes_home_key(), so a symlinked alias no longer gets a second entry. - The modal snapshot test swapped hermes_cli.config for a stub, which the policy import can no longer use. HERMES_HOME already points the real module at the test home, so the stub is gone. - The env_passthrough docstring now describes declared names rather than the dropped prefix rule.
163 lines
7.3 KiB
Python
163 lines
7.3 KiB
Python
"""Real children started by the production spawn sites observe the secret scrub."""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tests.tools._child_env_fixtures import child_env # noqa: F401
|
|
|
|
_TIER1 = ("TELEGRAM_BOT_TOKEN", "GATEWAY_RELAY_SECRET")
|
|
_PROVIDER = "OPENAI_API_KEY"
|
|
|
|
|
|
def _plant(monkeypatch):
|
|
for name in (*_TIER1, _PROVIDER):
|
|
monkeypatch.setenv(name, f"fake-{name.lower()}")
|
|
|
|
|
|
def _probe_script(path: Path, out: Path, names) -> Path:
|
|
path.write_text(
|
|
f"#!{sys.executable}\nimport json, os\n"
|
|
f"open({str(out)!r}, 'w').write(json.dumps({{n: os.environ.get(n) for n in {list(names)!r}}}))\n",
|
|
encoding="utf-8")
|
|
path.chmod(0o755)
|
|
return path
|
|
|
|
|
|
def _compute_host_seen(child_env, monkeypatch, names):
|
|
from tui_gateway.host_supervisor import HostSupervisor
|
|
|
|
hello = ("import json, os, sys; print(json.dumps({'type': 'hello', 'seen': "
|
|
f"{{n: os.environ.get(n) for n in {names!r}}}}}), flush=True); sys.stdin.readline()")
|
|
sup = HostSupervisor(registry_path=child_env / "host.json", argv=[sys.executable, "-c", hello],
|
|
cwd=child_env, expected_build_sha="unknown", autostart=False)
|
|
try:
|
|
sup.start()
|
|
return sup._hello["seen"]
|
|
finally:
|
|
sup.shutdown()
|
|
|
|
|
|
def _openviking_server_seen(child_env, monkeypatch, names):
|
|
import subprocess
|
|
|
|
import plugins.memory.openviking as ov
|
|
|
|
out = child_env / "seen.json"
|
|
probe = _probe_script(child_env / "openviking-server", out, names)
|
|
monkeypatch.setattr(ov, "_local_openviking_port_is_open", lambda host, port: False)
|
|
monkeypatch.setattr(ov.shutil, "which", lambda name: str(probe))
|
|
real_popen, children = subprocess.Popen, []
|
|
|
|
def _record(*args, **kwargs):
|
|
children.append(real_popen(*args, **kwargs))
|
|
return children[-1]
|
|
|
|
monkeypatch.setattr(ov.subprocess, "Popen", _record)
|
|
state, _ = ov._start_local_openviking_server("http://127.0.0.1:1933")
|
|
assert state == ov._LOCAL_SERVER_STARTED
|
|
children[0].wait(timeout=30)
|
|
return json.loads(out.read_text(encoding="utf-8-sig"))
|
|
|
|
|
|
def test_compute_host_is_hermes_and_keeps_its_full_environment(child_env, monkeypatch):
|
|
# It runs agent turns for the dashboard, so it needs what the turn needs: keys set only in the
|
|
# process env (Docker -e, systemd) are not in any .env for it to reload (#65895).
|
|
_plant(monkeypatch)
|
|
names = [*_TIER1, _PROVIDER]
|
|
assert _compute_host_seen(child_env, monkeypatch, names) == {n: f"fake-{n.lower()}" for n in names}
|
|
|
|
|
|
@pytest.mark.platforms("posix")
|
|
def test_openviking_server_keeps_provider_keys_but_never_tier1_secrets(child_env, monkeypatch):
|
|
# Its embedding/VLM models call providers, so provider keys pass. Bot and relay tokens never do.
|
|
# It finds ov.conf through OPENVIKING_CONFIG_FILE or HOME; Hermes' PYTHONPATH would shadow its
|
|
# own site-packages (#78153).
|
|
_plant(monkeypatch)
|
|
monkeypatch.setenv("TERMINAL_HOME_MODE", "profile") # HOME stays the user's even so
|
|
monkeypatch.setenv("OPENVIKING_CONFIG_FILE", str(child_env / "ov.conf"))
|
|
monkeypatch.setenv("PYTHONPATH", str(child_env / "hermes-venv"))
|
|
own = {"OPENVIKING_CONFIG_FILE": str(child_env / "ov.conf"), "HOME": str(child_env), "PYTHONPATH": None}
|
|
seen = _openviking_server_seen(child_env, monkeypatch, [*_TIER1, _PROVIDER, *own])
|
|
assert seen == {"TELEGRAM_BOT_TOKEN": None, "GATEWAY_RELAY_SECRET": None,
|
|
_PROVIDER: "fake-openai_api_key", **own}
|
|
|
|
|
|
def test_openviking_server_gets_the_bound_profiles_provider_keys_not_its_bot_tokens(child_env, monkeypatch):
|
|
# A routed profile's own .env is overlaid for its provider keys; its bot and dashboard
|
|
# secrets must not ride along, and the launch profile's keys must not either.
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
_plant(monkeypatch)
|
|
routed = child_env / "profiles" / "b"
|
|
routed.mkdir(parents=True)
|
|
(routed / ".env").write_text(
|
|
"TELEGRAM_BOT_TOKEN=b-bot\nHERMES_DASHBOARD_BASIC_AUTH_PASSWORD=b-dash\nOPENAI_API_KEY=b-openai\n",
|
|
encoding="utf-8")
|
|
token = set_hermes_home_override(routed)
|
|
try:
|
|
seen = _openviking_server_seen(
|
|
child_env, monkeypatch, ["TELEGRAM_BOT_TOKEN", "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", _PROVIDER])
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
assert seen == {"TELEGRAM_BOT_TOKEN": None, "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD": None, _PROVIDER: "b-openai"}
|
|
|
|
|
|
@pytest.mark.platforms("posix") # the stand-in binaries are shebang scripts
|
|
@pytest.mark.parametrize("site", ["lsp_server", "lsp_go_install", "lsp_npm_install", "raft_bridge", "buzz_cli"])
|
|
def test_third_party_children_never_see_hermes_credentials(child_env, monkeypatch, site):
|
|
_plant(monkeypatch)
|
|
# Profile home mode (the container default) re-points HOME; the CLIs whose own logins live
|
|
# under the user's HOME get it back, language servers and installers follow the terminal.
|
|
monkeypatch.setenv("TERMINAL_HOME_MODE", "profile")
|
|
(child_env / "hermes" / "home").mkdir(parents=True, exist_ok=True)
|
|
out = child_env / "seen.json"
|
|
own = {"lsp_server": "LSP_OWN_SETTING", "lsp_go_install": "GOBIN", "lsp_npm_install": "PATH",
|
|
"raft_bridge": "RAFT_CHANNEL_TOKEN", "buzz_cli": "BUZZ_PRIVATE_KEY"}[site]
|
|
probe = _probe_script(child_env / "probe", out, [*_TIER1, _PROVIDER, own, "HOME"])
|
|
|
|
if site == "lsp_server":
|
|
from agent.lsp.client import LSPClient
|
|
|
|
async def _run():
|
|
client = LSPClient(server_id="probe", workspace_root=str(child_env), command=[str(probe)],
|
|
env={"LSP_OWN_SETTING": "kept"})
|
|
await client._spawn()
|
|
await client._proc.wait()
|
|
for task in (client._reader_task, client._stderr_task):
|
|
task.cancel()
|
|
asyncio.run(_run())
|
|
elif site == "lsp_go_install":
|
|
from agent.lsp import install
|
|
|
|
with patch.object(install.shutil, "which", return_value=str(probe)):
|
|
install._install_go("example.com/probe@latest", "probe")
|
|
elif site == "lsp_npm_install":
|
|
from agent.lsp import install
|
|
|
|
with patch.object(install, "find_node_executable", return_value=str(probe)):
|
|
install._install_npm("probe-language-server", "probe")
|
|
elif site == "buzz_cli":
|
|
from plugins.platforms.buzz.adapter import _exec_buzz
|
|
|
|
asyncio.run(_exec_buzz(str(probe), [], relay_url="wss://relay.invalid", private_key="buzz-own"))
|
|
else:
|
|
from gateway.config import PlatformConfig
|
|
from plugins.platforms.raft.adapter import RaftAdapter
|
|
|
|
monkeypatch.setenv("RAFT_PROFILE", "probe")
|
|
config = PlatformConfig(enabled=True, extra={"bridge_token": "bridge-own", "runtime_session": "default", "port": 0})
|
|
adapter = RaftAdapter(config)
|
|
with patch("plugins.platforms.raft.adapter.shutil.which", return_value=str(probe)):
|
|
adapter._spawn_bridge(4321)
|
|
adapter._bridge_process.wait(timeout=30)
|
|
|
|
seen = json.loads(out.read_text(encoding="utf-8-sig"))
|
|
assert {k: seen[k] for k in (*_TIER1, _PROVIDER)} == dict.fromkeys((*_TIER1, _PROVIDER))
|
|
assert seen[own] # the child's own configuration still arrives
|
|
user_home = site in ("raft_bridge", "buzz_cli")
|
|
assert seen["HOME"] == str(child_env if user_home else child_env / "hermes" / "home")
|