diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 6b8f38c485..b7c63e102e 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -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 diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 487f67cfe8..6f8f412a7d 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -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 == {}