Skip to content

Commit cc3a9d9

Browse files
committed
fix(cli): don't let bm cloud setup overwrite an existing rclone remote
Closes #922. `bm cloud setup [--workspace X]` re-minted credentials and overwrote the target rclone remote with no warning — most dangerously, a default-workspace setup silently repointing the shared `basic-memory-cloud` remote from one tenant to another (surfaced while live-testing #920). Setup now aborts if the target remote already exists unless `--force` is given. The check runs before any credentials are minted, so an abort wastes nothing. The credential-leak half (re-mint leaves the prior key unrevoked on the tenant bucket) is a backend concern tracked in #922 for basic-memory-cloud. Signed-off-by: phernandez <paul@basicmachines.co>
1 parent de53e0e commit cc3a9d9

2 files changed

Lines changed: 69 additions & 11 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from basic_memory.cli.commands.cloud.rclone_config import (
3030
configure_rclone_remote,
31+
rclone_remote_exists,
3132
remote_name_for_workspace,
3233
)
3334
from basic_memory.cli.commands.cloud.rclone_installer import (
@@ -207,6 +208,11 @@ def setup(
207208
help="Set up sync for a specific workspace (slug, name, or tenant_id). "
208209
"Omit for your default workspace.",
209210
),
211+
force: bool = typer.Option(
212+
False,
213+
"--force",
214+
help="Reconfigure an rclone remote that already exists (mints new credentials).",
215+
),
210216
) -> None:
211217
"""Set up cloud sync by installing rclone and configuring credentials.
212218
@@ -239,6 +245,20 @@ def setup(
239245
workspace_id = None # default tenant
240246
remote_name = remote_name_for_workspace(None, is_default=True)
241247

248+
# Trigger: the target rclone remote already exists.
249+
# Why: re-running setup mints new credentials and overwrites the remote,
250+
# silently repointing it (e.g. clobbering the shared basic-memory-cloud
251+
# that served another tenant) — the #922 footgun. Checked BEFORE minting
252+
# so an abort wastes no credentials.
253+
# Outcome: stop unless the user explicitly opts in with --force.
254+
if rclone_remote_exists(remote_name) and not force:
255+
console.print(f"[red]rclone remote '{remote_name}' is already configured.[/red]")
256+
console.print(
257+
"Re-running setup mints new credentials and overwrites it. "
258+
"Pass --force to reconfigure."
259+
)
260+
raise typer.Exit(1)
261+
242262
# Step 2: Get tenant info (scoped to the target workspace when given)
243263
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
244264
tenant_info = run_with_cleanup(get_mount_info(workspace_id=workspace_id))

tests/cli/cloud/test_project_sync_command.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -625,42 +625,80 @@ def test_get_workspace_for_project_override_no_match_raises(monkeypatch, config_
625625
assert "No accessible workspace matches 'acme'" in str(exc_info.value)
626626

627627

628-
def test_cloud_setup_workspace_configures_named_remote(monkeypatch):
629-
"""`bm cloud setup --workspace acme` provisions the acme tenant's own remote."""
630-
core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands")
631-
recorder: dict = {}
632-
628+
def _stub_setup_env(monkeypatch, core, *, remote_exists=False, recorder=None):
629+
"""Stub the `bm cloud setup` dependency chain for the acme workspace."""
633630
monkeypatch.setattr(core, "install_rclone", lambda: None)
634631
monkeypatch.setattr(
635632
core,
636633
"get_available_workspaces",
637634
lambda: _async_value([_workspace("team-tenant", "organization", "acme")]),
638635
)
636+
monkeypatch.setattr(core, "rclone_remote_exists", lambda _remote: remote_exists)
637+
638+
def _mint(_tenant_id):
639+
if recorder is not None:
640+
recorder["minted"] = True
641+
return _async_value(SimpleNamespace(access_key="ak", secret_key="sk"))
642+
639643
monkeypatch.setattr(
640644
core,
641645
"get_mount_info",
642646
lambda **_kwargs: _async_value(
643647
SimpleNamespace(tenant_id="team-tenant", bucket_name="acme-bucket")
644648
),
645649
)
646-
monkeypatch.setattr(
647-
core,
648-
"generate_mount_credentials",
649-
lambda _tenant_id: _async_value(SimpleNamespace(access_key="ak", secret_key="sk")),
650-
)
650+
monkeypatch.setattr(core, "generate_mount_credentials", _mint)
651651

652652
def _fake_configure(**kwargs):
653-
recorder.update(kwargs)
653+
if recorder is not None:
654+
recorder.update(kwargs)
654655
return kwargs.get("remote_name")
655656

656657
monkeypatch.setattr(core, "configure_rclone_remote", _fake_configure)
657658

659+
660+
def test_cloud_setup_workspace_configures_named_remote(monkeypatch):
661+
"""`bm cloud setup --workspace acme` provisions the acme tenant's own remote."""
662+
core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands")
663+
recorder: dict = {}
664+
_stub_setup_env(monkeypatch, core, remote_exists=False, recorder=recorder)
665+
658666
result = runner.invoke(app, ["cloud", "setup", "--workspace", "acme"])
659667

660668
assert result.exit_code == 0, result.output
661669
assert recorder["remote_name"] == "basic-memory-cloud-acme"
662670

663671

672+
def test_cloud_setup_aborts_when_remote_exists_without_force(monkeypatch):
673+
"""Setup refuses to overwrite an existing remote, and mints nothing (the #922 footgun)."""
674+
core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands")
675+
recorder: dict = {}
676+
_stub_setup_env(monkeypatch, core, remote_exists=True, recorder=recorder)
677+
678+
result = runner.invoke(app, ["cloud", "setup", "--workspace", "acme"])
679+
680+
assert result.exit_code == 1, result.output
681+
output = " ".join(result.output.split())
682+
assert "basic-memory-cloud-acme' is already configured" in output
683+
assert "--force" in output
684+
# Aborted before minting credentials or touching the remote.
685+
assert "minted" not in recorder
686+
assert "remote_name" not in recorder
687+
688+
689+
def test_cloud_setup_force_overwrites_existing_remote(monkeypatch):
690+
"""--force reconfigures an existing remote."""
691+
core = importlib.import_module("basic_memory.cli.commands.cloud.core_commands")
692+
recorder: dict = {}
693+
_stub_setup_env(monkeypatch, core, remote_exists=True, recorder=recorder)
694+
695+
result = runner.invoke(app, ["cloud", "setup", "--workspace", "acme", "--force"])
696+
697+
assert result.exit_code == 0, result.output
698+
assert recorder["remote_name"] == "basic-memory-cloud-acme"
699+
assert recorder.get("minted") is True
700+
701+
664702
async def _async_value(value):
665703
return value
666704

0 commit comments

Comments
 (0)