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:
Erosika
2026-09-04 15:33:09 -04:00
committed by kshitij
parent 3eb3dad994
commit f87c915bf8
2 changed files with 119 additions and 10 deletions
+25 -10
View File
@@ -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
+94
View File
@@ -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 == {}