Summary
bm cloud pull and bm cloud push do not work on organization (Team) workspaces. Both commands are documented as the Team-safe sync path, and their transfer logic is deliberately not role-gated — but they cannot run, because their prerequisite fails for anyone who is not the workspace owner.
Personal workspaces are unaffected.
Reproduction
As a member of an organization workspace holding any role other than owner:
$ bm cloud pull --name <project> --workspace <workspace>
Workspace '<workspace>' is not set up for sync.
Run: bm cloud setup --workspace <workspace>
$ bm cloud setup --workspace <workspace>
Step 1: Installing rclone...
rclone is already installed
Step 2: Getting tenant information...
Found tenant: ...
Step 3: Generating sync credentials...
Setup failed: Failed to generate credentials: API request failed: Client error
'403 Forbidden' for url 'https://cloud.basicmemory.com/tenant/mount/credentials'
The CLI directs the user to a command that can never succeed for them, and offers no alternative.
Why it happens
_run_directional_transfer requires a tenant-scoped rclone remote and stops when one does not exist:
src/basic_memory/cli/commands/cloud/project_sync.py:496-502 — the missing-remote check and the bm cloud setup hint.
src/basic_memory/cli/commands/cloud/project_sync.py:467 — the transfer itself is intentionally ungated: "Uses additive rclone copy, so it never deletes on the destination — safe for Team workspaces and therefore not gated."
src/basic_memory/cli/commands/cloud/project_sync.py:872 — the CLI states the Team-safe commands "work on any workspace."
Provisioning that remote requires object-storage credentials from the cloud service, and that endpoint is restricted to workspace owners. Personal workspaces bypass the restriction because their members are always treated as owners, which is why this went unnoticed.
Proposed direction
Rather than widening who can obtain object-storage credentials, Team pull/push should not use them at all.
Object-storage credentials are scoped to an entire tenant bucket, so they bypass the service's own project-level access control — a member could reach projects they were never granted. No role restriction makes that safe, so loosening it is the wrong fix.
The cloud service already exposes a WebDAV surface that enforces the correct model through the application layer, where per-project access applies:
GET / PROPFIND require viewer access — so a viewer can pull.
PUT require editor access — so an editor can push.
Routing Team pull/push over that surface means:
bm cloud setup drops out of the Team path entirely — no remote to provision, no credentials to mint, and no 403.
- rclone stays an internal implementation detail of the Personal path and is never exposed through the CLI.
- There is precedent in this repo already:
bm cloud upload performs PUT /webdav/{project}/{path} over httpx with an X-OC-Mtime header (src/basic_memory/cli/commands/cloud/upload.py). It currently has no download counterpart; this work adds the other half.
Scope
CLI semantics do not change. bm cloud pull / bm cloud push keep their existing surface and behavior on Team workspaces, identical to Personal:
- additive — never deletes on the destination
--on-conflict [fail|keep-local|keep-cloud|keep-both], defaulting to fail
- no flags added, removed, or altered
This is not bidirectional sync. bisync remains gated to Personal workspaces and is explicitly out of scope.
Honoring --on-conflict requires detecting that a file differs on both sides, which needs a comparison primitive the current PROPFIND response does not provide — it returns size only. Size alone cannot distinguish an identical file from two edits that happen to be the same length, which is exactly the case the flag exists to catch.
Prerequisite: a companion change in the cloud service must expose an entity tag and last-modified time on PROPFIND (and ideally on GET, which currently returns no validators either). That work is tracked separately on the cloud side and should land first. It has been verified that the underlying object store returns single-part entity tags that are true content MD5s, including for objects well above the usual multipart threshold, so hash comparison is a viable basis for conflict detection.
Work in this repo
- Add the
GET / PROPFIND half to the existing WebDAV client in src/basic_memory/cli/commands/cloud/upload.py.
- Implement the directional-transfer comparison against entity tag plus size, so all four
--on-conflict strategies behave exactly as they do on the Personal path.
- Route Team workspaces through that transport; leave the Personal rclone path unchanged.
Treat a missing or multipart-shaped entity tag as "cannot compare by hash" and fall back to last-modified plus size. Silently treating such files as identical would lose an edit; treating them as conflicting would block every transfer.
Notes for implementers
Per CLAUDE.md / AGENTS.md in this repo:
- PR titles must be semantic and are enforced by
.github/workflows/pr-title.yml: type(scope): summary. Use an allowed type (feat, fix, chore, docs, style, refactor, perf, test, build, ci) and an allowed scope — cli or sync fit this work.
- Sign off commits with
git commit -s so the DCO check passes.
- Run
just typecheck alongside targeted ruff and pytest commands when tests change.
- Add regression tests covering each
--on-conflict strategy on the new transport.
Summary
bm cloud pullandbm cloud pushdo not work on organization (Team) workspaces. Both commands are documented as the Team-safe sync path, and their transfer logic is deliberately not role-gated — but they cannot run, because their prerequisite fails for anyone who is not the workspace owner.Personal workspaces are unaffected.
Reproduction
As a member of an organization workspace holding any role other than
owner:The CLI directs the user to a command that can never succeed for them, and offers no alternative.
Why it happens
_run_directional_transferrequires a tenant-scoped rclone remote and stops when one does not exist:src/basic_memory/cli/commands/cloud/project_sync.py:496-502— the missing-remote check and thebm cloud setuphint.src/basic_memory/cli/commands/cloud/project_sync.py:467— the transfer itself is intentionally ungated: "Uses additiverclone copy, so it never deletes on the destination — safe for Team workspaces and therefore not gated."src/basic_memory/cli/commands/cloud/project_sync.py:872— the CLI states the Team-safe commands "work on any workspace."Provisioning that remote requires object-storage credentials from the cloud service, and that endpoint is restricted to workspace owners. Personal workspaces bypass the restriction because their members are always treated as owners, which is why this went unnoticed.
Proposed direction
Rather than widening who can obtain object-storage credentials, Team
pull/pushshould not use them at all.Object-storage credentials are scoped to an entire tenant bucket, so they bypass the service's own project-level access control — a member could reach projects they were never granted. No role restriction makes that safe, so loosening it is the wrong fix.
The cloud service already exposes a WebDAV surface that enforces the correct model through the application layer, where per-project access applies:
GET/PROPFINDrequire viewer access — so a viewer can pull.PUTrequire editor access — so an editor can push.Routing Team
pull/pushover that surface means:bm cloud setupdrops out of the Team path entirely — no remote to provision, no credentials to mint, and no 403.bm cloud uploadperformsPUT /webdav/{project}/{path}overhttpxwith anX-OC-Mtimeheader (src/basic_memory/cli/commands/cloud/upload.py). It currently has no download counterpart; this work adds the other half.Scope
CLI semantics do not change.
bm cloud pull/bm cloud pushkeep their existing surface and behavior on Team workspaces, identical to Personal:--on-conflict [fail|keep-local|keep-cloud|keep-both], defaulting tofailThis is not bidirectional sync.
bisyncremains gated to Personal workspaces and is explicitly out of scope.Honoring
--on-conflictrequires detecting that a file differs on both sides, which needs a comparison primitive the currentPROPFINDresponse does not provide — it returns size only. Size alone cannot distinguish an identical file from two edits that happen to be the same length, which is exactly the case the flag exists to catch.Prerequisite: a companion change in the cloud service must expose an entity tag and last-modified time on
PROPFIND(and ideally onGET, which currently returns no validators either). That work is tracked separately on the cloud side and should land first. It has been verified that the underlying object store returns single-part entity tags that are true content MD5s, including for objects well above the usual multipart threshold, so hash comparison is a viable basis for conflict detection.Work in this repo
GET/PROPFINDhalf to the existing WebDAV client insrc/basic_memory/cli/commands/cloud/upload.py.--on-conflictstrategies behave exactly as they do on the Personal path.Treat a missing or multipart-shaped entity tag as "cannot compare by hash" and fall back to last-modified plus size. Silently treating such files as identical would lose an edit; treating them as conflicting would block every transfer.
Notes for implementers
Per
CLAUDE.md/AGENTS.mdin this repo:.github/workflows/pr-title.yml:type(scope): summary. Use an allowed type (feat,fix,chore,docs,style,refactor,perf,test,build,ci) and an allowed scope —cliorsyncfit this work.git commit -sso the DCO check passes.just typecheckalongside targetedruffandpytestcommands when tests change.--on-conflictstrategy on the new transport.