Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,22 @@ RELAYFILE_TOKEN="$TOKEN" go run ./cmd/relayfile-mount \
--local-dir ./relayfile-mount
```

Limit the daemon to one or more remote subtrees by repeating
`--remote-path`. Each subtree is mirrored under the matching path inside
`--local-dir`, which avoids full-workspace export pulls on large workspaces:

```bash
RELAYFILE_TOKEN="$TOKEN" go run ./cmd/relayfile-mount \
--base-url http://localhost:9090 \
--workspace ws_demo \
--local-dir ./relayfile-mount \
--remote-path /github \
--remote-path /slack/channels/proj-cloud
```

For long path lists, pass `--paths-file ./paths.json`; the file may be a JSON
array of remote roots or a newline-separated list.

Now any local tool or agent can use `./relayfile-mount` like a normal directory.

## Running Evals
Expand Down
204 changes: 202 additions & 2 deletions cmd/relayfile-mount/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"

Expand All @@ -34,6 +35,7 @@ type mountConfig struct {
token string
workspaceID string
remotePath string
remotePaths []string
eventProvider string
localDir string
stateFile string
Expand Down Expand Up @@ -64,7 +66,9 @@ func main() {
baseURL := flag.String("base-url", envOrDefault("RELAYFILE_BASE_URL", "http://127.0.0.1:8080"), "relayfile base URL")
token := flag.String("token", strings.TrimSpace(os.Getenv("RELAYFILE_TOKEN")), "bearer token")
workspaceID := flag.String("workspace", strings.TrimSpace(os.Getenv("RELAYFILE_WORKSPACE")), "workspace ID")
remotePath := flag.String("remote-path", envOrDefault("RELAYFILE_REMOTE_PATH", "/"), "remote root path")
var remotePaths repeatedStringFlag
flag.Var(&remotePaths, "remote-path", "remote root path (may be repeated)")
pathsFile := flag.String("paths-file", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_PATHS_FILE")), "file containing remote root paths, as JSON array or newline-separated list")
eventProvider := flag.String("provider", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_PROVIDER")), "event provider filter")
localDir := flag.String("local-dir", strings.TrimSpace(os.Getenv("RELAYFILE_LOCAL_DIR")), "local mirror directory")
stateFile := flag.String("state-file", strings.TrimSpace(os.Getenv("RELAYFILE_MOUNT_STATE_FILE")), "state file path")
Expand Down Expand Up @@ -99,6 +103,11 @@ func main() {
if *timeout <= 0 {
*timeout = 15 * time.Second
}
fileRemotePaths, err := readRemotePathsFile(*pathsFile)
if err != nil {
log.Fatalf("read paths-file: %v", err)
}
allRemotePaths := append(remotePaths.Values(), fileRemotePaths...)
*intervalJitter = clampJitterRatio(*intervalJitter)
resolvedMode, err := resolveMountMode(*mode, *fuse)
if err != nil {
Expand All @@ -112,7 +121,8 @@ func main() {
baseURL: *baseURL,
token: strings.TrimSpace(*token),
workspaceID: strings.TrimSpace(*workspaceID),
remotePath: *remotePath,
remotePath: firstRemotePath(allRemotePaths, envOrDefault("RELAYFILE_REMOTE_PATH", "/")),
remotePaths: normalizeRemotePaths(allRemotePaths, envOrDefault("RELAYFILE_REMOTE_PATH", "/")),
eventProvider: strings.TrimSpace(*eventProvider),
localDir: *localDir,
stateFile: *stateFile,
Expand Down Expand Up @@ -168,6 +178,108 @@ func executeMount(rootCtx context.Context, cfg mountConfig, runPoll pollRunner,
}

func runPollingMount(rootCtx context.Context, cfg mountConfig) error {
remotePaths := cfg.remotePaths
if len(remotePaths) == 0 {
remotePaths = []string{cfg.remotePath}
}
if len(remotePaths) > 1 || (len(remotePaths) == 1 && normalizeMountRemotePath(remotePaths[0]) != "/") {
return runScopedPollingMounts(rootCtx, cfg, remotePaths)
}
return runSinglePollingMount(rootCtx, cfg)
}

func runScopedPollingMounts(rootCtx context.Context, cfg mountConfig, remotePaths []string) error {
return runScopedPollingMountsWithRunner(rootCtx, cfg, remotePaths, runSinglePollingMount)
}

func runScopedPollingMountsWithRunner(
rootCtx context.Context,
cfg mountConfig,
remotePaths []string,
run pollRunner,
) error {
type scopedMount struct {
cfg mountConfig
}
scopedMounts := make([]scopedMount, 0, len(remotePaths))
seen := map[string]struct{}{}
for _, remotePath := range remotePaths {
remotePath := normalizeMountRemotePath(remotePath)
if _, ok := seen[remotePath]; ok {
continue
}
seen[remotePath] = struct{}{}
scoped := cfg
scoped.remotePath = remotePath
scoped.remotePaths = nil
scoped.localDir = scopedLocalDir(cfg.localDir, remotePath)
scoped.stateFile = scopedStateFile(cfg.stateFile, remotePath)
if err := os.MkdirAll(scoped.localDir, 0o755); err != nil {
return fmt.Errorf("create scoped local dir for %s: %w", remotePath, err)
}
scopedMounts = append(scopedMounts, scopedMount{cfg: scoped})
}
if len(scopedMounts) == 0 {
return nil
}
ctx, cancel := context.WithCancel(rootCtx)
defer cancel()
errCh := make(chan error, len(remotePaths))
var wg sync.WaitGroup
for _, mount := range scopedMounts {
mount := mount
wg.Add(1)
go func() {
defer wg.Done()
errCh <- run(ctx, mount.cfg)
}()
}
go func() {
wg.Wait()
close(errCh)
}()
var firstErr error
for err := range errCh {
if err != nil && firstErr == nil {
firstErr = err
cancel()
}
}
return firstErr
}

func readRemotePathsFile(path string) ([]string, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, nil
}
payload, err := os.ReadFile(path)
if err != nil {
return nil, err
}
trimmed := strings.TrimSpace(string(payload))
if trimmed == "" {
return nil, nil
}
var jsonPaths []string
if strings.HasPrefix(trimmed, "[") {
if err := json.Unmarshal(payload, &jsonPaths); err != nil {
return nil, err
}
return jsonPaths, nil
}
var paths []string
for _, line := range strings.Split(trimmed, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
paths = append(paths, line)
}
return paths, nil
}

func runSinglePollingMount(rootCtx context.Context, cfg mountConfig) error {
// No whole-request Timeout: net/http enforces http.Client.Timeout
// independent of context and would abort a long-but-progressing
// bootstrap body read mid-stream. Cancellation is owned by the
Expand Down Expand Up @@ -262,6 +374,94 @@ func runPollingMount(rootCtx context.Context, cfg mountConfig) error {
}
}

type repeatedStringFlag []string

func (f *repeatedStringFlag) String() string {
return strings.Join(*f, ",")
}

func (f *repeatedStringFlag) Set(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
*f = append(*f, trimmed)
return nil
}

func (f repeatedStringFlag) Values() []string {
return append([]string(nil), f...)
}

func firstRemotePath(paths []string, fallback string) string {
normalized := normalizeRemotePaths(paths, fallback)
if len(normalized) == 0 {
return "/"
}
return normalized[0]
}

func normalizeRemotePaths(paths []string, fallback string) []string {
if len(paths) == 0 {
paths = []string{fallback}
}
seen := map[string]struct{}{}
normalized := make([]string, 0, len(paths))
for _, path := range paths {
cleaned := normalizeMountRemotePath(path)
if _, ok := seen[cleaned]; ok {
continue
}
seen[cleaned] = struct{}{}
normalized = append(normalized, cleaned)
}
if len(normalized) == 0 {
return []string{"/"}
}
return normalized
}

func normalizeMountRemotePath(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" || trimmed == "/" {
return "/"
}
trimmed = strings.ReplaceAll(trimmed, "\\", "/")
if !strings.HasPrefix(trimmed, "/") {
trimmed = "/" + trimmed
}
cleaned := filepath.Clean(trimmed)
if cleaned == "." || cleaned == string(filepath.Separator) {
return "/"
}
return filepath.ToSlash(cleaned)
}

func scopedLocalDir(localRoot, remotePath string) string {
remotePath = normalizeMountRemotePath(remotePath)
if remotePath == "/" {
return localRoot
}
return filepath.Join(localRoot, filepath.FromSlash(strings.TrimPrefix(remotePath, "/")))
}

func scopedStateFile(stateFile, remotePath string) string {
if strings.TrimSpace(stateFile) == "" {
return ""
}
remotePath = normalizeMountRemotePath(remotePath)
if remotePath == "/" {
return stateFile
}
ext := filepath.Ext(stateFile)
base := strings.TrimSuffix(stateFile, ext)
suffix := strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(strings.Trim(remotePath, "/"))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Per-scope state-file naming is collision-prone, so different remote paths can clobber the same state file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/relayfile-mount/main.go, line 449:

<comment>Per-scope state-file naming is collision-prone, so different remote paths can clobber the same state file.</comment>

<file context>
@@ -262,6 +365,94 @@ func runPollingMount(rootCtx context.Context, cfg mountConfig) error {
+	}
+	ext := filepath.Ext(stateFile)
+	base := strings.TrimSuffix(stateFile, ext)
+	suffix := strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(strings.Trim(remotePath, "/"))
+	if suffix == "" {
+		suffix = "root"
</file context>
Suggested change
suffix := strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(strings.Trim(remotePath, "/"))
suffix := base64.RawURLEncoding.EncodeToString([]byte(strings.Trim(remotePath, "/")))

if suffix == "" {
suffix = "root"
}
return base + "-" + suffix + ext
}

// readBootstrapProgress reads the in-progress bootstrap block from the
// mountsync public state file. ok is false when there is no bootstrap in
// progress (or the file is missing/unparseable).
Expand Down
107 changes: 107 additions & 0 deletions cmd/relayfile-mount/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
Expand Down Expand Up @@ -187,3 +192,105 @@ func TestExecuteMountRejectsUnsupportedMode(t *testing.T) {
t.Fatal("expected unsupported mode error")
}
}

func TestNormalizeRemotePathsDedupesRepeatedFlagValues(t *testing.T) {
got := normalizeRemotePaths(
[]string{"/github/repos/acme/cloud", "github/repos/acme/cloud/", "/slack/channels/proj-cloud"},
"/",
)
want := []string{"/github/repos/acme/cloud", "/slack/channels/proj-cloud"}
if len(got) != len(want) {
t.Fatalf("expected %d paths, got %d: %v", len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("path %d: expected %q, got %q", i, want[i], got[i])
}
}
}

func TestScopedLocalDirKeepsProviderPrefixUnderMountRoot(t *testing.T) {
got := scopedLocalDir("/workspace", "/github/repos/acme/cloud")
want := filepath.Join("/workspace", "github", "repos", "acme", "cloud")
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}

func TestRunScopedPollingMountsCancelsSiblingsOnFirstError(t *testing.T) {
wantErr := errors.New("boom")
var canceled atomic.Bool
started := make(chan string, 2)
releaseFailingMount := make(chan struct{})
var once sync.Once

err := runScopedPollingMountsWithRunner(
context.Background(),
mountConfig{localDir: t.TempDir(), stateFile: filepath.Join(t.TempDir(), "state.json")},
[]string{"/github", "/slack"},
func(ctx context.Context, cfg mountConfig) error {
started <- cfg.remotePath
if strings.HasSuffix(cfg.remotePath, "/github") {
<-releaseFailingMount
return wantErr
}
once.Do(func() { close(releaseFailingMount) })
<-ctx.Done()
canceled.Store(true)
return nil
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("expected first error %v, got %v", wantErr, err)
}
if !canceled.Load() {
t.Fatal("expected sibling mount to observe context cancellation")
}
close(started)
seen := map[string]bool{}
for path := range started {
seen[path] = true
}
if !seen["/github"] || !seen["/slack"] {
t.Fatalf("expected both scoped mounts to start, saw %v", seen)
}
}

func TestReadRemotePathsFileSupportsJSONAndLines(t *testing.T) {
dir := t.TempDir()
jsonPath := filepath.Join(dir, "paths.json")
if err := os.WriteFile(jsonPath, []byte(`["/github","/linear/issues"]`), 0o644); err != nil {
t.Fatal(err)
}
jsonPaths, err := readRemotePathsFile(jsonPath)
if err != nil {
t.Fatalf("read json paths: %v", err)
}
if want := []string{"/github", "/linear/issues"}; !stringSlicesEqual(jsonPaths, want) {
t.Fatalf("expected json paths %v, got %v", want, jsonPaths)
}

linesPath := filepath.Join(dir, "paths.txt")
if err := os.WriteFile(linesPath, []byte("\n# comment\n/github/repos/acme/cloud\n/slack/channels/proj-cloud\n"), 0o644); err != nil {
t.Fatal(err)
}
linePaths, err := readRemotePathsFile(linesPath)
if err != nil {
t.Fatalf("read line paths: %v", err)
}
if want := []string{"/github/repos/acme/cloud", "/slack/channels/proj-cloud"}; !stringSlicesEqual(linePaths, want) {
t.Fatalf("expected line paths %v, got %v", want, linePaths)
}
}

func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
Loading