Review follow-ups on the declared-secret policy: - A profile's user-installed platform plugins declared secrets into one process-wide set, read from the launch home at import. Under multiplex that stripped profile B's same-named user variable and missed profile A's own declarations. Bundled manifests stay process-wide; user manifests are now read per bound home, cached until that home's plugin dirs change, and are Tier 1 for that profile only. - The declaration scans no longer fail open. A registry error is no longer swallowed into an empty set, non-string required_env entries are skipped rather than breaking the set, and a manifest that cannot be read raises instead of vanishing from the policy. A malformed manifest still declares nothing. - A plugin manifest can no longer reclassify a core-declared name such as OPENAI_API_KEY; the same rule the config form applies. - The dashboard basic-auth password and signing secret, the OIDC client secret and the drain bearer move to Tier 1. Credentialed CLIs (claude, codex) no longer receive them. - openviking-server starts from served_profile_child_env, so it gets the bound profile's provider keys, never the launch profile's. With no bound profile under multiplex the start is refused. - NOUS_API_KEY and QWEN_API_KEY are listed statically. Discovering provider plugins while the policy module imported re-mirrored them over a plugin's own auth registry entry (tests/providers/test_auth_registry_import_order.py).
144 lines
6.3 KiB
Python
144 lines
6.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}
|
|
|
|
|
|
@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")
|