Skip to content

feat: FUSE mount for relayfile VFS with permission enforcement#13

Merged
khaliqgant merged 19 commits intomainfrom
fuse-mount-for-local
Mar 27, 2026
Merged

feat: FUSE mount for relayfile VFS with permission enforcement#13
khaliqgant merged 19 commits intomainfrom
fuse-mount-for-local

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

@khaliqgant khaliqgant commented Mar 26, 2026

Summary

  • Adds a FUSE-based filesystem mount backed by the relayfile HTTP API using hanwen/go-fuse/v2
  • Every syscall (open, read, write, readdir, stat) becomes an HTTP call to relayfile with a scoped Bearer token
  • Permission enforcement at the OS level: ignored files → ENOENT, readonly files → EPERM on write
  • Local LRU cache with configurable TTL (files 2s, dirs 5s) and WebSocket-based cache invalidation
  • Per-agent mounts avoid concurrency bottleneck — each agent gets its own mount with its own token
  • Optimistic locking via If-Match revision headers, 409 → retry

New files

  • internal/mountfuse/ — fs.go, dir.go, file.go, client.go, cache.go, wsinvalidate.go
  • cmd/relayfile-mount/fuse_mount.go — FUSE runner with //go:build !nofuse tag
  • cmd/relayfile-mount/main.go--fuse flag and --mode=fuse|poll support

How it works

Agent reads file → FUSE kernel intercept → mountfuse daemon
  → GET /v1/workspaces/{ws}/fs/file?path=/src/app.ts
  → Authorization: Bearer <scoped-token>
  → Returns content (or ENOENT/EPERM based on ACL)

Test plan

  • go build ./internal/mountfuse/... passes
  • go build ./cmd/relayfile-mount/... passes
  • go test ./internal/mountfuse/... -short passes
  • Manual FUSE mount test with macFUSE/libfuse installed
  • Integration test with relayfile server + scoped tokens

🤖 Generated with Claude Code


Open with Devin

khaliqgant and others added 2 commits March 26, 2026 23:13
Adds a FUSE-based filesystem mount backed by the relayfile HTTP API.
Every syscall (open, read, write, readdir, stat) becomes an API call
with a scoped Bearer token. Ignored files return ENOENT, readonly
files return EPERM on write. Includes local LRU cache with TTL and
WebSocket-based cache invalidation for real-time multi-agent sync.

- internal/mountfuse/: fs.go, dir.go, file.go, client.go, cache.go, wsinvalidate.go
- cmd/relayfile-mount: --fuse flag and --mode=fuse support
- Build tag //go:build !nofuse for conditional compilation
- Tests for cache and FUSE operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
publish.yml now creates both sdk-v* and v* tags. The v* tag
triggers release.yml which builds Go binaries, publishes npm SDK,
and pushes Docker images — matching the relay repo's auto-release pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

wsinvalidate.go: Fix WebSocket endpoint path /fs/events/ws -> /fs/ws
dir.go: Return fuse.FOPEN_KEEP_CACHE instead of raw open flags
file.go: Remove duplicate putFile call before invalidate
file.go: Add RLock/RUnlock in fillEntry for thread safety

Co-Authored-By: My Senior Dev <dev@myseniordev.com>
devin-ai-integration[bot]

This comment was marked as resolved.

khaliqgant and others added 2 commits March 27, 2026 10:18
…empty

The bulk write endpoint doesn't persist the semantics field, so ACL
markers store permissions in the file content as JSON. The aclGetFile
function now falls back to parsing content when semantics.permissions
is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

khaliqgant and others added 3 commits March 27, 2026 11:09
wsinvalidate.go: backoff reset after stable connection, ws/wss scheme support
file.go: writeGen generation counter to prevent flush/Write race condition
webhooks.ts: move nextRevision() after size check to avoid wasting revisions
dir.go, file.go fillEntry, wsinvalidate.go endpoint: previously resolved

Co-Authored-By: My Senior Dev <dev@myseniordev.com>
When a write is denied (403) or a file is reverted, the mount client
now writes to .relay/permissions-denied.log with timestamp, action,
file path, and reason. Agents can check this log for details.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

khaliqgant and others added 5 commits March 27, 2026 11:41
When a readonly file's hash differs from the tracked hash (agent
used chmod to bypass 444 and wrote to it), the sync client now
fetches the original content from relayfile and overwrites the
local file, restoring both content and chmod 444 permissions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The mapError change correctly maps HTTP 429 to syscall.EAGAIN (retryable)
instead of syscall.EIO (fatal), but the test still expected EIO.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Group 1 - HTTP API security/ACL hardening:
- Log warnings when using default dev secrets (server.go)
- Add ACL rule value validation with regex patterns (acl.go)
- Extend aclCheckPath for tree/query_files routes (server.go)
- Document TOCTOU-safe single-snapshot ACL resolution (server.go)
- Add 12 path normalization tests and 18 validation tests (acl_test.go)

Group 2 - SDK/core package security:
- Make webhook signature verification required by default (webhooks.ts)
- Move bearer token from WebSocket URL to auth message (sync.ts)

Group 3 - FUSE mount reliability:
- Add inode cache size limit with eviction (fs.go)
- Add WS read limit, auth error detection, max failure cap (wsinvalidate.go)
- Map HTTP 429 to EAGAIN instead of EIO (client.go)
- Enforce maxFileBytes limit on write buffer (file.go)

Group 4 - Mount command cleanup:
- Cancel derived context on FUSE exit to clean up WS (fuse_mount.go)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test expected the bearer token in the WebSocket URL query string,
but commit 1f87f7b moved it to a post-open auth message for security.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d root version

- Rewrite scopeGrantsWrite to recognize manage/wildcard scopes
  (plane:resource:action:path segments), matching server-side scopeMatches
- Remove ReadOnly OR-latch so permissions reflect fresh scope evaluation
- Add conflicted entry after applyWriteDenied to prevent pullRemote override
- Skip .relay directory in scanLocalFiles walk
- Add version field to root package.json for CI npm version compatibility

Fixes: 3000084339, 3000241814, 3000241892, 3000241978

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

khaliqgant and others added 2 commits March 27, 2026 12:20
…coverage

- fsnotify file watcher for instant local change detection (replaces polling)
- Path-aware scopeMatchesPath in auth.go (fixes .env leak — token with
  relayfile:fs:read:/src/app.ts no longer grants read to all files)
- canReadPath in mount client filters pulls by token scopes
- .agentdeny command filter via shell preexec hook
- Readonly revert in pushSingleFile when hash differs
- Full test coverage: scope matching, read filtering, write rejection,
  agentdeny, fsnotify watcher
- Fixed TestWriteRejectionRevertsFile to use read-only scopes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 4 new potential issues.

View 30 additional findings in Devin Review.

Open in Devin Review

Comment on lines +461 to +462
if exists && tracked.ReadOnly {
return s.revertReadonlyFile(ctx, remotePath, localPath, tracked, "")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Missing saveState() after revertReadonlyFile in HandleLocalChange Remove/Rename path

When a readonly file is locally deleted (Remove/Rename), revertReadonlyFile modifies s.state.Files[remotePath] (setting ReadOnly, Dirty, etc.) but the caller returns its result directly without calling s.saveState(). This means the reverted state is held only in memory and lost if the process restarts before the next timed sync cycle. Compare with the Write/Create path at line 454-458 which correctly calls s.saveState() after handleLocalWriteOrCreate.

Affected code path

Lines 459-462 return directly from revertReadonlyFile without saving:

case op&(fsnotify.Remove|fsnotify.Rename) != 0:
    tracked, exists := s.state.Files[remotePath]
    if exists && tracked.ReadOnly {
        return s.revertReadonlyFile(ctx, remotePath, localPath, tracked, "")
    }

But the non-readonly path at lines 464-467 correctly saves state.

Suggested change
if exists && tracked.ReadOnly {
return s.revertReadonlyFile(ctx, remotePath, localPath, tracked, "")
if exists && tracked.ReadOnly {
if err := s.revertReadonlyFile(ctx, remotePath, localPath, tracked, ""); err != nil {
return err
}
return s.saveState()
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 91 to 92
run: npm ci

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Version bump uses root package.json starting at 0.0.0, producing incorrect version numbers

The publish workflow's version bump step was changed to operate on the root package.json (which has "version": "0.0.0" per package.json:3) instead of packages/sdk/package.json (previously used). When a relative bump like patch is selected, npm version patch on the root will produce 0.0.1 instead of incrementing from the actual published version of the packages (e.g., core is at 0.1.0). This means the first non-custom-version publish from this workflow would reset all package versions to an unexpectedly low number like 0.0.1.

Prompt for agents
In .github/workflows/publish.yml, the version bump step at line 91-92 reads CURRENT_VERSION from the root package.json which is at 0.0.0. It should either: (1) read the current version from one of the actual published packages (e.g. packages/core/package.json or packages/sdk/package.json) before bumping, or (2) set the root package.json version to match the current published version, or (3) always derive the version from an existing package. The key change needed is around line 91: CURRENT_VERSION should come from a package with the real published version, and npm version should be run against that package or the root version should be seeded correctly.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@khaliqgant khaliqgant merged commit 16640c3 into main Mar 27, 2026
6 checks passed
@khaliqgant khaliqgant deleted the fuse-mount-for-local branch March 27, 2026 12:04
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 29 additional findings in Devin Review.

Open in Devin Review

const envelopeStorage = getWebhookStorage(storage);
const correlationId = input.correlationId?.trim() ?? "";

const requireSignature = options.requireSignature === true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 requireSignature documented default is true (fail-closed) but code defaults to false (fail-open)

The JSDoc on requireSignature states "Defaults to true (fail-closed)" but the implementation at line 121 uses options.requireSignature === true, which evaluates to false when the option is undefined (the default). This means callers who omit requireSignature — trusting the documented default — will get fail-open behavior where webhooks are accepted without signature verification, directly contradicting the documented security guarantee.

Suggested change
const requireSignature = options.requireSignature === true;
const requireSignature = options.requireSignature !== false;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +263 to +265
if strings.HasSuffix(scopePath, "*") && strings.HasPrefix(filePath, scopeDir) {
return true
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Bare * wildcard in scopeMatchesPath matches across directory boundaries

When a scope path ends with * (but not /*), the check at line 263 does strings.HasPrefix(filePath, scopeDir) where scopeDir has the trailing * stripped. For a scope like relayfile:fs:read:/src*, scopeDir becomes /src, so HasPrefix("/srcevil/secret.txt", "/src") returns true — granting unintended access to paths outside the intended /src directory tree. The /* variant (line 260) correctly appends / to prevent this, but the bare * variant does not.

Example of unintended match

Scope: relayfile:fs:read:/src*

  • Line 255: scopeDir = TrimSuffix("/src*", "/*")"/src*" (unchanged)
  • Line 256: scopeDir = TrimSuffix("/src*", "*")"/src"
  • Line 263: HasPrefix("/srcevil/secret.txt", "/src")true ← unintended match
Suggested change
if strings.HasSuffix(scopePath, "*") && strings.HasPrefix(filePath, scopeDir) {
return true
}
if strings.HasSuffix(scopePath, "*") && (strings.HasPrefix(filePath, scopeDir+"/") || filePath == scopeDir) {
return true
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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