feat(secrets/bitwarden): EU Cloud + self-hosted server URL support (#31378)

Closes #31370.

bws defaults to the US identity endpoint, so EU Cloud and self-hosted
machine-account tokens fail with [400 Bad Request] {"error":"invalid_client"}
during 'hermes secrets bitwarden setup'. The token is valid — it's just
being checked against the wrong region.

Add a Bitwarden region step to the wizard between the access-token and
project-list steps:

  Step 1  Install bws
  Step 2  Provide access token
  Step 3  Pick region   <-- new (US / EU / self-hosted-custom-URL)
  Step 4  Pick project  (now talks to the right endpoint)
  Step 5  Test fetch

Region is stored in config.yaml as secrets.bitwarden.server_url and
plumbed into every bws subprocess as BWS_SERVER_URL (project list,
secret list, test fetch, and the env_loader startup pull).

Also:
- Non-interactive: 'hermes secrets bitwarden setup --server-url ...'
- Pre-existing BWS_SERVER_URL in the shell is detected and reused
- Cache key includes server_url so EU/US fetches don't collide
- 'hermes secrets bitwarden status' shows the configured region
- 'invalid_client' / '400 Bad Request' from bws now triggers a hint
  pointing at the region setting instead of looking like a bad token
This commit is contained in:
Teknium 2026-05-24 02:19:57 -07:00 committed by GitHub
parent c9b3eeabdc
commit bc3f1f4f34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 271 additions and 14 deletions

View file

@ -301,6 +301,89 @@ def test_fetch_cache_hits(monkeypatch, tmp_path):
assert call_count["n"] == 1 # cached on second call
def test_fetch_server_url_sets_env(monkeypatch, tmp_path):
"""server_url must be plumbed into the subprocess as BWS_SERVER_URL."""
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
payload = _fake_bws_payload([{"key": "K", "value": "v"}])
captured_env = {}
def fake_run(cmd, **kwargs):
captured_env.update(kwargs["env"])
return mock.Mock(returncode=0, stdout=payload, stderr="")
monkeypatch.setattr(bw.subprocess, "run", fake_run)
bw.fetch_bitwarden_secrets(
access_token="0.t",
project_id="p",
binary=fake_binary,
use_cache=False,
server_url="https://vault.bitwarden.eu",
)
assert captured_env.get("BWS_SERVER_URL") == "https://vault.bitwarden.eu"
def test_fetch_no_server_url_does_not_set_env(monkeypatch, tmp_path):
"""When server_url is empty, BWS_SERVER_URL must not be injected."""
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
payload = _fake_bws_payload([])
# Make sure the inherited env doesn't already have BWS_SERVER_URL set.
monkeypatch.delenv("BWS_SERVER_URL", raising=False)
captured_env = {}
def fake_run(cmd, **kwargs):
captured_env.update(kwargs["env"])
return mock.Mock(returncode=0, stdout=payload, stderr="")
monkeypatch.setattr(bw.subprocess, "run", fake_run)
bw.fetch_bitwarden_secrets(
access_token="0.t",
project_id="p",
binary=fake_binary,
use_cache=False,
)
assert "BWS_SERVER_URL" not in captured_env
def test_fetch_server_url_keyed_in_cache(monkeypatch, tmp_path):
"""Different server_url values must produce separate cache entries."""
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
payload = _fake_bws_payload([{"key": "K", "value": "v"}])
call_count = {"n": 0}
def fake_run(*a, **kw):
call_count["n"] += 1
return mock.Mock(returncode=0, stdout=payload, stderr="")
monkeypatch.setattr(bw.subprocess, "run", fake_run)
# US (default empty) — fresh fetch.
bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="p",
binary=fake_binary, cache_ttl_seconds=60,
)
# EU — different server_url, must NOT hit the US cache entry.
bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="p",
binary=fake_binary, cache_ttl_seconds=60,
server_url="https://vault.bitwarden.eu",
)
# Second EU call hits cache.
bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="p",
binary=fake_binary, cache_ttl_seconds=60,
server_url="https://vault.bitwarden.eu",
)
assert call_count["n"] == 2
def test_fetch_cache_disabled(monkeypatch, tmp_path):
fake_binary = tmp_path / "bws"
fake_binary.write_text("")