fix(cli): run Team push/pull over WebDAV instead of storage credentials - #1263
fix(cli): run Team push/pull over WebDAV instead of storage credentials#1263groksrc wants to merge 3 commits into
Conversation
`bm cloud pull` / `bm cloud push` could not run at all on organization (Team) workspaces. Both required a tenant-scoped rclone remote, and provisioning one mints object-storage credentials through an owner-only endpoint, so every other member was told to run `bm cloud setup` and got a 403 from it. Widening that endpoint is the wrong fix: storage credentials are scoped to a whole tenant bucket, so they cannot express per-project access. Team transfers now use the service's WebDAV surface, which authorizes each request against the caller's access to that project. - webdav.py adds the read half of the protocol (PROPFIND listing, GET) beside the PUT that `bm cloud upload` already used, including the entity tag and last-modified validators a transfer needs. - webdav_transfer.py compares by entity tag plus size, falling back to last-modified plus size when the tag cannot be a content hash (absent, weak, or multipart shaped), and reports a file as uncomparable rather than guessing when neither is available. - transfer.py holds the plan vocabulary both transports share. The CLI surface is unchanged: same commands, same flags, same `--on-conflict` default of `fail`, still additive in both directions. `bisync` remains gated to Personal workspaces, and the Personal rclone path is untouched. Depends on the companion cloud change that returns `getetag` and `getlastmodified` from PROPFIND and ETag / Last-Modified on GET. Refs #1262 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85845272a7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| suffix = "" if source_rel == dest_rel else f" -> {dest_rel}" | ||
| console.print(f" [dim]{source_rel}{suffix}[/dim]") | ||
| return | ||
|
|
There was a problem hiding this comment.
Revalidate destinations before transferring planned files
When another Team member creates a cloud file—or a local process creates a local file—after webdav_project_diff() classifies the path as new, these unconditional GET/PUT calls transfer against the stale plan. On pull, _pull_file() ultimately uses os.replace() and destroys the newly created local note; on push, the unconditional PUT can overwrite the teammate's new cloud note, even with the default --on-conflict fail. Use conditional destination creation or revalidate the planned validator immediately before writing so concurrent creations abort rather than overwrite canonical bytes.
AGENTS.md reference: AGENTS.md:L156-L160
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0408031.
The first thing I checked was whether the Personal path has the same window, since if it did, Team would not be worse and a narrower fix plus a documented residual would have been the right call. It does not — and not because of its plan. project_copy passes --ignore-existing, and rclone evaluates that against the destination listing it makes at copy time, not against the earlier rclone check. So a file that appeared in between is skipped there. This transport had no equivalent, which made it a real regression relative to Personal rather than a shared limitation.
So create-only transfers now carry that classification forward (_Transfer.create_only) and get the same property, per direction:
- Pull claims the path with
os.open(..., O_CREAT | O_EXCL). That is atomic, so there is no residual window here, and it refuses a symlink outright as a bonus — a link that appeared at the path is treated as the path being taken rather than followed. - Push re-reads the cloud immediately before the transfer loop and drops any create-only path that now exists. This narrows the window rather than closing it: nothing there is atomic with the PUT that follows.
Either way the file is left alone and named in the output, so a re-run compares it and --on-conflict applies. An explicit keep-local / keep-cloud resolution still overwrites — that is an instruction, not a stale guess — and there are tests for both halves.
On If-None-Match: *: I checked the write endpoint before relying on it, and it does not act on conditional request headers at all — it reads the request only to size and store the body. Sending the header would have been a guard that silently does nothing, so I did not ship it. Closing the push window properly needs a server-side conditional create (If-None-Match: * → 412 when the resource exists). That is out of scope here; it is called out in the PR body for a separate issue.
Tests: a local note and a cloud note each appearing after planning are left untouched and reported; both explicit resolutions still overwrite; and a failed download leaves no empty placeholder behind from the exclusive create.
| candidate = PurePosixPath(rel_path) | ||
| if "\\" in rel_path or candidate.is_absolute() or ".." in candidate.parts: | ||
| raise WebdavError(f"Refusing to transfer a path outside the project: {rel_path!r}") | ||
| return local_root.joinpath(*candidate.parts) |
There was a problem hiding this comment.
Reject symlinks that escape the project root
When a project contains a symlinked file or directory, this lexical check still accepts the path: scan_local_files() and _push_file() follow file symlinks and can upload data from outside the project to the Team workspace, while _pull_file() follows a symlinked parent and writes the downloaded file outside local_root. The normal project index explicitly skips symlinked files in src/basic_memory/index/local_project.py:236-272; resolve paths and enforce containment here as well, and skip symlinked local files during scanning.
AGENTS.md reference: AGENTS.md:L156-L160
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in 0408031. You are right that there was no symlink handling at all — _safe_local_path was purely lexical, and a lexical check cannot see a link.
Following the precedent you pointed at in local_project.py:
scan_local_filesnow walks withfollowlinks=False(explicit rather than relying on the default) and skips symlinked files, so push can never read bytes from outside the project boundary.- Containment is enforced on the resolved parent chain rather than the lexical path — that is the part that actually decides which directory is read from or written into, so a symlinked parent no longer defeats it.
- The final component is handled separately, because what should happen to a link there depends on what the transfer is about to do to it.
_refuse_symlinkrefuses where the transfer would follow it — reading a push source, or replacing a file the user resolved withkeep-cloud. Create-only pulls need no such check: their exclusive create already refuses a link, and treating it as "path taken" and skipping is better than aborting the whole transfer over a file the user may legitimately have symlinked.
Tests cover both directions refusing to transfer through a symlinked directory, symlinked files and directories being excluded from the scan, and keep-cloud refusing to overwrite a symlinked note (asserting the outside file's contents are untouched).
| if not rel_path: | ||
| return f"{WEBDAV_ROOT}/{project}" | ||
| return f"{WEBDAV_ROOT}/{project}/{rel_path}" |
There was a problem hiding this comment.
Percent-encode WebDAV path segments
When a valid POSIX filename or project name contains ? or #, concatenating it into a raw URL does not let httpx encode it as path data: ? starts a query and # starts a fragment. A listed notes/a#draft.md, for example, is subsequently requested as notes/a, causing a 404 or transferring the wrong object's bytes. Encode each project/path segment while preserving the intended / separators before passing the URL to httpx.
AGENTS.md reference: AGENTS.md:L156-L160
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and fixed in 0408031. My docstring asserted something that is not true: httpx percent-encodes characters that are unsafe within a component, but ? and # are structural delimiters, so it reads them as the start of a query and a fragment. A note named a#draft.md was requested as a.
webdav_path now encodes the project name and each path segment while preserving / as the separator (quote(rel_path, safe="/")). One knock-on: _same_path compares a PROPFIND href against the request path, and the request path is now encoded while the href may not be, so both sides are decoded before comparison — the question is which resource is named, not how it was spelled on the wire.
Tests: a parametrized check on webdav_path for #, ?, space, a space in the project name, and nested separators staying separators; a round trip through list → download for a#draft.md; and transfer-level tests that pull and push #, ?, and space filenames and assert on the paths the server actually receives.
…sfer Three review findings on the Team push/pull transport. Symlinks were not handled at all. `_safe_local_path` was a lexical check, which cannot see a link: a symlinked file or parent directory let push read bytes from outside the project into a shared workspace, and let pull write through to somewhere the user never pointed at. The local scan now skips symlinked files and does not descend into symlinked directories, matching what the local project scanner does for the same reason, and containment is enforced on the resolved parent chain rather than the lexical path. A link at the final component is refused where a transfer would follow it — reading a push source, or replacing a file resolved with keep-cloud. Request paths were not percent-encoded, on the assumption that the HTTP client would handle it. That does not hold for `?` and `#`, which are structural URL delimiters rather than path data: a note named `a#draft.md` was requested as `a`. Both are legal POSIX filenames. A path classified as `new` was transferred unconditionally, so a file that appeared on the destination between planning and writing was destroyed — pull replaced a newly created local note, push overwrote a teammate's, even under the default `--on-conflict fail`. The Personal path does not have this problem, and not because of its plan: `project_copy` passes `--ignore-existing`, which rclone evaluates against the destination listing it makes at copy time. Create-only transfers now get the same property. Pull claims the path with an exclusive create, which is atomic and also refuses a link. Push re-reads the cloud immediately before transferring and drops any create-only path that now exists; that narrows the window rather than closing it, because the write endpoint does not act on conditional request headers, so an `If-None-Match: *` guard would silently do nothing. Files left alone are named in the output rather than skipped quietly, so a re-run compares them as conflicts. Refs #1262 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
|
Review follow-up: two create-only transfer paths still need hard atomicity before this is safe to merge.
The symlink and percent-encoding follow-ups otherwise look well addressed. |
Both create-only paths were relying on a check that happened before the write, which is never the same thing as a write that refuses. Pull created and closed an empty destination file, then downloaded, then published with an unconditional `os.replace`. That placeholder was visible for the whole download window — an editor could open it and write real content, and the replace discarded it — and any failure after the download left it behind as a zero-byte note. Nothing is now created at the destination until the bytes exist: the download lands in a sibling temp file and is published with `os.link`, which is atomic and fails outright if the name is taken. A filesystem with no hardlinks (exFAT, some virtual and network mounts) falls back to an exclusive create, which claims the name just as atomically and is weaker only in that a reader can catch the new file mid-write. Push re-listed the cloud once, then issued sequential unconditional PUTs, so the window for the Nth file grew with every file ahead of it in the queue. Create-only uploads now send `If-None-Match: *` and treat 412 as "appeared; left untouched", reported exactly as the pull side reports it. The re-list stays, but as an optimization and a reporting aid rather than the correctness boundary. Explicit keep-local/keep-cloud resolutions deliberately send no precondition — they are instructions to replace. Also drops `?` from the filesystem-touching delimiter test. Windows rejects it as a filename character, which broke the Windows unit job; `?` stays covered at the client level, where nothing is written to disk. This depends on the cloud companion honoring `If-None-Match: *` on PUT (201/204 on create, 412 when the resource exists). Refs #1262 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
|
Both fixed in 97c0612. You were right that the Pull. Nothing is created at the destination until the bytes exist. The download lands in a sibling temp file, and publication is On hardlink availability — you asked me to evaluate and say what I chose. I kept One consequence worth flagging: the download now runs before we discover the name is taken, so a raced pull wastes one download. That is the direct cost of "nothing at the destination before the bytes exist," and it seemed clearly the right trade. Push. Create-only uploads now send I did not need to stop on the backend question: Also in this push: a genuine CI failure I introduced last round. Tests: |
|
Both addressed in 97c0612. Pull — nothing is created at the destination until the bytes exist. The empty placeholder is gone. The download now lands in a sibling temp file, fully written and timestamped, and only then is the name claimed. The timestamp is applied before publication deliberately: mtime lives on the inode, so a hardlinked publish shares it and there is no window in which the published note carries the wrong one. Publication is Filesystems that cannot hardlink (exFAT, some virtual and network mounts) fall back to an exclusive create, which claims the name just as atomically. That fallback is weaker in exactly one way, stated rather than hidden: a reader can catch the new file mid-write. No unlinked filesystem can do better. Genuine failures such as no space or no permission still surface rather than being swallowed by the fallback. An explicit Push — the conditional header is now the correctness boundary. Create-only uploads send Coverage for a destination created during the download, content written into the destination during the download, a publication failure after a successful download leaving nothing behind, the no-hardlink fallback, and the conditional-push paths including both strategy distinctions above. 200 tests pass. The 7 failures in Still depends on the companion server change, which now covers both the validators and |
Fixes the Team-workspace half of #1262.
Problem
bm cloud pull/bm cloud pushare documented as the Team-safe sync path, and theirtransfer logic is deliberately not role-gated — but on an organization workspace they
could not run at all. Both require a tenant-scoped rclone remote, provisioning one mints
object-storage credentials, and that endpoint is restricted to workspace owners. Every
other member was pointed at
bm cloud setupand got a 403 from it.Widening that endpoint would be the wrong fix. Storage credentials are scoped to an
entire tenant bucket, so they cannot express "this member may read project A but not
project B" — they would grant more access than the service itself does.
Approach
Team transfers now run over the service's WebDAV surface, where every request is
authorized against the caller's access to the specific project (
GET/PROPFINDneedviewer,
PUTneeds editor).bm cloud setupdrops out of the Team path entirely: noremote to provision, no credentials to mint, no 403.
The Personal rclone path is untouched, and rclone stays an internal detail of it.
Design choices
webdav.py, a sibling ofupload.py, rather than growingupload.py.upload.pyis the implementation of one command — a directory walk that prints itsown progress and owns that command's filtering rules. The new module is protocol only
(
PROPFIND,GET,PUT, entity-tag normalization), with no CLI output and no policy,so the upload command and the push/pull engine can share one definition of how a
project's files are addressed.
upload.py's only change is to build its remote paththrough that shared helper.
transfer.pyholds the plan vocabulary (TransferPlan,TransferDirection,ConflictStrategy, and the two conflict-resolution helpers), moved out ofrclone_commands.py. It is transport-agnostic, and keeping it separate means theWebDAV engine does not import rclone's subprocess machinery to reuse it.
PROPFINDis walked, not requested recursively. The surface answers for onecollection at a time, so a project listing is a breadth-first walk; each path is
listed once. Entry names come from
displayname(the literal, unencoded name), withthe percent-decoded final href segment as the fallback for servers that omit it.
whenever it differs, so a large file is never hashed to learn what its length already
proved. A single-part entity tag is an MD5 of the bytes and is compared directly. A
tag that cannot be a content hash — absent, opaque, weak (
W/), or multipart-shaped(
-N) — falls back to last-modified plus size. When neither a usable tag nor atimestamp is available and sizes match, the file is reported as uncomparable and the
transfer aborts rather than guessing. Treating such a file as identical would lose an
edit; treating it as always-conflicting would block every transfer.
modtimes, which is what keeps the timestamp fallback meaningful after a pull. Files are
written to a sibling temp file and renamed, so an interrupted pull cannot leave a
half-written note behind.
..segment,or a backslash is refused at planning time — remote input decides where we write on
pull, so it is checked before a plan is even presented.
way rclone's
--filter-fromapplies to both sides of a transfer.Unchanged
Same commands, same flags, same defaults.
--on-conflict [fail|keep-local|keep-cloud|keep-both]still defaults to
fail. Transfers stay additive — nothing is ever deleted on thedestination — and deletions are still not propagated. No flags added, removed, or renamed.
bisyncremains gated to Personal workspaces and is untouched.Review follow-ups
Three findings from review, all fixed in the second commit.
symlinked file or parent directory let push read bytes from outside the project into a
shared workspace, and let pull write through to outside
local_root. The local scan nowskips symlinked files and does not descend into symlinked directories, matching the local
project scanner; containment is enforced on the resolved parent chain; and a link at the
final component is refused where a transfer would follow it.
?and#are structural URL delimiters, not path data, so a notenamed
a#draft.mdwas requested asa. Request paths are now encoded per segment withthe separators preserved, and the self-href comparison decodes both sides.
newwas transferred unconditionally, so a filethat appeared on the destination between planning and writing was destroyed — even under
the default
--on-conflict fail. See below.On the stale-plan window
The Personal path does not have this problem, and not because of its plan:
project_copypasses
--ignore-existing, which rclone evaluates against the destination listing it makesat copy time, not against the earlier
rclone check.A first pass gave this transport a listing-based equivalent. That was not enough — a check
that happens before the write is never the same thing as a write that refuses — so the
guarantee now lives at the write itself, in both directions:
os.link, which is atomicand fails outright if the name is taken. Nothing exists at the destination until the bytes
do, so there is no placeholder for an editor to fill and have discarded, and no failure
path that leaves a zero-byte note behind. On a filesystem without hardlinks (exFAT, some
virtual and network mounts — the kind this project already accommodates elsewhere), it
falls back to an exclusive create, which claims the name just as atomically and is weaker
only in that a reader can catch the new file mid-write. A real failure still surfaces
rather than being swallowed.
If-None-Match: *on create-only uploads and treats412as "appeared;left untouched". The pre-transfer re-list stays, but as an optimization and a reporting
aid: it avoids uploading bytes certain to be refused and names collisions up front. It is
explicitly not the correctness boundary, because its window grows with every file ahead
of the Nth in the queue.
Either way the file is left alone and named in the output, so a re-run compares it and
--on-conflictapplies. Explicitkeep-local/keep-cloudresolutions deliberately sendno precondition and still replace — those are instructions, not stale guesses.
Dependency — please land the server changes first
This codes against companion cloud-side changes on two fronts. Neither is deployed yet.
getetagandgetlastmodifiedon thePROPFINDresponse, andETag/Last-Modified/Content-Lengthheaders onGET.If-None-Match: *honored onPUT: create normally (201/204), and412when theresource already exists. Push's no-clobber guarantee rests on this; until it lands, a
create-only PUT is unconditional, which is the behavior this PR is fixing. Until it is,
every file present on both sides will be reported as uncomparable and the transfer will
abort — which is the intended fail-fast behavior, not a silent wrong answer, but it does
mean this is not usable end to end until the server side lands.
Accordingly, the tests here use fixture
PROPFINDXML and mocked HTTP responses. No liveend-to-end call was made against production cloud, deliberately: it would not yet return
validators, so a failure there would prove nothing.
The companion server now paginates each
PROPFINDcollection to completion and failsclosed if object storage reports truncation without a continuation token. Planning
therefore never proceeds from a partial listing.
Tests
New:
tests/cli/cloud/test_webdav_client.py— PROPFIND parsing and the directory walk(including a child collection whose href collides with the collection being listed),
name resolution and its href fallback, entity-tag normalization and the single-part /
multipart / weak / opaque cases, GET and PUT, and the error mapping for rejected and
transport-level failures.
tests/cli/cloud/test_webdav_transfer.py— plan classification, ignore filtering onboth sides, the path-escape refusal, the etag-unavailable fallback (match, diverged
timestamp, sub-second drift, and the no-validator-at-all case), and all four
--on-conflictstrategies for both push and pull, plus dry-run and verbose.tests/cli/cloud/test_project_sync_command.py— Team routing at the CLI level: bothdirections go over WebDAV and never reach rclone, storage credentials, or the
bm cloud setuphint; the conflict gate, the uncomparable-file abort, the strategy anddry-run/verbose pass-through, and the failure and missing-project paths.
Added for the review follow-ups: symlinked files and directories excluded from the scan;
pull and push both refusing to transfer through a symlinked directory; keep-cloud refusing
to overwrite a symlinked note; a local and a cloud file appearing after planning being left
untouched and reported; explicit resolutions still overwriting; no placeholder left behind
when a download fails; and
#,?, and space in filenames round-tripping through list,download, and upload.
Added for the atomicity round: a note created during the download surviving; content
written into the destination during the download surviving, with an assertion that nothing
is staged there beforehand; a publication failure after a successful download leaving nothing
behind; the no-hardlink path still refusing to clobber;
If-None-Match: *sent on create-onlypushes and
412reported as appeared; conflict copies being conditional too; and keep-localdeliberately sending no precondition.
One Windows-only fix:
?is not a legal filename character there, so the delimiter test thatactually writes to disk now covers
#and space, and?stays covered at the client levelwhere nothing touches the filesystem. That was a genuine CI failure on the previous push, not
a pre-existing one.
Commands run:
The 2 test failures and all 4 type diagnostics are pre-existing and reproduce identically
on
mainatea38fd76:test_cloud_pull_clean_transfersandtest_cloud_prune_dry_run_previews_without_deletingassert on
[dim]-styled console output, which carries ANSI escapes in a local terminal.New tests added here strip styling before asserting, so they are not affected. Running
that file with color enabled surfaces 7 failures for the same reason; the sorted failure
list is byte-identical between this branch and
main.unresolved-importforpymilvusintests/repository/test_milvus_repository.py, an optional dependency not installed inthis environment.
Not run: the full suite, the Postgres matrix, and any live cloud call.