Skip to content

Commit d1320f6

Browse files
phernandezclaude
andauthored
fix: (cloud) CLI cloud commands now use API key when configured (#698)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 94bdfe7 commit d1320f6

File tree

2 files changed

+83
-3
lines changed

2 files changed

+83
-3
lines changed

src/basic_memory/cli/commands/cloud/api_client.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,26 @@ def get_cloud_config() -> tuple[str, str, str]:
4545

4646
async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]:
4747
"""
48-
Get authentication headers with JWT token.
49-
handles jwt refresh if needed.
48+
Get authentication headers for cloud API requests.
49+
50+
Credential priority mirrors async_client._resolve_cloud_token():
51+
1. API key (config.cloud_api_key) — fast, no refresh needed
52+
2. OAuth token via CLIAuth — handles JWT refresh automatically
5053
"""
54+
# --- API key (preferred) ---
55+
config_manager = ConfigManager()
56+
api_key = config_manager.config.cloud_api_key
57+
if api_key:
58+
return {"Authorization": f"Bearer {api_key}"}
59+
60+
# --- OAuth fallback ---
5161
client_id, domain, _ = get_cloud_config()
5262
auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain)
5363
token = await auth_obj.get_valid_token()
5464
if not token:
55-
console.print("[red]Not authenticated. Please run 'bm cloud login' first.[/red]")
65+
console.print(
66+
"[red]Not authenticated. Run 'bm cloud set-key <key>' or 'bm cloud login' first.[/red]"
67+
)
5668
raise typer.Exit(1)
5769

5870
return {"Authorization": f"Bearer {token}"}

tests/cli/cloud/test_cloud_api_client_and_utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,71 @@ async def api_request(**kwargs):
163163
assert created.new_project["name"] == "My Project"
164164
# Path should be permalink-like (kebab)
165165
assert seen["create_payload"]["path"] == "my-project"
166+
167+
168+
@pytest.mark.asyncio
169+
async def test_make_api_request_prefers_api_key_over_oauth(config_home, config_manager):
170+
"""API key in config should be used without needing an OAuth token on disk."""
171+
# Arrange: set an API key in config, no OAuth token on disk
172+
config = config_manager.load_config()
173+
config.cloud_api_key = "bmc_test_key_12345"
174+
config_manager.save_config(config)
175+
176+
async def handler(request: httpx.Request) -> httpx.Response:
177+
# Verify the API key is sent as the Bearer token
178+
assert request.headers.get("authorization") == "Bearer bmc_test_key_12345"
179+
return httpx.Response(200, json={"ok": True})
180+
181+
transport = httpx.MockTransport(handler)
182+
183+
@asynccontextmanager
184+
async def http_client_factory():
185+
async with httpx.AsyncClient(transport=transport) as client:
186+
yield client
187+
188+
# Act — no auth= parameter, no OAuth token file; should use API key from config
189+
resp = await make_api_request(
190+
method="GET",
191+
url="https://cloud.example.test/proxy/health",
192+
http_client_factory=http_client_factory,
193+
)
194+
195+
# Assert
196+
assert resp.json()["ok"] is True
197+
198+
199+
@pytest.mark.asyncio
200+
async def test_make_api_request_falls_back_to_oauth_when_no_api_key(config_home, config_manager):
201+
"""When no API key is configured, should fall back to OAuth token."""
202+
# Arrange: no API key, but OAuth token on disk
203+
config = config_manager.load_config()
204+
config.cloud_api_key = None
205+
config_manager.save_config(config)
206+
207+
auth = CLIAuth(client_id="cid", authkit_domain="https://auth.example.test")
208+
auth.token_file.parent.mkdir(parents=True, exist_ok=True)
209+
auth.token_file.write_text(
210+
'{"access_token":"oauth-token-456","refresh_token":null,'
211+
'"expires_at":9999999999,"token_type":"Bearer"}',
212+
encoding="utf-8",
213+
)
214+
215+
async def handler(request: httpx.Request) -> httpx.Response:
216+
assert request.headers.get("authorization") == "Bearer oauth-token-456"
217+
return httpx.Response(200, json={"ok": True})
218+
219+
transport = httpx.MockTransport(handler)
220+
221+
@asynccontextmanager
222+
async def http_client_factory():
223+
async with httpx.AsyncClient(transport=transport) as client:
224+
yield client
225+
226+
resp = await make_api_request(
227+
method="GET",
228+
url="https://cloud.example.test/proxy/health",
229+
auth=auth,
230+
http_client_factory=http_client_factory,
231+
)
232+
233+
assert resp.json()["ok"] is True

0 commit comments

Comments
 (0)