Skip to content

fix(mount): scope the websocket dial to the mount root - #447

Merged
khaliqgant merged 1 commit into
mainfrom
fix/ws-dial-scopes-to-mount-root
Aug 25, 2026
Merged

fix(mount): scope the websocket dial to the mount root#447
khaliqgant merged 1 commit into
mainfrom
fix/ws-dial-scopes-to-mount-root

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 25, 2026

Copy link
Copy Markdown
Member

The bug

A path-scoped mount can never use the websocket. websocketURL dials /v1/workspaces/{id}/fs/ws with only token and cursor — no path. The server branches on exactly that param:

const firstPath = paths[0] ?? "/";
firstPath === "/"
  ? authorizeWebSocketTokenCapability(c, workspaceId, "fs:read")   // workspace-WIDE
  : authorizeWebSocketToken(c, workspaceId, "fs:read", scopePath)  // path-scoped

With no path, the server requires a workspace-wide fs:read capability. Sandbox mounts carry a token deliberately scoped to one subtree, so the handshake 403s and the daemon falls back to polling:

2026/08/25 07:54:30 websocket unavailable; using polling sync: failed to WebSocket dial:
  expected handshake response status code 101 but got 403
eventListener = {'mode': 'websocket', 'status': 'reconnecting'}

Observed on a real cloud sandbox mounting /workflows/runs/<id> against dev.file.agentrelay.com. Every scoped mount silently loses real-time propagation and degrades to the poll interval — measured elsewhere as roughly 170ms → 5s+.

Broad user tokens are unaffected, which is why this went unnoticed: a personal mount with workspace-wide scopes connects fine.

The fix

Scoped mounts now send two path filters:

?path=/workflows/runs/abc&path=/workflows/runs/abc/**
  • The server authorizes against paths[0] — the bare root, which is exactly what the mount token covers.
  • The server ORs the filters when matching events — the subtree glob is what actually delivers file events.

Root mounts (/ or empty) keep the historical unscoped dial, unchanged.

Both filters are required. My first attempt sent only the bare root and broke TestAssessPropagationLatencyWebSocket — the mirror never converged. webSocketPathMatches compares segment counts, not prefixes:

return len(patternSegments) == len(pathSegments)   // "/a" does NOT match "/a/b.md"

So root-only would authorize successfully and then match nothing — a silent failure, strictly worse than the 403 it replaced. That existing latency test is what caught it.

Verification

  • New TestWebSocketURLScopesToMountRoot — asserts the scoped dial sends root-then-glob in that order, and that root mounts still send no path
  • go test ./internal/mountsync/full suite green (118s), including TestAssessPropagationLatencyWebSocket / PollOnly
  • go build ./internal/... clean

Note for reviewers

Not yet verified against a live scoped mount — that needs this built into a relayfile-mount release and pulled into a cloud sandbox snapshot. The 403 is reproducible today on any cloud workflow sandbox, so it should be straightforward to confirm after release.

🤖 Generated with Claude Code

Review in cubic

A path-scoped mount could never use the websocket. websocketURL dialed
/v1/workspaces/{id}/fs/ws with only token+cursor, and the server branches on
the path query: with none, it authorizes against a workspace-WIDE fs:read
capability. Sandbox mounts carry a token scoped to one subtree, so the
handshake returned 403 and the daemon fell back to polling:

  websocket unavailable; using polling sync: failed to WebSocket dial:
  expected handshake response status code 101 but got 403

Observed on a real cloud sandbox mounting /workflows/runs/<id>, where it
silently cost every scoped mount its real-time propagation.

Send two filters for a scoped mount: the bare root first, then root + "/**".
The server authorizes against the first (which is what the token covers) and
ORs them when matching events. The glob is not optional -- webSocketPathMatches
compares segment counts rather than prefixes, so "/a" does not match
"/a/b.md"; sending only the root authorizes and then delivers nothing, which
is a silent failure worse than the 403. An earlier version of this patch did
exactly that and broke TestAssessPropagationLatencyWebSocket.

Root mounts keep the historical unscoped dial unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fd7e8020-69f8-4d3a-aa18-97ca3545e229

📥 Commits

Reviewing files that changed from the base of the PR and between fecb9ce and 90b59c4.

📒 Files selected for processing (2)
  • internal/mountsync/realtime_collaboration_test.go
  • internal/mountsync/syncer.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

WebSocket URL construction now receives the mount remote root. Scoped mounts request the exact root and recursive subtree paths. Root mounts remain unscoped. Tests cover path filters, protocol conversion, and reconnect cursors.

Changes

WebSocket mount scoping

Layer / File(s) Summary
Mount-aware WebSocket URL construction
internal/mountsync/syncer.go
websocketURL accepts remoteRoot. Scoped mounts add the exact root and /** path filters. Root mounts omit path filters.
Syncer integration and URL tests
internal/mountsync/syncer.go, internal/mountsync/realtime_collaboration_test.go
connectWebSocket passes s.remoteRoot. Tests cover scoped mounts, root mounts, https to wss, and durable cursors.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 90b59

Scoped mounts now receive real-time websocket updates for their mounted subtree while root mounts retain existing behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: miyaontherelay, kjgbot

Poem

A rabbit checks the socket lane

Scoped roots guide the stream like rain
Bare roots keep their open view
Reconnects carry cursors too
WSS hops through the burrow bright

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the WebSocket path-scoping bug, the two-filter fix, root-mount behavior, and verification results. It directly matches the changeset.
Title check ✅ Passed The title clearly and concisely describes the main change: scoping the WebSocket dial to the mount root.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ws-dial-scopes-to-mount-root

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Relayfile Eval Review

Run: .relayfile/evals/runs/2026-08-25T08-50-43-883Z-HEAD-provider
Mode: provider
Git SHA: d563cf3

Passed: 4 | Needs human: 0 | Reviewable: 0 | Missing output: 0 | Failed: 0 | Skipped: 0

Human Review Cases

No reviewable human-review cases captured Relayfile output.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

@khaliqgant
khaliqgant merged commit 4fd24d3 into main Aug 25, 2026
11 checks passed
@khaliqgant
khaliqgant deleted the fix/ws-dial-scopes-to-mount-root branch August 25, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant