Skip to content

Commit c99c3f7

Browse files
committed
fix(cli): address mount PR review feedback
From the Codex and claude-review automated reviews on PR #920: - Route the cloud project lookup through the resolved workspace (Codex P2): _get_cloud_project now takes workspace_id and passes it to get_client, so the project metadata is read from the same tenant the bucket/remote target. Without this, --workspace (or a non-default config) could read a different tenant's project and transfer against the wrong bucket. - Validate workspace slugs before splicing into a remote name (claude-review, security): remote_name_for_workspace rejects slugs with characters invalid in an rclone remote section, failing fast instead of writing a broken rclone.conf. - Fix the now-stale rclone_commands module docstring (single-remote bullet). - Clarify that generate_mount_credentials' tenant_id is the X-Workspace-ID routing key. - Note (TODO #919) why sync/bisync/check still use default-tenant mount info. - Tests: pull --workspace routing, slug validation rejects unsafe slugs. Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 8b8e400 commit c99c3f7

6 files changed

Lines changed: 81 additions & 10 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
7777
config = config_manager.config
7878
host_url = config.cloud_host.rstrip("/")
7979

80+
# The mount endpoints resolve X-Workspace-ID by matching the workspace's
81+
# tenant_id, so passing a tenant_id here is the correct routing key.
8082
response = await make_api_request(
8183
method="POST",
8284
url=f"{host_url}/tenant/mount/credentials",

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,15 @@ def _require_personal_workspace(
186186
return workspace
187187

188188

189-
async def _get_cloud_project(name: str) -> ProjectItem | None:
190-
"""Fetch a project by name from the cloud API."""
191-
async with get_client(project_name=name) as client:
189+
async def _get_cloud_project(name: str, *, workspace_id: str | None = None) -> ProjectItem | None:
190+
"""Fetch a project by name from the cloud API.
191+
192+
``workspace_id`` routes the lookup to a specific tenant so the project
193+
metadata comes from the same workspace the transfer targets (otherwise
194+
get_client would resolve the workspace from config/default and could read a
195+
different tenant — see #920 review).
196+
"""
197+
async with get_client(project_name=name, workspace=workspace_id) as client:
192198
projects_list = await ProjectClient(client).list_projects()
193199
for proj in projects_list.projects:
194200
if generate_permalink(proj.name) == generate_permalink(name):
@@ -252,7 +258,10 @@ def sync_project_command(
252258
_require_personal_workspace(name, config, unsupported_message=TEAM_WORKSPACE_SYNC_UNSUPPORTED)
253259

254260
try:
255-
# Get tenant info for bucket name
261+
# Get tenant info for bucket name.
262+
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
263+
# because these mirror commands are gated to the (default-tenant) Personal
264+
# workspace, so the default mount info is correct.
256265
tenant_info = run_with_cleanup(get_mount_info())
257266
bucket_name = tenant_info.bucket_name
258267

@@ -353,9 +362,12 @@ def _run_directional_transfer(
353362
tenant_info = run_with_cleanup(get_mount_info(workspace_id=target_workspace.tenant_id))
354363
bucket_name = tenant_info.bucket_name
355364

356-
# Get project info
365+
# Get project info from the same workspace we resolved above, so the
366+
# project path and the bucket/remote all refer to one tenant.
357367
with force_routing(cloud=True):
358-
project_data = run_with_cleanup(_get_cloud_project(name))
368+
project_data = run_with_cleanup(
369+
_get_cloud_project(name, workspace_id=target_workspace.tenant_id)
370+
)
359371
if not project_data:
360372
console.print(f"[red]Error: Project '{name}' not found[/red]")
361373
raise typer.Exit(1)
@@ -512,7 +524,10 @@ def bisync_project_command(
512524
_require_personal_workspace(name, config)
513525

514526
try:
515-
# Get tenant info for bucket name
527+
# Get tenant info for bucket name.
528+
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
529+
# because these mirror commands are gated to the (default-tenant) Personal
530+
# workspace, so the default mount info is correct.
516531
tenant_info = run_with_cleanup(get_mount_info())
517532
bucket_name = tenant_info.bucket_name
518533

@@ -570,7 +585,10 @@ def check_project_command(
570585
_require_cloud_credentials(config)
571586

572587
try:
573-
# Get tenant info for bucket name
588+
# Get tenant info for bucket name.
589+
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
590+
# because these mirror commands are gated to the (default-tenant) Personal
591+
# workspace, so the default mount info is correct.
574592
tenant_info = run_with_cleanup(get_mount_info())
575593
bucket_name = tenant_info.bucket_name
576594

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
33
This module provides simplified, project-scoped rclone operations:
44
- Each project syncs independently
5-
- Uses single "basic-memory-cloud" remote (not tenant-specific)
5+
- Routes through the project's tenant-scoped remote (SyncProject.remote_name);
6+
the default tenant keeps "basic-memory-cloud", others use their own (see #919)
67
- Balanced defaults from SPEC-8 Phase 4 testing
78
- Per-project bisync state tracking
89

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import configparser
88
import os
9+
import re
910
import shutil
1011
from pathlib import Path
1112
from typing import Optional
@@ -67,14 +68,29 @@ def save_rclone_config(config: configparser.ConfigParser) -> None:
6768
DEFAULT_RCLONE_REMOTE = "basic-memory-cloud"
6869

6970

71+
# rclone remote section names allow letters, digits, hyphens, and underscores.
72+
# The slug comes from the cloud API (a trust boundary), so validate it before
73+
# splicing it into a remote name to avoid a broken/unusable rclone.conf section.
74+
_SAFE_SLUG = re.compile(r"^[A-Za-z0-9_-]+$")
75+
76+
7077
def remote_name_for_workspace(slug: str | None, *, is_default: bool) -> str:
7178
"""Return the rclone remote name for a workspace.
7279
7380
The default workspace keeps the legacy ``basic-memory-cloud`` remote so
7481
existing setups keep working; other workspaces get ``basic-memory-cloud-<slug>``.
82+
83+
Raises:
84+
RcloneConfigError: If a non-default workspace slug contains characters
85+
that are not valid in an rclone remote name.
7586
"""
7687
if is_default or not slug:
7788
return DEFAULT_RCLONE_REMOTE
89+
if not _SAFE_SLUG.match(slug):
90+
raise RcloneConfigError(
91+
f"Workspace slug '{slug}' cannot be used as an rclone remote name "
92+
"(allowed: letters, digits, hyphens, underscores)."
93+
)
7894
return f"{DEFAULT_RCLONE_REMOTE}-{slug}"
7995

8096

tests/cli/cloud/test_project_sync_command.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ def _stub_transfer_env(
409409
monkeypatch.setattr(
410410
module,
411411
"_get_cloud_project",
412-
lambda _name: _async_value(SimpleNamespace(name="research", path="research")),
412+
lambda _name, **_kwargs: _async_value(SimpleNamespace(name="research", path="research")),
413413
)
414414
monkeypatch.setattr(
415415
module,
@@ -540,6 +540,29 @@ def test_cloud_push_allows_organization_workspace(monkeypatch, config_manager):
540540
assert recorder["args"][0].remote_name == "basic-memory-cloud-acme"
541541

542542

543+
def test_cloud_pull_workspace_override_routes_through_workspace_remote(monkeypatch, config_manager):
544+
"""pull --workspace routes through the named workspace's own remote and bucket."""
545+
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
546+
547+
org_ws = _workspace("team-tenant", "organization", "acme", is_default=False)
548+
plan = TransferPlan(new=["new.md"], conflicts=[], dest_only=[], errors=[])
549+
recorder: dict = {}
550+
551+
# _get_workspace_for_project must receive the override and return the org workspace.
552+
def _resolve(_name, _config, *, workspace_override=None):
553+
assert workspace_override == "acme"
554+
return _async_value(org_ws)
555+
556+
_stub_transfer_env(monkeypatch, module, plan=plan, recorder=recorder, workspace=org_ws)
557+
monkeypatch.setattr(module, "_get_workspace_for_project", _resolve)
558+
559+
result = runner.invoke(app, ["cloud", "pull", "--name", "research", "--workspace", "acme"])
560+
561+
assert result.exit_code == 0, result.output
562+
assert recorder["args"][0].remote_name == "basic-memory-cloud-acme"
563+
assert recorder["args"][2] == "pull"
564+
565+
543566
def test_cloud_push_errors_when_workspace_remote_not_set_up(monkeypatch, config_manager):
544567
"""If the workspace's remote isn't configured, push stops with the setup command."""
545568
module = importlib.import_module("basic_memory.cli.commands.cloud.project_sync")

tests/cli/cloud/test_rclone_config_and_bmignore_filters.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import time
22

3+
import pytest
4+
35
from basic_memory.cli.commands.cloud.bisync_commands import convert_bmignore_to_rclone_filters
46
from basic_memory.cli.commands.cloud.rclone_config import (
7+
RcloneConfigError,
58
configure_rclone_remote,
69
get_rclone_config_path,
710
rclone_remote_exists,
@@ -123,6 +126,14 @@ def test_remote_name_for_workspace():
123126
assert remote_name_for_workspace("acme", is_default=False) == "basic-memory-cloud-acme"
124127

125128

129+
def test_remote_name_for_workspace_rejects_unsafe_slug():
130+
# A slug from the API with characters invalid in an rclone remote name must
131+
# fail fast rather than write a broken rclone.conf section.
132+
for bad in ["a/b", "has space", "dot.dot", "weird:name"]:
133+
with pytest.raises(RcloneConfigError):
134+
remote_name_for_workspace(bad, is_default=False)
135+
136+
126137
def test_configure_rclone_remote_named_workspace_remote(config_home):
127138
remote = configure_rclone_remote(
128139
access_key="ak", secret_key="sk", remote_name="basic-memory-cloud-acme"

0 commit comments

Comments
 (0)