Skip to content

Commit a2276a8

Browse files
groksrcphernandez
authored andcommitted
fix(cli): block team workspace rclone sync
Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent ff5d872 commit a2276a8

2 files changed

Lines changed: 172 additions & 1 deletion

File tree

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,20 @@
2626
from basic_memory.config import ConfigManager, ProjectEntry
2727
from basic_memory.mcp.async_client import get_client
2828
from basic_memory.mcp.clients import ProjectClient
29+
from basic_memory.mcp.project_context import get_available_workspaces
30+
from basic_memory.schemas.cloud import WorkspaceInfo
2931
from basic_memory.schemas.project_info import ProjectItem
3032
from basic_memory.utils import generate_permalink, normalize_project_path
3133

3234
console = Console()
3335

36+
TEAM_WORKSPACE_SYNC_UNSUPPORTED = (
37+
"Local rclone sync/bisync is supported only for Personal workspaces.\n"
38+
"Team workspaces are accessed through the cloud API/MCP and do not support "
39+
"local multi-user bisync.\n"
40+
"Use `bm project list --workspace <workspace>` to inspect Team projects."
41+
)
42+
3443

3544
# --- Shared helpers ---
3645

@@ -52,6 +61,54 @@ def _require_cloud_credentials(config) -> None:
5261
raise typer.Exit(1)
5362

5463

64+
async def _get_workspace_for_project(name: str, config) -> WorkspaceInfo:
65+
"""Resolve the cloud workspace targeted by a project-scoped sync command."""
66+
workspaces = await get_available_workspaces()
67+
if not workspaces:
68+
raise ValueError("No accessible cloud workspaces found for this account")
69+
70+
entry = config.projects.get(name)
71+
workspace_id = entry.workspace_id if entry and entry.workspace_id else config.default_workspace
72+
if workspace_id:
73+
workspace = next(
74+
(item for item in workspaces if item.tenant_id == workspace_id),
75+
None,
76+
)
77+
if workspace is None:
78+
raise ValueError(
79+
f"Configured workspace '{workspace_id}' for project '{name}' is not accessible"
80+
)
81+
return workspace
82+
83+
default_workspaces = [item for item in workspaces if item.is_default]
84+
if len(default_workspaces) == 1:
85+
return default_workspaces[0]
86+
87+
if len(workspaces) == 1:
88+
return workspaces[0]
89+
90+
raise ValueError(
91+
f"Project '{name}' does not have an unambiguous cloud workspace. "
92+
"Set a default workspace with `bm cloud workspace set-default <workspace>` "
93+
"or attach the project with `bm project set-cloud <name> --workspace <workspace>`."
94+
)
95+
96+
97+
def _require_personal_workspace(name: str, config) -> WorkspaceInfo:
98+
"""Exit before rclone work when the target workspace is not personal."""
99+
try:
100+
workspace = run_with_cleanup(_get_workspace_for_project(name, config))
101+
except Exception as exc:
102+
console.print(f"[red]Error resolving workspace for project '{name}': {exc}[/red]")
103+
raise typer.Exit(1)
104+
105+
if workspace.workspace_type != "personal":
106+
console.print(f"[red]{TEAM_WORKSPACE_SYNC_UNSUPPORTED}[/red]")
107+
raise typer.Exit(1)
108+
109+
return workspace
110+
111+
55112
async def _get_cloud_project(name: str) -> ProjectItem | None:
56113
"""Fetch a project by name from the cloud API."""
57114
async with get_client(project_name=name) as client:
@@ -103,6 +160,7 @@ def sync_project_command(
103160
"""
104161
config = ConfigManager().config
105162
_require_cloud_credentials(config)
163+
_require_personal_workspace(name, config)
106164

107165
try:
108166
# Get tenant info for bucket name
@@ -152,6 +210,7 @@ def bisync_project_command(
152210
"""
153211
config = ConfigManager().config
154212
_require_cloud_credentials(config)
213+
_require_personal_workspace(name, config)
155214

156215
try:
157216
# Get tenant info for bucket name
@@ -210,6 +269,7 @@ def check_project_command(
210269
"""
211270
config = ConfigManager().config
212271
_require_cloud_credentials(config)
272+
_require_personal_workspace(name, config)
213273

214274
try:
215275
# Get tenant info for bucket name
@@ -253,6 +313,10 @@ def bisync_reset(
253313
"""
254314
import shutil
255315

316+
config = ConfigManager().config
317+
if _has_cloud_credentials(config):
318+
_require_personal_workspace(name, config)
319+
256320
try:
257321
state_path = get_project_bisync_state(name)
258322

@@ -288,6 +352,7 @@ def setup_project_sync(
288352
config_manager = ConfigManager()
289353
config = config_manager.config
290354
_require_cloud_credentials(config)
355+
_require_personal_workspace(name, config)
291356

292357
async def _verify_project_exists():
293358
"""Verify the project exists on cloud by listing all projects."""

tests/cli/cloud/test_project_sync_command.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from typer.testing import CliRunner
88

99
from basic_memory.cli.app import app
10-
from basic_memory.config import ProjectMode
10+
from basic_memory.config import ProjectEntry, ProjectMode
11+
from basic_memory.schemas.cloud import WorkspaceInfo
1112

1213
runner = CliRunner()
1314

@@ -28,6 +29,9 @@ def test_cloud_sync_commands_skip_explicit_cloud_project_sync(monkeypatch, argv,
2829
config_manager.save_config(config)
2930

3031
monkeypatch.setattr(project_sync_command, "_require_cloud_credentials", lambda _config: None)
32+
monkeypatch.setattr(
33+
project_sync_command, "_require_personal_workspace", lambda _name, _config: None
34+
)
3135
monkeypatch.setattr(
3236
project_sync_command,
3337
"get_mount_info",
@@ -63,6 +67,9 @@ def test_cloud_bisync_fails_fast_when_sync_entry_disappears(monkeypatch, config_
6367
config_manager.save_config(config)
6468

6569
monkeypatch.setattr(project_sync_command, "_require_cloud_credentials", lambda _config: None)
70+
monkeypatch.setattr(
71+
project_sync_command, "_require_personal_workspace", lambda _name, _config: None
72+
)
6673
monkeypatch.setattr(
6774
project_sync_command,
6875
"get_mount_info",
@@ -88,5 +95,104 @@ def test_cloud_bisync_fails_fast_when_sync_entry_disappears(monkeypatch, config_
8895
assert "unexpectedly missing after validation" in result.output
8996

9097

98+
@pytest.mark.parametrize(
99+
"argv",
100+
[
101+
["cloud", "sync", "--name", "research"],
102+
["cloud", "bisync", "--name", "research"],
103+
["cloud", "check", "--name", "research"],
104+
["cloud", "bisync-reset", "research"],
105+
["cloud", "sync-setup", "research", "/tmp/research"],
106+
],
107+
)
108+
def test_cloud_sync_commands_block_organization_workspace(monkeypatch, argv, config_manager):
109+
"""Rclone sync commands should fail before setup/execution for Team workspaces."""
110+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
111+
112+
config = config_manager.load_config()
113+
config.cloud_api_key = "bmc_test"
114+
config.projects["research"] = ProjectEntry(
115+
path="/tmp/research",
116+
mode=ProjectMode.CLOUD,
117+
workspace_id="team-tenant",
118+
local_sync_path="/tmp/research",
119+
)
120+
config_manager.save_config(config)
121+
122+
monkeypatch.setattr(
123+
project_sync_command,
124+
"get_available_workspaces",
125+
lambda: _async_value([_workspace("team-tenant", "organization", "team")]),
126+
)
127+
monkeypatch.setattr(
128+
project_sync_command,
129+
"get_mount_info",
130+
lambda: pytest.fail("workspace guard should run before mount lookup"),
131+
)
132+
133+
result = runner.invoke(app, argv)
134+
135+
assert result.exit_code == 1, result.output
136+
assert "Local rclone sync/bisync is supported only for Personal workspaces" in result.output
137+
assert "Team workspaces are accessed through the cloud API/MCP" in result.output
138+
139+
140+
def test_require_personal_workspace_allows_personal_workspace(monkeypatch, config_manager):
141+
"""Personal workspaces keep the existing rclone sync path available."""
142+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
143+
144+
config = config_manager.load_config()
145+
config.projects["research"] = ProjectEntry(
146+
path="/tmp/research",
147+
mode=ProjectMode.CLOUD,
148+
workspace_id="personal-tenant",
149+
local_sync_path="/tmp/research",
150+
)
151+
config_manager.save_config(config)
152+
153+
monkeypatch.setattr(
154+
project_sync_command,
155+
"get_available_workspaces",
156+
lambda: _async_value([_workspace("personal-tenant", "personal", "personal")]),
157+
)
158+
159+
workspace = project_sync_command._require_personal_workspace("research", config)
160+
161+
assert workspace.tenant_id == "personal-tenant"
162+
163+
164+
def test_bisync_reset_skips_workspace_check_without_credentials(monkeypatch, tmp_path):
165+
"""Resetting local bisync state stays harmless when no cloud credentials exist."""
166+
project_sync_command = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
167+
168+
monkeypatch.setattr(
169+
project_sync_command,
170+
"get_available_workspaces",
171+
lambda: pytest.fail("workspace lookup requires cloud credentials"),
172+
)
173+
monkeypatch.setattr(
174+
project_sync_command,
175+
"get_project_bisync_state",
176+
lambda _name: tmp_path / "missing-state",
177+
)
178+
179+
result = runner.invoke(app, ["cloud", "bisync-reset", "research"])
180+
181+
assert result.exit_code == 0, result.output
182+
assert "No bisync state found for project 'research'" in result.output
183+
184+
91185
async def _async_value(value):
92186
return value
187+
188+
189+
def _workspace(tenant_id: str, workspace_type: str, slug: str) -> WorkspaceInfo:
190+
return WorkspaceInfo(
191+
tenant_id=tenant_id,
192+
workspace_type=workspace_type,
193+
slug=slug,
194+
name=slug.title(),
195+
role="owner",
196+
is_default=False,
197+
has_active_subscription=True,
198+
)

0 commit comments

Comments
 (0)