fix(memory): read endpoint.baseUrl from Honcho config; accept HONCHO_URL

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.
This commit is contained in:
Rob Sherman 2026-07-31 16:26:28 -07:00 committed by kshitij
parent 5118692c25
commit ad588542ea
2 changed files with 110 additions and 3 deletions

View File

@ -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.

View File

@ -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({