fix(mountsync): preserve local files when the server denies the write#50
Conversation
Previously `pushSingleFile` deleted the local copy of a newly-created
file whenever the server returned HTTP 403 for its create. On a cloud
orchestrator sandbox where the workspace ACL denies writes for the
synced agent, this silently wiped every agent-written file in the
mount's `local-dir` within one sync cycle (~1s default). Agents that
wrote workflow deliverables (e.g. `intro-<cli>.md`, `opencode.json`)
saw their writes succeed on disk, then disappear mid-run.
Evidence from a live cloud run:
denial.log: "WRITE_DENIED /opencode.json: agent does not have write
permission"
sandbox: /project/opencode.json missing 5s after write
/home/daytona/... (outside local-dir) preserved
Change: on 403 for a create, keep the local file on disk, write a
WRITE_DENIED entry to the denial log (now annotated "local copy
preserved"), and record a `WriteDenied` / `DeniedHash` entry in the
sync state. `pushLocal` and `applyRemoteDelete` now skip entries
flagged `WriteDenied` so:
1. Subsequent sync cycles don't re-push identical content (no log
spam, no redundant server round-trip).
2. If the user edits the file (hash changes) we retry the push and
either succeed or log a fresh denial — never silently skipped.
3. The "tracked in state but absent from remote → delete local"
reconcile path is disabled for write-denied entries, since their
absence from the remote snapshot is expected.
Also tightens the revision selection in `pushSingleFile`: a state
entry with `Revision=""` (previously WriteDenied, now retrying)
falls back to the create-if-absent sentinel "0" instead of sending
an empty `baseRevision` on the next attempt.
Test: `TestLocalCreatePreservedWhenServerDeniesWrite` covers all four
invariants (file preserved, denial logged, reconcile doesn't re-spam
the log or re-delete, content edits retry the push).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.2.0 release commit bumped every workspace package.json to 0.2.0 but didn't regenerate package-lock.json, leaving @relayfile/sdk pinned to 0.1.13 in the lockfile. Every `npm ci` in CI (SDK Typecheck, E2E, Contract) failed with: npm error Missing: @relayfile/sdk@0.1.13 from lock file Regenerate the lockfile (npm install --package-lock-only) so all workspace versions line up with package.json. No runtime dep changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba985907e1
ℹ️ 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".
| s.state.Files[remotePath] = trackedFile{ | ||
| ContentType: snapshot.ContentType, | ||
| Hash: snapshot.Hash, | ||
| WriteDenied: true, | ||
| DeniedHash: snapshot.Hash, |
There was a problem hiding this comment.
Record a deletable revision for write-denied creates
This new state entry stores a write-denied file without any Revision, but pushSingleDelete later sends tracked.Revision as If-Match when the user removes/renames that local file. With an empty revision, the HTTP API returns 412 precondition_failed (handleDeleteFile rejects empty If-Match), so local delete events start failing and the stale write-denied record is never cleaned up. In practice, a file that was denied on create can become undeletable from sync state and future recreates on that path can behave incorrectly.
Useful? React with 👍 / 👎.
Problem
relayfile-mountsilently destroys local files in its--local-dirwhenever the workspace server rejects the push with HTTP 403. Downstream impact observed in a live cloud orchestrator sandbox:intro-<cli>.md,opencode.json) to/project.pushSingleFileatinternal/mountsync/syncer.go:622-630deletes the local file on 403 and removes it from sync state.file_exists: intro-*.mdfail even though the agent successfully wrote the file moments earlier.Evidence from a reproducing run
```
/project/.relay/permissions-denied.log
[2026-04-20T09:16:30Z] WRITE_DENIED /opencode.json: agent does not have write permission
[2026-04-20T09:16:30Z] WRITE_DENIED /probe-project-1776676590.md: agent does not have write permission
...
```
Within 5 seconds of a deterministic shell step writing
/project/probe-project-*.md,/project/.probe-stamp, and/project/opencode.json, all three were gone from disk. Files written to `$HOME` (outside the mount) survived. The mount is actively, aggressively destroying user data.Fix
On 403 during a create (exists=false), keep the local file, record a `WriteDenied` + `DeniedHash` entry in the sync state, and short-circuit future push attempts for the same content.
```diff
var httpErr *HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden {
if !exists {
```
Supporting changes
trackedFilegains `WriteDenied bool` and `DeniedHash string`. Distinct from the existing `Denied` (read-denied, local file removed) so future readers don't conflate semantics.pushLocal(first loop) skips re-pushing a write-denied entry when `tracked.DeniedHash == snapshot.Hash`. Prevents log spam and redundant 403 round-trips.pushLocal(second loop) excludes `WriteDenied` entries from the "state has it, remote doesn't → delete" reconcile path. Write-denied files were never on the remote; their absence is expected.applyRemoteDeletereturns early for `WriteDenied` entries for the same reason.pushSingleFilerevision selection: when re-trying a previously write-denied entry whose `Revision` is empty, fall back to the create-if-absent sentinel "0" instead of sending an empty revision.Behavior after the fix
Tests
Follow-up (not in this PR)
The 403 itself is a server-side ACL decision. This PR makes the mount non-destructive when it happens — it does not change the ACL policy. If the orchestrator agent is expected to write back, the workspace ACL for that agent needs `fs:write` on the paths it pushes. That's a relayauth/relayfile ACL concern, tracked separately.
🤖 Generated with Claude Code