Two related fixes for remote terminal backends (Modal/Docker): 1. NEW: Credential file mounting system Skills declare required_credential_files in frontmatter. Files are mounted into Docker (read-only bind mounts) and Modal (mounts at creation + sync via exec on each command for mid-session changes). Google Workspace skill updated with the new field. 2. FIX: Docker backend now includes env_passthrough vars Skills that declare required_environment_variables (e.g. Notion with NOTION_API_KEY) register vars in the env_passthrough system. The local backend checked this, but Docker's forward_env was a separate disconnected list. Now Docker exec merges both sources, so skill-declared env vars are forwarded into containers automatically. This fixes the reported issue where NOTION_API_KEY in ~/.hermes/.env wasn't reaching the Docker container despite being registered via the Notion skill's prerequisites. Closes #3665
159 lines
5.3 KiB
Python
159 lines
5.3 KiB
Python
"""Tests for credential file passthrough registry (tools/credential_files.py)."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tools.credential_files import (
|
|
clear_credential_files,
|
|
get_credential_file_mounts,
|
|
register_credential_file,
|
|
register_credential_files,
|
|
reset_config_cache,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_registry():
|
|
"""Reset registry between tests."""
|
|
clear_credential_files()
|
|
reset_config_cache()
|
|
yield
|
|
clear_credential_files()
|
|
reset_config_cache()
|
|
|
|
|
|
class TestRegisterCredentialFile:
|
|
def test_registers_existing_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "token.json").write_text('{"token": "abc"}')
|
|
|
|
result = register_credential_file("token.json")
|
|
|
|
assert result is True
|
|
mounts = get_credential_file_mounts()
|
|
assert len(mounts) == 1
|
|
assert mounts[0]["host_path"] == str(tmp_path / "token.json")
|
|
assert mounts[0]["container_path"] == "/root/.hermes/token.json"
|
|
|
|
def test_skips_missing_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
result = register_credential_file("nonexistent.json")
|
|
|
|
assert result is False
|
|
assert get_credential_file_mounts() == []
|
|
|
|
def test_custom_container_base(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "cred.json").write_text("{}")
|
|
|
|
register_credential_file("cred.json", container_base="/home/user/.hermes")
|
|
|
|
mounts = get_credential_file_mounts()
|
|
assert mounts[0]["container_path"] == "/home/user/.hermes/cred.json"
|
|
|
|
def test_deduplicates_by_container_path(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "token.json").write_text("{}")
|
|
|
|
register_credential_file("token.json")
|
|
register_credential_file("token.json")
|
|
|
|
mounts = get_credential_file_mounts()
|
|
assert len(mounts) == 1
|
|
|
|
|
|
class TestRegisterCredentialFiles:
|
|
def test_string_entries(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "a.json").write_text("{}")
|
|
(tmp_path / "b.json").write_text("{}")
|
|
|
|
missing = register_credential_files(["a.json", "b.json"])
|
|
|
|
assert missing == []
|
|
assert len(get_credential_file_mounts()) == 2
|
|
|
|
def test_dict_entries(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "token.json").write_text("{}")
|
|
|
|
missing = register_credential_files([
|
|
{"path": "token.json", "description": "OAuth token"},
|
|
])
|
|
|
|
assert missing == []
|
|
assert len(get_credential_file_mounts()) == 1
|
|
|
|
def test_returns_missing_files(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "exists.json").write_text("{}")
|
|
|
|
missing = register_credential_files([
|
|
"exists.json",
|
|
"missing.json",
|
|
{"path": "also_missing.json"},
|
|
])
|
|
|
|
assert missing == ["missing.json", "also_missing.json"]
|
|
assert len(get_credential_file_mounts()) == 1
|
|
|
|
def test_empty_list(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
assert register_credential_files([]) == []
|
|
|
|
|
|
class TestConfigCredentialFiles:
|
|
def test_loads_from_config(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "oauth.json").write_text("{}")
|
|
(tmp_path / "config.yaml").write_text(
|
|
"terminal:\n credential_files:\n - oauth.json\n"
|
|
)
|
|
|
|
mounts = get_credential_file_mounts()
|
|
|
|
assert len(mounts) == 1
|
|
assert mounts[0]["host_path"] == str(tmp_path / "oauth.json")
|
|
|
|
def test_config_skips_missing_files(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "config.yaml").write_text(
|
|
"terminal:\n credential_files:\n - nonexistent.json\n"
|
|
)
|
|
|
|
mounts = get_credential_file_mounts()
|
|
assert mounts == []
|
|
|
|
def test_combines_skill_and_config(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "skill_token.json").write_text("{}")
|
|
(tmp_path / "config_token.json").write_text("{}")
|
|
(tmp_path / "config.yaml").write_text(
|
|
"terminal:\n credential_files:\n - config_token.json\n"
|
|
)
|
|
|
|
register_credential_file("skill_token.json")
|
|
mounts = get_credential_file_mounts()
|
|
|
|
assert len(mounts) == 2
|
|
paths = {m["container_path"] for m in mounts}
|
|
assert "/root/.hermes/skill_token.json" in paths
|
|
assert "/root/.hermes/config_token.json" in paths
|
|
|
|
|
|
class TestGetMountsRechecksExistence:
|
|
def test_removed_file_excluded_from_mounts(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
token = tmp_path / "token.json"
|
|
token.write_text("{}")
|
|
|
|
register_credential_file("token.json")
|
|
assert len(get_credential_file_mounts()) == 1
|
|
|
|
# Delete the file after registration
|
|
token.unlink()
|
|
assert get_credential_file_mounts() == []
|