From e327eaa2a070bf6788d179e73c9ae5096d652e94 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Wed, 29 Jul 2026 08:14:03 -0700 Subject: [PATCH] feat(sync): default the sync plane to production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill Sync had no default base URL, so a user with no `sync.base_url` in config.yaml and no HERMES_SYNC_BASE_URL got: sync inert: no sync base URL configured (config.yaml sync.base_url or HERMES_SYNC_BASE_URL). Every sync command was unusable out of the box. The URL was left unset because the plane did not exist yet when the client was written; it does now. - Adds DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com" and returns it as the last step of resolve_sync_base_url(). - Resolution order is unchanged otherwise: HERMES_SYNC_BASE_URL -> config.yaml sync.base_url -> production default. The env var and config key now exist to point a dev/staging build at another plane rather than to make the feature work at all. - Follows the existing precedent for production endpoints in this codebase (DEFAULT_NOUS_PORTAL_URL in hermes_cli/auth.py, HERMES_DIAGNOSTICS_BASE_URL in diagnostics_upload.py): a module constant with env/config override. The "no sync base URL configured" guards are kept — they are now unreachable in practice but remain correct if the default is ever blanked. Tests: 3 new — the default is returned when nothing is configured, config still overrides it, and the constant is a bare https origin (no trailing slash, no path) since the client appends /v1/sync/. 2349 passed / 0 failed across 56 suites via scripts/run_tests.sh. Verified against a temp HERMES_HOME with no config: resolves to the production plane; HERMES_SYNC_BASE_URL and sync.base_url both still win, and trailing slashes are stripped. --- tests/tools/test_skills_sync_client.py | 27 ++++++++++++++++++++++++++ tools/skills_sync_client.py | 27 ++++++++++++++++---------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_skills_sync_client.py b/tests/tools/test_skills_sync_client.py index 6e90f5f3278..ec7e68d9331 100644 --- a/tests/tools/test_skills_sync_client.py +++ b/tests/tools/test_skills_sync_client.py @@ -694,6 +694,33 @@ class TestEnvConfig: monkeypatch.setenv("HERMES_SYNC_BASE_URL", "https://plane.example/") assert ssc.resolve_sync_base_url() == "https://plane.example" + def test_base_url_defaults_to_production(self, monkeypatch): + # With nothing configured a user must still reach the real plane — + # otherwise every sync command fails with "no base URL configured". + monkeypatch.delenv("HERMES_SYNC_BASE_URL", raising=False) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}, raising=False) + assert ssc.resolve_sync_base_url() == ssc.DEFAULT_SYNC_BASE_URL + + def test_default_is_a_bare_https_origin(self): + # The client appends /v1/sync/, so the default must be a scheme+host + # origin with no trailing slash and no path. + from urllib.parse import urlparse + + parsed = urlparse(ssc.DEFAULT_SYNC_BASE_URL) + assert parsed.scheme == "https" + assert parsed.netloc + assert parsed.path == "" + assert not ssc.DEFAULT_SYNC_BASE_URL.endswith("/") + + def test_config_overrides_default(self, monkeypatch): + monkeypatch.delenv("HERMES_SYNC_BASE_URL", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"sync": {"base_url": "https://cfg.example/"}}, + raising=False, + ) + assert ssc.resolve_sync_base_url() == "https://cfg.example" + def test_feature_enabled_env(self, monkeypatch): # Default off. monkeypatch.delenv("HERMES_SYNC_ENABLED", raising=False) diff --git a/tools/skills_sync_client.py b/tools/skills_sync_client.py index 812b3b9ad20..90fe360e981 100644 --- a/tools/skills_sync_client.py +++ b/tools/skills_sync_client.py @@ -295,18 +295,25 @@ def dev_gate_open() -> bool: # --------------------------------------------------------------------------- # Sync-plane endpoint resolution # -# The sync routes are mounted under /v1/sync/. The base URL is -# configurable (config.yaml sync.base_url or HERMES_SYNC_BASE_URL bridge env); -# it is NOT the inference base_url. When unset, sync is inert -- there is no -# server to talk to yet (the server is being built in parallel). +# The sync routes are mounted under /v1/sync/. The base URL defaults to the +# production plane, so a normal user configures nothing; config.yaml +# sync.base_url (or the HERMES_SYNC_BASE_URL bridge env) overrides it to point +# a dev/staging build at another plane. It is NOT the inference base_url. # --------------------------------------------------------------------------- -def resolve_sync_base_url() -> Optional[str]: - """Resolve the sync-plane base URL, or None when unconfigured. +#: Production Skill Sync plane. Overridable per the resolution order below. +DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com" - Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url``. - Returns a base without a trailing slash (e.g. ``https://host``); the - ``/v1/sync/`` prefix is appended by the client. +def resolve_sync_base_url() -> Optional[str]: + """Resolve the sync-plane base URL. + + Order: HERMES_SYNC_BASE_URL env bridge -> config.yaml ``sync.base_url`` -> + the production plane. Returns a base without a trailing slash (e.g. + ``https://host``); the ``/v1/sync/`` prefix is appended by the client. + + The production default means a normal user never configures a URL — the + env var and config key exist to point a dev/staging build at another + plane. Returns None only if the default is somehow blanked out. """ env = os.getenv("HERMES_SYNC_BASE_URL") if env and env.strip(): @@ -324,7 +331,7 @@ def resolve_sync_base_url() -> Optional[str]: return base.strip().rstrip("/") except Exception as e: logger.debug("skills_sync_client: config sync.base_url read failed: %s", e) - return None + return DEFAULT_SYNC_BASE_URL or None # ---------------------------------------------------------------------------