P1: host-agnostic quercus-core with agent loop, providers, and tool schemas
- Core types: Rational time, EntityId, ActionBatch, PngBytes (no Pillow dep) - Config: ~/.quercus/config.toml with 0600 perms, env var override for CI - LLMProvider: Claude (anthropic SDK) and OpenAI-compatible endpoint (covers custom gateways and llama.cpp server mode); graceful degradation without API keys - Tool schemas: 26 curated tools with JSON Schema and read_only flags - AgentLoop: sync dialogue loop with confirmation gate (read-only batches bypass, rejected mutations are reported back to the LLM), PNG frame feedback for multimodal providers - Session log: JSONL recording with frame sha256 hashes, replayable - HostAdapter ABC (signatures only, aligned with OPP/1 method families) - Mock LLM provider (scripted/replay/record) for network-free CI Tests: 69 passed via uv run pytest (no network access)
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""配置:0600 权限、环境变量覆盖优先级、密钥不落日志。"""
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from quercus_core.config import (
|
||||
ENV_ANTHROPIC_API_KEY,
|
||||
ENV_OPENAI_API_KEY,
|
||||
ENV_OPENAI_BASE_URL,
|
||||
Config,
|
||||
config_home,
|
||||
config_summary,
|
||||
default_config_path,
|
||||
default_sessions_dir,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
|
||||
|
||||
class TestSaveLoad:
|
||||
def test_save_creates_0600(self, tmp_path):
|
||||
path = tmp_path / "cfg" / "config.toml"
|
||||
save_config(Config(anthropic_api_key="sk-ant-secret"), path)
|
||||
assert path.exists()
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
|
||||
def test_roundtrip(self, tmp_path):
|
||||
path = tmp_path / "config.toml"
|
||||
save_config(
|
||||
Config(
|
||||
anthropic_api_key="sk-a",
|
||||
openai_api_key="sk-o",
|
||||
openai_base_url="https://gw.example/v1",
|
||||
),
|
||||
path,
|
||||
)
|
||||
cfg = load_config(path)
|
||||
assert cfg.anthropic_api_key == "sk-a"
|
||||
assert cfg.openai_api_key == "sk-o"
|
||||
assert cfg.openai_base_url == "https://gw.example/v1"
|
||||
assert cfg.has_anthropic_key and cfg.has_openai_key
|
||||
|
||||
def test_env_overrides_file(self, tmp_path, monkeypatch):
|
||||
path = tmp_path / "config.toml"
|
||||
save_config(Config(anthropic_api_key="file-key", openai_api_key="file-o"), path)
|
||||
monkeypatch.setenv(ENV_ANTHROPIC_API_KEY, "env-key")
|
||||
cfg = load_config(path)
|
||||
assert cfg.anthropic_api_key == "env-key" # 环境变量 > 配置文件
|
||||
assert cfg.openai_api_key == "file-o" # 未覆盖的保持文件值
|
||||
|
||||
def test_env_without_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_OPENAI_API_KEY, "env-o")
|
||||
monkeypatch.setenv(ENV_OPENAI_BASE_URL, "http://localhost:8080/v1")
|
||||
monkeypatch.delenv(ENV_ANTHROPIC_API_KEY, raising=False)
|
||||
cfg = load_config(tmp_path / "missing.toml")
|
||||
assert cfg.openai_api_key == "env-o"
|
||||
assert cfg.openai_base_url == "http://localhost:8080/v1"
|
||||
assert cfg.anthropic_api_key is None
|
||||
|
||||
def test_no_config_no_env(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv(ENV_ANTHROPIC_API_KEY, raising=False)
|
||||
monkeypatch.delenv(ENV_OPENAI_API_KEY, raising=False)
|
||||
monkeypatch.delenv(ENV_OPENAI_BASE_URL, raising=False)
|
||||
cfg = load_config(tmp_path / "missing.toml")
|
||||
assert cfg.anthropic_api_key is None
|
||||
assert cfg.openai_api_key is None
|
||||
assert cfg.openai_base_url is None
|
||||
assert not cfg.has_anthropic_key and not cfg.has_openai_key
|
||||
|
||||
|
||||
class TestHome:
|
||||
def test_home_override(self, tmp_home):
|
||||
assert config_home() == tmp_home / ".quercus"
|
||||
assert default_config_path() == tmp_home / ".quercus" / "config.toml"
|
||||
assert default_sessions_dir() == tmp_home / ".quercus" / "sessions"
|
||||
|
||||
|
||||
def test_config_summary_hides_secrets():
|
||||
cfg = Config(anthropic_api_key="top-secret-value", openai_api_key="second-secret")
|
||||
summary = config_summary(cfg)
|
||||
assert summary["anthropic_configured"] is True
|
||||
assert summary["openai_configured"] is True
|
||||
assert "secret" not in str(summary)
|
||||
Reference in New Issue
Block a user