From c3377644293d23a3a253fb81b46a18b78ab398db Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sat, 23 May 2026 21:08:44 +0200 Subject: [PATCH 1/2] Support scoped relayfile mount paths --- README.md | 16 +++ cmd/relayfile-mount/main.go | 195 ++++++++++++++++++++++++++++++- cmd/relayfile-mount/main_test.go | 65 +++++++++++ 3 files changed, 274 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 56bdaa9a..ce111508 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/relayfile-mount/main.go b/cmd/relayfile-mount/main.go index be114e43..28799643 100644 --- a/cmd/relayfile-mount/main.go +++ b/cmd/relayfile-mount/main.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "time" @@ -34,6 +35,7 @@ type mountConfig struct { token string workspaceID string remotePath string + remotePaths []string eventProvider string localDir string stateFile string @@ -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") @@ -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 { @@ -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, @@ -168,6 +178,99 @@ 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 { + 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 <- runSinglePollingMount(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 @@ -262,6 +365,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, "/")) + 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). diff --git a/cmd/relayfile-mount/main_test.go b/cmd/relayfile-mount/main_test.go index 5200bd12..db43af2f 100644 --- a/cmd/relayfile-mount/main_test.go +++ b/cmd/relayfile-mount/main_test.go @@ -3,6 +3,8 @@ package main import ( "context" "errors" + "os" + "path/filepath" "testing" "time" ) @@ -187,3 +189,66 @@ 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 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 +} From f0b7a91a58a078775ce510c8e1815601eec972d3 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sat, 23 May 2026 21:18:06 +0200 Subject: [PATCH 2/2] Test scoped mount cancellation --- cmd/relayfile-mount/main.go | 11 ++++++++- cmd/relayfile-mount/main_test.go | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/cmd/relayfile-mount/main.go b/cmd/relayfile-mount/main.go index 28799643..1fb0844f 100644 --- a/cmd/relayfile-mount/main.go +++ b/cmd/relayfile-mount/main.go @@ -189,6 +189,15 @@ func runPollingMount(rootCtx context.Context, cfg mountConfig) error { } 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 } @@ -222,7 +231,7 @@ func runScopedPollingMounts(rootCtx context.Context, cfg mountConfig, remotePath wg.Add(1) go func() { defer wg.Done() - errCh <- runSinglePollingMount(ctx, mount.cfg) + errCh <- run(ctx, mount.cfg) }() } go func() { diff --git a/cmd/relayfile-mount/main_test.go b/cmd/relayfile-mount/main_test.go index db43af2f..e4f6c688 100644 --- a/cmd/relayfile-mount/main_test.go +++ b/cmd/relayfile-mount/main_test.go @@ -5,6 +5,9 @@ import ( "errors" "os" "path/filepath" + "strings" + "sync" + "sync/atomic" "testing" "time" ) @@ -214,6 +217,45 @@ func TestScopedLocalDirKeepsProviderPrefixUnderMountRoot(t *testing.T) { } } +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")