Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
217 lines
7.4 KiB
Python
217 lines
7.4 KiB
Python
"""Keyed ``providers.<key>`` entries must use the durable pool slug.
|
|
|
|
``hermes auth add b-ai`` stores keys under ``credential_pool.b-ai``. Runtime
|
|
used to look up ``custom:<display-name>`` (e.g. ``custom:b.ai`` from
|
|
``name: B.AI``), miss the pool, and send the ``no-key-required`` placeholder
|
|
to an auth-required endpoint (HTTP 401 Invalid api_key format).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import hermes_yaml as yaml
|
|
|
|
|
|
POOL_KEY = "sk-real-b-ai-pool-key-12345"
|
|
LEGACY_KEY = "sk-legacy-custom-b-ai-pool-key"
|
|
ENDPOINT = "https://api.b.ai/v1"
|
|
|
|
|
|
def _write_keyed_provider_home(tmp_path, monkeypatch, *, pool_id="b-ai", extra_config=None):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
config = {
|
|
"model": {"default": "b-ai-model", "provider": "b-ai"},
|
|
"providers": {
|
|
"b-ai": {
|
|
"name": "B.AI",
|
|
"base_url": ENDPOINT,
|
|
}
|
|
},
|
|
}
|
|
if extra_config:
|
|
config["providers"]["b-ai"].update(extra_config)
|
|
(hermes_home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
(hermes_home / "auth.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"providers": {},
|
|
"credential_pool": {
|
|
pool_id: [
|
|
{
|
|
"id": "k1",
|
|
"label": "primary",
|
|
"auth_type": "api_key",
|
|
"priority": 0,
|
|
"source": "manual",
|
|
"access_token": POOL_KEY if pool_id == "b-ai" else LEGACY_KEY,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return hermes_home
|
|
|
|
|
|
def test_get_named_custom_provider_exposes_provider_key_and_key_env(
|
|
tmp_path, monkeypatch
|
|
):
|
|
_write_keyed_provider_home(
|
|
tmp_path, monkeypatch, extra_config={"key_env": "B_AI_API_KEY"}
|
|
)
|
|
monkeypatch.setenv("B_AI_API_KEY", "sk-from-env-not-the-pool")
|
|
|
|
from hermes_cli.runtime_provider import _get_named_custom_provider
|
|
|
|
entry = _get_named_custom_provider("b-ai")
|
|
assert entry is not None
|
|
assert entry.get("provider_key") == "b-ai"
|
|
assert entry.get("key_env") == "B_AI_API_KEY"
|
|
assert entry.get("name") == "B.AI"
|
|
assert entry.get("base_url") == ENDPOINT
|
|
|
|
|
|
def test_keyed_provider_runtime_uses_durable_pool_slug(tmp_path, monkeypatch):
|
|
"""Main turns must send the pooled key, not the no-key-required placeholder."""
|
|
_write_keyed_provider_home(tmp_path, monkeypatch)
|
|
|
|
from hermes_cli import runtime_provider as rp
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="b-ai")
|
|
assert resolved["base_url"] == ENDPOINT
|
|
assert resolved["api_key"] == POOL_KEY
|
|
assert resolved["api_key"] != "no-key-required"
|
|
assert str(resolved.get("source") or "").startswith("pool:")
|
|
|
|
|
|
def test_keyed_provider_runtime_falls_back_to_legacy_custom_namespace(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Older auth.json rows stored under custom:<display-name> must still work."""
|
|
_write_keyed_provider_home(tmp_path, monkeypatch, pool_id="custom:b.ai")
|
|
|
|
from hermes_cli import runtime_provider as rp
|
|
|
|
resolved = rp.resolve_runtime_provider(requested="b-ai")
|
|
assert resolved["api_key"] == LEGACY_KEY
|
|
assert resolved["api_key"] != "no-key-required"
|
|
|
|
|
|
def _model_config_entry(entry_id, token):
|
|
return {
|
|
"id": entry_id,
|
|
"label": "model_config",
|
|
"auth_type": "api_key",
|
|
"priority": 0,
|
|
"source": "model_config",
|
|
"access_token": token,
|
|
}
|
|
|
|
|
|
def test_prune_keeps_active_legacy_pool_for_keyed_provider(tmp_path, monkeypatch):
|
|
"""A keyed provider's own legacy-named pool must not be false-pruned.
|
|
|
|
Regression: with keys stored under ``custom:b.ai`` while the provider is
|
|
configured as ``providers.b-ai``, the active pool key resolves to the
|
|
durable slug ``b-ai``; comparing with ``==`` let the prune strip the
|
|
provider's own current credential from ``custom:b.ai``.
|
|
"""
|
|
config = {
|
|
"model": {"default": "b-ai-model", "provider": "b-ai"},
|
|
"providers": {
|
|
"b-ai": {
|
|
"name": "B.AI",
|
|
"base_url": "https://api.b.ai/v1",
|
|
}
|
|
},
|
|
}
|
|
pools = {
|
|
# legacy-named pool for the (now keyed) b.ai provider holding the
|
|
# credential seeded from model.api_key — this is the ACTIVE pool
|
|
"custom:b.ai": [_model_config_entry("mc1", "sk-current-b-ai-key")],
|
|
# an unrelated stale pool that SHOULD be pruned
|
|
"custom:old-endpoint": [_model_config_entry("mc2", "sk-stale-key")],
|
|
}
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
(hermes_home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
(hermes_home / "auth.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"providers": {},
|
|
"credential_pool": pools,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from hermes_cli.model_setup_flows_common import (
|
|
_prune_replaced_custom_model_config_credentials,
|
|
)
|
|
|
|
_prune_replaced_custom_model_config_credentials(
|
|
"https://api.b.ai/v1", provider_name="B.AI"
|
|
)
|
|
|
|
after = json.loads(
|
|
(tmp_path / ".hermes" / "auth.json").read_text(encoding="utf-8")
|
|
)
|
|
kept = (after.get("credential_pool") or {}).get("custom:b.ai")
|
|
pruned = (after.get("credential_pool") or {}).get("custom:old-endpoint")
|
|
|
|
assert kept, "active provider's own legacy-named pool must keep its credential"
|
|
assert kept[0]["access_token"] == "sk-current-b-ai-key"
|
|
assert pruned == [], "stale pool for a different endpoint must be pruned"
|
|
|
|
|
|
def test_seed_custom_pool_matches_legacy_named_pool(tmp_path, monkeypatch):
|
|
"""A legacy-named pool must still seed from model.api_key.
|
|
|
|
Regression: with model.provider 'custom' pointing at a keyed provider's
|
|
base_url, the pool key ``custom:b.ai`` no longer equals the preferred
|
|
candidate (the slug ``b-ai``), silently skipping the model_config seed.
|
|
"""
|
|
config = {
|
|
"model": {
|
|
"default": "b-ai-model",
|
|
"provider": "custom",
|
|
"base_url": "https://api.b.ai/v1",
|
|
"api_key": "sk-model-config-key",
|
|
},
|
|
"providers": {
|
|
"b-ai": {
|
|
"name": "B.AI",
|
|
"base_url": "https://api.b.ai/v1",
|
|
}
|
|
},
|
|
}
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
(hermes_home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
(hermes_home / "auth.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"providers": {},
|
|
"credential_pool": {"custom:b.ai": []},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from agent.credential_pool import load_pool
|
|
|
|
pool = load_pool("custom:b.ai")
|
|
entries = pool.entries() if hasattr(pool, "entries") else []
|
|
seeded = [e for e in entries if getattr(e, "source", "") == "model_config"]
|
|
assert seeded, "legacy-named pool must still seed model_config from model.api_key"
|
|
assert getattr(seeded[0], "access_token", "") == "sk-model-config-key"
|