fix(remote): refuse Path values at the SshCache boundary - #828
Conversation
Path values are stored by content, but the RPC ships values by cloudpickle and a pickled Path is only its string. A path handed to the remote was therefore a name the *server* resolved against its own filesystem: * if a file sat at that name, the server stored those bytes under their digest — breaking `digest(x) == save_value(x)`, so the record landed under a key no client ever recomputes, and a load returned the wrong content; * if nothing sat there, `Indigestible` surfaced from inside an RPC; * on load the server materialized into a temp dir on its own disk and sent back only the name — dangling here, and unlinked there as soon as the server's own reference died (`PurePath.__reduce__` rebuilds from `parts`, so `TempPath`'s temp-root guard does not survive the hop). Refuse instead, in both directions, via the new `RemotePathUnsupported`. It subclasses `SaveError`, so the existing two-phase-save degradations carry it without any caller special-casing: a path argument falls back to a digest-only reference whose digest is computed locally (the seal stays intact and lookups still hit), and a path result becomes `Rejected` — the call runs and returns normally, just uncached. Detection uses `storage.paths.find_path`, which walks a value through the new `storage.destructuring.child_slots` — the read-only half of `_intern_rec`'s dispatch — so nesting is covered exactly as far as a destructuring save would reach, and no further. Documents the contract in the file-semantics page (plus a quick-reference row and a recipe) and the mechanism in the path-storage dev page, including what full path support over SSH would additionally require. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RascUYS7JgCMHHpXsYbPTf
|
CI is red on The job never reaches checkout, so no code in this PR is involved. Same failure on the original run and on a re-run ~7 min later, so the outage is ongoing. Collateral damage on the same commit: the Nothing to fix here. I'll re-run For the record, locally on Generated by Claude Code |
pmrv
left a comment
There was a problem hiding this comment.
Impl ok for now, but let's track the problem in an issue. I think this should be solvable.
|
Tracked as #829 — agreed it's solvable, and the shape is fairly clear: Also pushed e4a6f8e addressing the four inline threads on #797: "feels almost more like it should go into a more generic section" — you were right that it isn't file-specific. Verified it generalizes (a mutated list argument behaves identically: cold call mutates, hit leaves the argument untouched, mutated-and-returned is stored post-mutation), so it's now "cross ref destructuring here, since that's the key" — done, and reframed the section around it: only destructured children reach the path machinery, so "move the completedprocess digester into fleche itself" — now a match arm in "save executed version" — Verification: 1788 passed / 11 skipped. Generated by Claude Code |
|
separate PRs for both commits |
The guard added in the previous commit refuses Path values rather than solving them; per review, the problem itself is now tracked. The shape is already implied by PathValueMixin: it reduces a path to bytes plus a FileBlob/DirectoryBlob, all of which ship fine over the wire, and those blobs' `__digest__` is *defined* to match the Path digest arm — so running the reduction client-side keeps the seal intact by construction. The one genuinely new piece is an unmended `load_value` that returns the blob instead of materializing it on the server. Referenced from `RemotePathUnsupported`, the path-storage dev page, and the DEVELOPING design-theme cheat sheet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RascUYS7JgCMHHpXsYbPTf
e4a6f8e to
c047dd3
Compare
|
Split — the four inline threads are now #831, this PR is the The split isn't quite along the commit boundary, because the second commit carried three hunks that were about this PR: the Result: Re-verified after the split: 1777 passed / 11 skipped here, 1763 on #831 (the two add 25 and 11 tests to Generated by Claude Code |
|
🤖 CI failure summary for Summary Root cause Suggested fix
Generated by Claude Haiku 4.5. |
…omment-r1zhhx # Conflicts: # src/fleche/storage/paths.py
Conflict in `SshCache`, in `remote.py` — and not only textual. Both sides changed `save`, and `prepared-call` adds `prepare`, an RPC that carries argument *values* rather than digests. That is a fourth way a `Path` can reach the server, which the guard added in #828 did not cover: shipping the live call hands the far side a path *string* to resolve against its own filesystem, breaking `digest(x) == save_value(x)` exactly as before. Resolved by keeping both sides and closing that route: - `save` handles `PreparedCall` (ship result, then file the record) as well as the live-`Call` and `DigestedCall` forms, routing the result through `save_value` rather than `_rpc` so the path guard applies to it. - `prepare` falls back to the local `BaseCache` two-phase prepare when any argument carries a path. Each argument then goes through `save_value`, where a path degrades to a digest-only reference computed *locally* — the seal stays intact and lookups still hit, which is the same degradation #828 established for the other routes. Also widens `find_path` itself, which was too narrow to be safe. It mirrored *destructuring*, but the invariant at stake is `digest(x) == save_value(x)`, and `digest` recurses further than destructuring does: into namedtuples, sets, and frozensets, reading the file in each. A path hidden in one of those therefore still decides the key while passing the guard, so `Bundle(path, 0.5)` crossed the wire and the server digested that name against its own disk. The walk now follows `digest` instead, stopping where `digest` stops (an arbitrary object is `Indigestible`, so no path inside it can decide a key) and declining to consume one-shot iterables. 1807 passed, 11 skipped; `ty check src/` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RascUYS7JgCMHHpXsYbPTf
) ## Summary Audit sweep over `agents/DEVELOPING.md`. Records the recent code changes since the last docs sync (`cad32cd`) so the file stays a reliable starting point for a fresh session. ### Landed since last sync - **PR #793** (`feat(caches)!`, released as 2.0.0). New two-phase save protocol — `BaseCache.prepare(call) → PreparedCall`; `Cache.prepare` stashes arguments into `self.values` immediately so the recorded lookup key describes the arguments as they were *before* the body ran (an argument-mutating body could previously leak its post-mutation state into the record). `PreparedCall.commit(result, metadata)` calls `cache.save(self)` on the sealed record; `.abandon()` releases without filing; `.resolve(values)` stores the pending result into a value storage (used by `Cache.save` and by `SshCache.save`'s two-trip commit). `CacheWrapper.prepare` rebinds `PreparedCall.cache=self` so wrapper policy still governs the commit; `CacheStack.prepare` delegates to `stack[0]`; `ReadOnlyMixin.prepare` seals the key with a digest-only admission so the body still runs uncached. Wrapper (`wrapper.py`) integrates the protocol with a fallback to uncached execution on `prepare` failure and `prepared.abandon()` around every exception path. On the wire, `SshCache.prepare` is one RPC returning the sealed `DigestedCall`; `SshCache.save(prepared)` is two RPCs (`save_value` + `save`) — a `PreparedCall` never crosses the wire. - **PR #818** (`test(caches)`). Pins the corrupt-cache silent-skip contract in `Cache._query` — an exploding `DigestedCall.fetch` in the middle of a query stream now provably logs at ERROR on `fleche.cache` and continues rather than aborting iteration. Test lives in `tests/unit/caches/test_cache.py::test_cache_query_logs_and_skips_calls_that_fail_to_fetch`. - **PR #839** + follow-ups. All eight notebooks are symlinked into `docs/notebooks/` and executed by `test_notebooks.py`; the test parametrisation flipped from a hand-maintained five-file list to `sorted(NOTEBOOKS_DIR.glob("*.ipynb"))`. `ConcurrentExecution.ipynb` is pinned to `multiprocessing.get_context("fork")` to survive Python 3.14's `forkserver` default. ### Open-scope updates - 2026-08-07 refactor batch (#832 split `remote.py`, #833 collapse `to_config`/`register_storage` boilerplate, #834 turn `_digest_bytes` `match/case` into a dispatch table, #835 hoist `_redact_config`/`_redact_url_password` to `config.py`). - Bugs #826 (`gc()` can collect in-flight `PreparedCall` argument values) and #840 (`BoundWrapper` only survives `ProcessPoolExecutor` under `fork`). - Feature request #829 (`SshCache` `Path`-value support — PR #828 has the refusal on the `temppath` feature branch, not on `main`). ## Test plan - [x] `git diff --stat` shows `agents/DEVELOPING.md` only. - [x] Every added claim spot-checked against source: `PreparedCall` at `src/fleche/call.py:335`, `Cache.prepare` at `src/fleche/caches.py:322`, `SshCache.prepare` at `src/fleche/remote.py:859`, `_save_value` at `src/fleche/remote.py:145`, `test_prepared_call.py` present, corrupt-cache test at `tests/unit/caches/test_cache.py`, `test_notebooks.py` glob, `docs/notebooks/` symlink set. - [x] Open-issue numbers verified via `list_issues` (state=OPEN) against `pmrv/fleche` on 2026-08-08. --- _Generated by [Claude Code](https://claude.ai/code/session_014DeWPsJ97GnPLhauttvM6N)_ Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
Answers your comment on #797, "Interactions with ssh cache unclear". Targets
temppath.The four inline review threads on #797 moved to #831, which has since merged —
temppathwas merged back in here (f938975, no conflicts), so this PR is theSshCache×Pathwork only.What the interaction actually was
Probed with a real
python -m fleche remote --servesubprocess, client and server in different working directories so the same relative name denotes different files on each side. Three behaviours, all silent:digest(x) == save_value(x)broken, so the record is filed under a key no client recomputes, and a load returns the wrong contentIndigestiblesurfaces from inside an RPCThe last one is not fixable by holding a reference:
PurePath.__reduce__rebuilds frompartsalone, soTempPath's_temp_rootis dropped in transit and_live_rootsis per-process. Thesave_valuecomment already said "paths over SSH remain unsupported" — nothing enforced it.What this does
Refuses, in both directions, via a new
RemotePathUnsupported. It subclassesSaveError, so the two-phase-save degradations already in the codebase carry it and no caller special-cases anything:Rejected. The call runs, returns the file, is logged as not cached.Detection is
storage.paths.find_path, walking a value through a newstorage.destructuring.child_slots— the read-only half of_intern_rec's dispatch — so nesting is covered exactly as far as a destructuring save reaches, and no further (a path inside an opaque object is still stored by location, same as locally).Escape hatches, both documented: return
bytes, or put a local layer in front of the remote one so saves never reach the wire.Follow-up (second commit)
Per your review, the problem itself is tracked as #829 rather than left as a comment. The shape is already implied by
PathValueMixin: it reduces a path tobytesplus aFileBlob/DirectoryBlob, all of which ship fine, and those blobs'__digest__is defined to match thePathdigest arm — so running the reduction client-side keeps the seal intact by construction, and needs no server change for saves (aFileBlobisn't aPath, so it falls straight through the server's path layer). The one genuinely new verb is an unmendedload_valuethat returns the blob instead of materializing it on the server. Recorded inRemotePathUnsupported,dev/path_storage, and the DEVELOPING design-theme cheat sheet.Docs
usage/file_semantics.rst: new "Paths stop at a remote (SSH) cache" contract section + a quick-reference row.dev/path_storage.rst: the mechanism, and the Support Path values over SshCache (do the blob conversion client-side) #829 sketch.recipes/files_and_paths.rst: a recipe.SshCache's own docstring too.Tests
tests/unit/test_remote.py— bare and nested (list/tuple/nested-dict/dataclass) rejection with nothing reaching the server; thebyteshatch still working;prepare()keepingprepared.key == call.to_lookup_key()for a path argument (the regression the guard buys); live-Callrejection; load-side rejection incl. nested; aLazyCallwhose arguments still read fine and only.resultraises.tests/unit/storage/test_paths.py—find_pathdescent, opaque-leaf boundary, cycle termination.tests/integration/test_remote.py— the divergent-cwd reproduction above, and a path-returning@flechefunction running uncached against a remote instead of breaking.Re-verified after merging
temppath: 1788 passed / 11 skipped, docs build succeeds.ty check src/reports 3 diagnostics — all already ontemppath, none from this PR; see the #831 thread for the diagnosis and a verified fix, still pending your call on where it should land.