From ad588542ea7fe54c2261239a163fb54e50e02935 Mon Sep 17 00:00:00 2001 From: Rob Sherman Date: Fri, 31 Jul 2026 16:26:28 -0700 Subject: [PATCH] fix(memory): read endpoint.baseUrl from Honcho config; accept HONCHO_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HonchoClientConfig.from_global_config() only consulted top-level baseUrl / base_url / HONCHO_BASE_URL in ~/.honcho/config.json. The Honcho SDK's native config format — and what Claude Desktop writes — nests the URL at endpoint.baseUrl. Users with that config format had their self-hosted Honcho container silently ignored: every honcho_* call routed to https://api.honcho.dev with a workspace_id that does not exist there, so tools returned empty data with no error anywhere. Resolution order in from_global_config(), highest first: 1. endpoint.baseUrl (SDK-native, what Claude Desktop writes) 2. baseUrl / base_url (root-level, existing behavior) 3. HONCHO_BASE_URL (existing env var) 4. HONCHO_URL (the SDK's own env var, honcho/client.py:234) HONCHO_URL is also read in from_env(). from_global_config() delegates to from_env() whenever the config file is missing or unreadable, so an env fallback wired into only one of the two would silently do nothing for users with no config file. A non-dict endpoint value falls through cleanly rather than raising. Existing users are unaffected — the new sources are consulted only when the existing ones resolve to None. The INFO log for the base_url-unset case now says so explicitly instead of printing only the host. The SDK resolves that case from its own ENVIRONMENTS map (honcho/client.py:36-39), which for environment= production means the public cloud; a self-hosted user whose config was not picked up otherwise sees a healthy-looking startup line. Closes #43800. --- plugins/memory/honcho/client.py | 36 ++++++++++++-- tests/honcho_plugin/test_client.py | 77 ++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 42bb378c445f6..08efe0beaea23 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -489,7 +489,16 @@ class HonchoClientConfig: """Create config from environment variables (fallback).""" resolved_host = host or resolve_active_host() api_key = get_secret("HONCHO_API_KEY") - base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None + # HONCHO_URL is the SDK's own env var (honcho.client resolves it when + # no environment is passed); accept it here so the fallback path + # behaves the same as from_global_config() when no config file exists. + # Read straight from os.environ, matching HONCHO_BASE_URL: a base URL + # is a deployment setting, not a profile-scoped credential. + base_url = ( + os.environ.get("HONCHO_BASE_URL", "").strip() + or os.environ.get("HONCHO_URL", "").strip() + or None + ) timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT")) _resolved_path = resolve_config_path() return cls( @@ -555,10 +564,22 @@ class HonchoClientConfig: or raw.get("environment", "production") ) + # The Honcho SDK's native config format — and what Claude Desktop + # writes — nests the URL at endpoint.baseUrl. Read it first: a user + # who has that block set almost certainly means it, and the flat + # baseUrl / base_url keys below are the Hermes-specific spelling. + endpoint_block = raw.get("endpoint") + native_base_url = ( + endpoint_block.get("baseUrl") + if isinstance(endpoint_block, dict) + else None + ) base_url = ( - raw.get("baseUrl") + native_base_url + or raw.get("baseUrl") or raw.get("base_url") or os.environ.get("HONCHO_BASE_URL", "").strip() + or os.environ.get("HONCHO_URL", "").strip() or None ) # Host config wins over flat/global config and environment. @@ -1243,7 +1264,16 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: if resolved_base_url: logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id) else: - logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id) + # No base_url resolved, so the SDK falls back to its own + # ENVIRONMENTS map (honcho.client: local -> http://localhost:8000, + # production -> https://api.honcho.dev). Name the target at INFO: + # a self-hosted user whose config wasn't picked up otherwise sees + # a healthy-looking startup and silently talks to the public cloud. + logger.info( + "Initializing Honcho client (host: %s, workspace: %s, " + "base_url unset — SDK will resolve from environment=%s)", + config.host, config.workspace_id, config.environment, + ) # Local Honcho instances don't require an API key, but the SDK # expects a non-empty string. Use a placeholder for local URLs. diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 634467805a540..e3b117aceb738 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -67,6 +67,29 @@ class TestFromEnv: assert config.enabled is True + def test_honcho_url_env_var_is_honored(self): + """HONCHO_URL is the SDK's own env var; from_env() accepts it too.""" + with patch.dict(os.environ, {"HONCHO_URL": "http://localhost:8000"}, clear=False): + os.environ.pop("HONCHO_API_KEY", None) + os.environ.pop("HONCHO_BASE_URL", None) + config = HonchoClientConfig.from_env() + assert config.base_url == "http://localhost:8000" + assert config.enabled is True + + + def test_honcho_base_url_wins_over_honcho_url(self): + with patch.dict( + os.environ, + { + "HONCHO_BASE_URL": "http://localhost:8000", + "HONCHO_URL": "http://localhost:9999", + }, + clear=False, + ): + config = HonchoClientConfig.from_env() + assert config.base_url == "http://localhost:8000" + + class TestFromGlobalConfig: def test_missing_config_falls_back_to_env(self, tmp_path): with patch.dict(os.environ, {}, clear=True): @@ -78,6 +101,60 @@ class TestFromGlobalConfig: assert config.api_key is None + def test_missing_config_still_reads_honcho_url(self, tmp_path): + """The env fallback path must honor HONCHO_URL, not just HONCHO_BASE_URL. + + from_global_config() returns from_env() when the config file is + absent, so a fallback that only from_global_config() understood + would silently do nothing for users with no ~/.honcho/config.json. + """ + with patch.dict(os.environ, {"HONCHO_URL": "http://localhost:8000"}, clear=True): + config = HonchoClientConfig.from_global_config( + config_path=tmp_path / "nonexistent.json" + ) + assert config.base_url == "http://localhost:8000" + assert config.enabled is True + + + def test_base_url_from_sdk_native_endpoint_block(self, tmp_path): + """endpoint.baseUrl is the SDK-native spelling Claude Desktop writes.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "key", + "endpoint": {"baseUrl": "http://localhost:8000"}, + })) + + with patch.dict(os.environ, {}, clear=True): + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.base_url == "http://localhost:8000" + + + def test_endpoint_base_url_wins_over_top_level_and_env(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "endpoint": {"baseUrl": "http://localhost:8000"}, + "baseUrl": "http://localhost:9001", + "base_url": "http://localhost:9002", + })) + + with patch.dict(os.environ, {"HONCHO_BASE_URL": "http://localhost:9003"}, clear=True): + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.base_url == "http://localhost:8000" + + + def test_endpoint_block_non_dict_is_ignored(self, tmp_path): + """A malformed endpoint value falls through instead of crashing.""" + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "endpoint": "http://localhost:8000", + "baseUrl": "http://localhost:9001", + })) + + with patch.dict(os.environ, {}, clear=True): + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.base_url == "http://localhost:9001" + + def test_host_block_overrides_root(self, tmp_path): config_file = tmp_path / "config.json" config_file.write_text(json.dumps({