fix(honcho): write enabled: true only for a host block that can authenticate
a named profile cloned from a default profile that signed in with oauth got a host block with enabled: true and nothing to authenticate with. hosts.hermes holds the grant, its apiKey is not inherited (#66125), and copying the oauth block would make two blocks replay one single-use refresh token. 'hermes honcho enable' on a fresh profile wrote the same shape. status then showed the profile as enabled while every honcho call ran without memory and the plugin quietly stayed inactive. clone_honcho_for_profile and cmd_enable now resolve a credential for the target block (its own apiKey, the root apiKey, HONCHO_API_KEY, or a base url) before writing enabled: true. _resolve_api_key takes the block to check so both share one definition of "can authenticate". cohorts: clone from an oauth default block, no root key: block written without enabled; the client's auto-enable rule turns it on once a credential appears (setup apikey writes the root key, or a per-profile login) clone from a default block with a host-level static key only: same clone with a root apiKey, an env key, or a base url: enabled as before enable on an empty or fresh block with no credential: refused, one message names the profile's setup command and the hosts.<name> key, nothing is written legacy blocks already on disk as enabled with no credential: nothing rewrites them; the plugin already treats them as unusable and stays inactive; enable now prints the same message instead of "already enabled" env-only key: counted as a credential at write time, as the client does at run time; if the variable later disappears the client still refuses to initialize the block
This commit is contained in:
@@ -127,15 +127,17 @@ def _default_block_and_key(cfg: dict) -> tuple[dict, bool]:
|
||||
return cfg_get(cfg, "hosts", HOST, default={}), bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY"))
|
||||
|
||||
|
||||
def _resolve_api_key(cfg: dict) -> str:
|
||||
"""API key with host -> root -> env fallback. A self-hosted ``baseUrl`` without a key
|
||||
yields ``"local"`` so credential guards accept it: the URL must be http/https (so
|
||||
``baseUrl: true`` can't pass) or a schemeless host:port (legacy ``localhost:8000``;
|
||||
the SDK rejects those itself)."""
|
||||
key = _host_block(cfg, _host_key()).get("apiKey") or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
|
||||
def _resolve_api_key(cfg: dict, block: dict | None = None) -> str:
|
||||
"""API key for ``block`` (default: the active host's block) with host -> root -> env fallback.
|
||||
A self-hosted ``baseUrl`` without a key yields ``"local"`` so credential guards accept it: the
|
||||
URL must be http/https (so ``baseUrl: true`` can't pass) or a schemeless host:port (legacy
|
||||
``localhost:8000``; the SDK rejects those itself)."""
|
||||
block = _host_block(cfg, _host_key()) if block is None else block
|
||||
key = block.get("apiKey") or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
|
||||
if key:
|
||||
return key
|
||||
base_url = (cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "") or "").strip()
|
||||
base_url = (block.get("baseUrl") or block.get("base_url") or cfg.get("baseUrl") or cfg.get("base_url")
|
||||
or os.environ.get("HONCHO_BASE_URL", "") or "").strip()
|
||||
if not base_url:
|
||||
return key
|
||||
from urllib.parse import urlparse
|
||||
@@ -231,8 +233,11 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
|
||||
new_block["pinUserPeer"] = default_block["pinPeerName"]
|
||||
# AI peer is profile-specific (bare profile name: Honcho peer IDs allow no dots);
|
||||
# workspace is shared so all profiles see the same context.
|
||||
new_block.update(aiPeer=profile_name, workspace=_pref(default_block, cfg, "workspace") or HOST,
|
||||
enabled=default_block.get("enabled", True))
|
||||
new_block.update(aiPeer=profile_name, workspace=_pref(default_block, cfg, "workspace") or HOST)
|
||||
# The default host's apiKey is not inherited (#66125): without a credential of its own the block
|
||||
# stays unenabled until 'hermes honcho setup --target-profile' signs the profile in.
|
||||
if _resolve_api_key(cfg, new_block):
|
||||
new_block["enabled"] = default_block.get("enabled", True)
|
||||
cfg.setdefault("hosts", {})[new_host] = new_block
|
||||
_write_config(cfg)
|
||||
_ensure_peer_exists(new_host) # eager so the peer exists before first message
|
||||
@@ -282,12 +287,22 @@ def sync_honcho_profiles_quiet() -> int:
|
||||
return _sync_profiles(verbose=False)
|
||||
|
||||
|
||||
def _no_credential_hint(host: str) -> str:
|
||||
profile = _active_profile_name()
|
||||
setup = "hermes honcho setup" + (f" --target-profile {profile}" if profile != "default" else "")
|
||||
return (f"Honcho stays disabled: no API key or base URL is configured for this profile, and the default "
|
||||
f"profile's key is not shared.\n Run '{setup}' to sign in, or set apiKey on hosts.{host} in {_config_path()}.")
|
||||
|
||||
|
||||
def cmd_enable(args) -> None:
|
||||
"""Enable Honcho for the active profile."""
|
||||
"""Enable Honcho for the active profile. Refuses to write ``enabled: true`` for a block that
|
||||
cannot authenticate, so an enabled block always has a credential behind it."""
|
||||
cfg = _read_config()
|
||||
host = _host_key()
|
||||
label = _label(host)
|
||||
block = cfg.setdefault("hosts", {}).setdefault(host, {})
|
||||
if not _resolve_api_key(cfg, block):
|
||||
return print(f" {label}{_no_credential_hint(host)}\n")
|
||||
if block.get("enabled") is True:
|
||||
return print(f" {label}Honcho is already enabled.\n")
|
||||
block["enabled"] = True
|
||||
|
||||
@@ -848,3 +848,97 @@ class TestSetupApiKeyReplacesStaleGrant:
|
||||
assert ok is True
|
||||
assert host["apiKey"] == "hch-v3-hostkey"
|
||||
assert "apiKey" not in cfg
|
||||
|
||||
|
||||
class TestEnabledRequiresACredential:
|
||||
"""A host block must not be written with enabled: true unless it can authenticate. The default
|
||||
host's apiKey is not inherited by named profiles (#66125), so a clone of an OAuth-authenticated
|
||||
default block, or an enable on an empty block, has nothing to sign requests with."""
|
||||
|
||||
def _env(self, monkeypatch, tmp_path, cfg, *, host="hermes_dreamer", profile="dreamer"):
|
||||
import plugins.memory.honcho.cli as honcho_cli
|
||||
cfg_path = tmp_path / "honcho.json"
|
||||
cfg_path.write_text("{}")
|
||||
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
|
||||
monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
|
||||
monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg)
|
||||
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
|
||||
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
|
||||
monkeypatch.setattr(honcho_cli, "_host_key", lambda: host)
|
||||
monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: profile)
|
||||
monkeypatch.setattr(honcho_cli, "_ensure_peer_exists", lambda host_key=None: True)
|
||||
written = {}
|
||||
monkeypatch.setattr(honcho_cli, "_write_config", lambda c, path=None: written.setdefault("cfg", c))
|
||||
return honcho_cli, written
|
||||
|
||||
def _oauth_default(self):
|
||||
return {"peerName": "eri", "hosts": {"hermes": {
|
||||
"enabled": True, "apiKey": "hch-at-live", "workspace": "hermes", "peerName": "eri",
|
||||
"oauth": {"refreshToken": "hch-rt-live", "expiresAt": 9e9, "clientId": "hermes-agent",
|
||||
"tokenEndpoint": "https://api.honcho.dev/oauth/token"},
|
||||
}}}
|
||||
|
||||
def test_clone_from_oauth_default_is_written_without_enabled(self, monkeypatch, tmp_path):
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, self._oauth_default())
|
||||
assert honcho_cli.clone_honcho_for_profile("dreamer") is True
|
||||
block = written["cfg"]["hosts"]["hermes_dreamer"]
|
||||
assert "enabled" not in block
|
||||
assert "apiKey" not in block and "oauth" not in block
|
||||
assert block["aiPeer"] == "dreamer" and block["workspace"] == "hermes"
|
||||
|
||||
def test_clone_from_host_only_static_key_is_written_without_enabled(self, monkeypatch, tmp_path):
|
||||
cfg = {"hosts": {"hermes": {"enabled": True, "apiKey": "hch-v3-hostonly", "workspace": "hermes"}}}
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, cfg)
|
||||
assert honcho_cli.clone_honcho_for_profile("dreamer") is True
|
||||
assert "enabled" not in written["cfg"]["hosts"]["hermes_dreamer"]
|
||||
|
||||
def test_clone_with_root_key_is_enabled(self, monkeypatch, tmp_path):
|
||||
cfg = {"apiKey": "hch-v3-root", "hosts": {"hermes": {"workspace": "hermes"}}}
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, cfg)
|
||||
assert honcho_cli.clone_honcho_for_profile("dreamer") is True
|
||||
assert written["cfg"]["hosts"]["hermes_dreamer"]["enabled"] is True
|
||||
|
||||
def test_clone_with_env_key_is_enabled(self, monkeypatch, tmp_path):
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, self._oauth_default())
|
||||
monkeypatch.setenv("HONCHO_API_KEY", "hch-v3-env")
|
||||
assert honcho_cli.clone_honcho_for_profile("dreamer") is True
|
||||
assert written["cfg"]["hosts"]["hermes_dreamer"]["enabled"] is True
|
||||
|
||||
def test_clone_with_self_hosted_url_is_enabled(self, monkeypatch, tmp_path):
|
||||
cfg = {"baseUrl": "http://localhost:8000", "hosts": {"hermes": {"workspace": "hermes"}}}
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, cfg)
|
||||
assert honcho_cli.clone_honcho_for_profile("dreamer") is True
|
||||
assert written["cfg"]["hosts"]["hermes_dreamer"]["enabled"] is True
|
||||
|
||||
def test_enable_on_empty_block_refuses_and_writes_nothing(self, monkeypatch, tmp_path, capsys):
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, self._oauth_default())
|
||||
honcho_cli.cmd_enable(SimpleNamespace())
|
||||
out = capsys.readouterr().out
|
||||
assert "stays disabled" in out
|
||||
assert "hermes honcho setup --target-profile dreamer" in out
|
||||
assert "hosts.hermes_dreamer" in out
|
||||
assert written == {}
|
||||
|
||||
def test_enable_on_legacy_enabled_keyless_block_explains_instead_of_already_enabled(self, monkeypatch, tmp_path, capsys):
|
||||
cfg = self._oauth_default()
|
||||
cfg["hosts"]["hermes_dreamer"] = {"enabled": True, "aiPeer": "dreamer", "workspace": "hermes", "peerName": "eri"}
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, cfg)
|
||||
honcho_cli.cmd_enable(SimpleNamespace())
|
||||
out = capsys.readouterr().out
|
||||
assert "stays disabled" in out
|
||||
assert "already enabled" not in out
|
||||
assert written == {}
|
||||
|
||||
def test_enable_with_root_key_still_enables(self, monkeypatch, tmp_path, capsys):
|
||||
cfg = {"apiKey": "hch-v3-root", "hosts": {"hermes": {"workspace": "hermes"}}}
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, cfg)
|
||||
honcho_cli.cmd_enable(SimpleNamespace())
|
||||
assert "Honcho enabled" in capsys.readouterr().out
|
||||
assert written["cfg"]["hosts"]["hermes_dreamer"]["enabled"] is True
|
||||
|
||||
def test_enable_on_default_profile_hint_omits_target_profile(self, monkeypatch, tmp_path, capsys):
|
||||
honcho_cli, written = self._env(monkeypatch, tmp_path, {"hosts": {}}, host="hermes", profile="default")
|
||||
honcho_cli.cmd_enable(SimpleNamespace())
|
||||
out = capsys.readouterr().out
|
||||
assert "Run 'hermes honcho setup' to sign in" in out
|
||||
assert written == {}
|
||||
|
||||
Reference in New Issue
Block a user