From c125c7a79f9b5c5095ccce9b5fc0dcd7842d3615 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 26 Mar 2026 17:56:55 -0500 Subject: [PATCH] fix(cloud): CLI cloud commands now use API key when configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_authenticated_headers() only checked OAuth tokens, ignoring config.cloud_api_key entirely. This meant all CLI cloud commands (upload, status, snapshot, restore, etc.) failed for API-key-only users — even though MCP tools worked fine via _resolve_cloud_token(). Now mirrors the same credential priority: API key first, OAuth fallback. Fixes: bm cloud upload --project returning "project does not exist" when authenticated with bmc_* API key. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: phernandez --- .../cli/commands/cloud/api_client.py | 18 ++++- .../cloud/test_cloud_api_client_and_utils.py | 68 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/basic_memory/cli/commands/cloud/api_client.py b/src/basic_memory/cli/commands/cloud/api_client.py index 4d8a2a351..8f77963b8 100644 --- a/src/basic_memory/cli/commands/cloud/api_client.py +++ b/src/basic_memory/cli/commands/cloud/api_client.py @@ -45,14 +45,26 @@ def get_cloud_config() -> tuple[str, str, str]: async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]: """ - Get authentication headers with JWT token. - handles jwt refresh if needed. + Get authentication headers for cloud API requests. + + Credential priority mirrors async_client._resolve_cloud_token(): + 1. API key (config.cloud_api_key) — fast, no refresh needed + 2. OAuth token via CLIAuth — handles JWT refresh automatically """ + # --- API key (preferred) --- + config_manager = ConfigManager() + api_key = config_manager.config.cloud_api_key + if api_key: + return {"Authorization": f"Bearer {api_key}"} + + # --- OAuth fallback --- client_id, domain, _ = get_cloud_config() auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain) token = await auth_obj.get_valid_token() if not token: - console.print("[red]Not authenticated. Please run 'bm cloud login' first.[/red]") + console.print( + "[red]Not authenticated. Run 'bm cloud set-key ' or 'bm cloud login' first.[/red]" + ) raise typer.Exit(1) return {"Authorization": f"Bearer {token}"} diff --git a/tests/cli/cloud/test_cloud_api_client_and_utils.py b/tests/cli/cloud/test_cloud_api_client_and_utils.py index 8c509df6f..65f2f99eb 100644 --- a/tests/cli/cloud/test_cloud_api_client_and_utils.py +++ b/tests/cli/cloud/test_cloud_api_client_and_utils.py @@ -163,3 +163,71 @@ async def api_request(**kwargs): assert created.new_project["name"] == "My Project" # Path should be permalink-like (kebab) assert seen["create_payload"]["path"] == "my-project" + + +@pytest.mark.asyncio +async def test_make_api_request_prefers_api_key_over_oauth(config_home, config_manager): + """API key in config should be used without needing an OAuth token on disk.""" + # Arrange: set an API key in config, no OAuth token on disk + config = config_manager.load_config() + config.cloud_api_key = "bmc_test_key_12345" + config_manager.save_config(config) + + async def handler(request: httpx.Request) -> httpx.Response: + # Verify the API key is sent as the Bearer token + assert request.headers.get("authorization") == "Bearer bmc_test_key_12345" + return httpx.Response(200, json={"ok": True}) + + transport = httpx.MockTransport(handler) + + @asynccontextmanager + async def http_client_factory(): + async with httpx.AsyncClient(transport=transport) as client: + yield client + + # Act — no auth= parameter, no OAuth token file; should use API key from config + resp = await make_api_request( + method="GET", + url="https://cloud.example.test/proxy/health", + http_client_factory=http_client_factory, + ) + + # Assert + assert resp.json()["ok"] is True + + +@pytest.mark.asyncio +async def test_make_api_request_falls_back_to_oauth_when_no_api_key(config_home, config_manager): + """When no API key is configured, should fall back to OAuth token.""" + # Arrange: no API key, but OAuth token on disk + config = config_manager.load_config() + config.cloud_api_key = None + config_manager.save_config(config) + + auth = CLIAuth(client_id="cid", authkit_domain="https://auth.example.test") + auth.token_file.parent.mkdir(parents=True, exist_ok=True) + auth.token_file.write_text( + '{"access_token":"oauth-token-456","refresh_token":null,' + '"expires_at":9999999999,"token_type":"Bearer"}', + encoding="utf-8", + ) + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.headers.get("authorization") == "Bearer oauth-token-456" + return httpx.Response(200, json={"ok": True}) + + transport = httpx.MockTransport(handler) + + @asynccontextmanager + async def http_client_factory(): + async with httpx.AsyncClient(transport=transport) as client: + yield client + + resp = await make_api_request( + method="GET", + url="https://cloud.example.test/proxy/health", + auth=auth, + http_client_factory=http_client_factory, + ) + + assert resp.json()["ok"] is True