From 89cb2dff1a6d892af83299721eede75a7a52d421 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 29 Jun 2026 14:21:26 -0700 Subject: [PATCH 1/4] Resolve integration resources to VFS globs --- cmd/relayfile-cli/main.go | 531 ++++++++++++++++++++++++++++++++- cmd/relayfile-cli/main_test.go | 120 ++++++++ packages/cli/CHANGELOG.md | 4 +- 3 files changed, 650 insertions(+), 5 deletions(-) diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 97bd1847..b52db18e 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -709,6 +709,8 @@ func printIntegrationUsage(w io.Writer, subcommand string) { fmt.Fprintln(w, "Usage: relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes]") case "bind": fmt.Fprintln(w, "Usage: relayfile integration bind PROVIDER PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") + case "resolve-path": + fmt.Fprintln(w, "Usage: relayfile integration resolve-path PROVIDER RESOURCE [--json]") case "unbind": fmt.Fprintln(w, "Usage: relayfile integration unbind PROVIDER [PATH_GLOB|--resource PATH_GLOB]") default: @@ -721,6 +723,7 @@ func printIntegrationUsage(w io.Writer, subcommand string) { relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] relayfile integration bind PROVIDER PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN + relayfile integration resolve-path PROVIDER RESOURCE [--json] relayfile integration unbind PROVIDER [PATH_GLOB|--resource PATH_GLOB] relayfile integration writeback-secret --channel CHANNEL [--workspace WS] [--json]`) } @@ -804,6 +807,7 @@ Usage: relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] relayfile integration bind PROVIDER PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN + relayfile integration resolve-path PROVIDER RESOURCE [--json] relayfile integration unbind PROVIDER [PATH_GLOB|--resource PATH_GLOB] relayfile ops list [--workspace NAME] [--json] relayfile ops replay OPID [--workspace NAME] @@ -2285,6 +2289,8 @@ func runIntegration(args []string, stdin io.Reader, stdout io.Writer) error { return runIntegrationSetMetadata(args[1:], stdin, stdout) case "bind": return runIntegrationBind(args[1:], stdout) + case "resolve-path": + return runIntegrationResolvePath(args[1:], stdout) case "unbind": return runIntegrationUnbind(args[1:], stdout) case "writeback-secret": @@ -2328,13 +2334,13 @@ func runIntegrationBind(args []string, stdout io.Writer) error { if err := validateLocalProviderID(provider); err != nil { return err } - pathGlob := strings.TrimSpace(fs.Arg(1)) - if !strings.HasPrefix(pathGlob, "/") { - return errors.New("PATH_GLOB must start with /") + resolved, err := resolveIntegrationBindPathGlob(provider, fs.Arg(1)) + if err != nil { + return err } binding := relayIntegrationBinding{ Provider: provider, - PathGlob: pathGlob, + PathGlob: resolved.PathGlob, Channel: strings.TrimSpace(*channel), WebhookID: strings.TrimSpace(*webhookID), WebhookToken: strings.TrimSpace(*webhookToken), @@ -2378,6 +2384,9 @@ func runIntegrationBind(args []string, stdout io.Writer) error { if err := writeRelayIntegrationBindings(bindings); err != nil { return err } + if resolved.Warning != "" { + fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) + } if replaced { fmt.Fprintf(stdout, "%s binding updated: %s -> %s\n", binding.Provider, binding.PathGlob, binding.Channel) } else { @@ -2386,6 +2395,46 @@ func runIntegrationBind(args []string, stdout io.Writer) error { return nil } +func runIntegrationResolvePath(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("integration resolve-path", flag.ContinueOnError) + fs.SetOutput(io.Discard) + jsonOutput := fs.Bool("json", false, "emit JSON") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ + "json": false, + })); err != nil { + return err + } + if fs.NArg() != 2 { + return errors.New("usage: relayfile integration resolve-path PROVIDER RESOURCE [--json]") + } + provider := normalizeProviderID(fs.Arg(0)) + if err := validateLocalProviderID(provider); err != nil { + return err + } + resolved, err := resolveIntegrationBindPathGlob(provider, fs.Arg(1)) + if err != nil { + return err + } + if *jsonOutput { + return writeJSON(stdout, struct { + Provider string `json:"provider"` + Resource string `json:"resource"` + PathGlob string `json:"pathGlob"` + Warning string `json:"warning,omitempty"` + }{ + Provider: provider, + Resource: strings.TrimSpace(fs.Arg(1)), + PathGlob: resolved.PathGlob, + Warning: resolved.Warning, + }) + } + if resolved.Warning != "" { + fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) + } + fmt.Fprintln(stdout, resolved.PathGlob) + return nil +} + func runIntegrationUnbind(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("integration unbind", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -2409,6 +2458,16 @@ func runIntegrationUnbind(args []string, stdout io.Writer) error { } pathGlob = strings.TrimSpace(fs.Arg(1)) } + if pathGlob != "" && !strings.HasPrefix(pathGlob, "/") { + resolved, err := resolveIntegrationBindPathGlob(provider, pathGlob) + if err != nil { + return err + } + pathGlob = resolved.PathGlob + if resolved.Warning != "" { + fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) + } + } bindings, err := readRelayIntegrationBindings() if err != nil { return err @@ -7805,6 +7864,470 @@ func delegatedCredentialsPath() string { return filepath.Join(configDir(), "delegated-credentials.json") } +type integrationBindPathResolution struct { + PathGlob string + Warning string +} + +type integrationContainerResolver struct { + Provider string + Root string + Container string + Aliases []string + StripSigils string + DirectoryGlob bool + DirectID func(string) bool + AdditionalKeys func(map[string]any) []string +} + +func resolveIntegrationBindPathGlob(provider, resource string) (integrationBindPathResolution, error) { + resource = strings.TrimSpace(resource) + if resource == "" { + return integrationBindPathResolution{}, errors.New("PATH_GLOB/resource is required") + } + if strings.HasPrefix(resource, "/") { + return integrationBindPathResolution{PathGlob: resource}, nil + } + + switch provider { + case "github": + return resolveGitHubBindPathGlob(resource) + case "slack": + return resolveContainerBindPathGlob(resource, integrationContainerResolver{ + Provider: "slack", + Root: "/slack", + Container: "channels", + Aliases: []string{"by-name"}, + StripSigils: "#", + DirectoryGlob: true, + DirectID: func(value string) bool { + return len(value) >= 6 && strings.IndexFunc(value, func(r rune) bool { + return !(r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') + }) < 0 + }, + }) + case "telegram": + return resolveContainerBindPathGlob(resource, integrationContainerResolver{ + Provider: "telegram", + Root: "/telegram", + Container: "chats", + Aliases: []string{"by-title", "by-username"}, + StripSigils: "@", + DirectoryGlob: true, + DirectID: func(value string) bool { + trimmed := strings.TrimPrefix(value, "-") + return trimmed != "" && strings.IndexFunc(trimmed, func(r rune) bool { + return r < '0' || r > '9' + }) < 0 + }, + }) + case "linear": + return resolveContainerBindPathGlob(resource, integrationContainerResolver{ + Provider: "linear", + Root: "/linear", + Container: "teams", + Aliases: []string{"by-name"}, + StripSigils: "", + DirectoryGlob: false, + DirectID: func(value string) bool { + return isUUIDLike(value) || strings.HasPrefix(strings.ToLower(value), "team") + }, + AdditionalKeys: func(row map[string]any) []string { + return []string{stringField(row, "key"), stringField(row, "team_key")} + }, + }) + default: + return integrationBindPathResolution{}, fmt.Errorf("PATH_GLOB must start with /; provider %q has no native resource resolver", provider) + } +} + +func resolveGitHubBindPathGlob(resource string) (integrationBindPathResolution, error) { + parts := strings.Split(strings.Trim(resource, "/"), "/") + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return integrationBindPathResolution{}, errors.New("github resource must be owner/repo or an explicit /-prefixed path glob") + } + return integrationBindPathResolution{ + PathGlob: path.Join("/github/repos", url.PathEscape(parts[0]), url.PathEscape(parts[1]), "**"), + }, nil +} + +func resolveContainerBindPathGlob(resource string, cfg integrationContainerResolver) (integrationBindPathResolution, error) { + native := strings.TrimSpace(resource) + lookup := strings.Trim(native, " \t\r\n") + if cfg.StripSigils != "" { + lookup = strings.TrimLeft(lookup, cfg.StripSigils) + } + if lookup == "" { + return integrationBindPathResolution{}, fmt.Errorf("%s resource is empty after trimming sigils", cfg.Provider) + } + + if cfg.DirectID != nil && cfg.DirectID(lookup) { + if canonical, ok := resolveContainerResourceByID(cfg, lookup); ok { + return integrationBindPathResolution{PathGlob: canonicalPathToBindGlob(canonical, cfg)}, nil + } + if cfg.DirectoryGlob { + return integrationBindPathResolution{ + PathGlob: path.Join(cfg.Root, cfg.Container, encodeVFSPathSegment(lookup), "**"), + Warning: fmt.Sprintf("could not find %s resource %q in the active mount; bound %s and it may miss id-slug paths until the mount index is available", + cfg.Provider, native, path.Join(cfg.Root, cfg.Container, encodeVFSPathSegment(lookup), "**")), + }, nil + } + return integrationBindPathResolution{PathGlob: path.Join(cfg.Root, cfg.Container, encodeVFSPathSegment(lookup)+".json")}, nil + } + + if canonical, ok := resolveContainerResourceByAliasOrIndex(cfg, lookup); ok { + return integrationBindPathResolution{PathGlob: canonicalPathToBindGlob(canonical, cfg)}, nil + } + + fallback := unresolvedContainerFallbackGlob(cfg) + return integrationBindPathResolution{ + PathGlob: fallback, + Warning: fmt.Sprintf("could not resolve %s resource %q from the active mount; bound fallback glob %s, which may match more than the requested resource", + cfg.Provider, native, fallback), + }, nil +} + +func resolveContainerResourceByID(cfg integrationContainerResolver, id string) (string, bool) { + if canonical, ok := findContainerIndexCanonical(cfg, func(row map[string]any) bool { + return stringEqualFold(stringField(row, "id"), id) || stringEqualFold(stringField(row, "objectId"), id) + }); ok { + return canonical, true + } + return findMountedContainerEntry(cfg, id) +} + +func resolveContainerResourceByAliasOrIndex(cfg integrationContainerResolver, name string) (string, bool) { + slug := slugifyNativeResource(name) + for _, alias := range cfg.Aliases { + aliasDir := path.Join(cfg.Root, cfg.Container, alias) + if canonical, ok := readAliasCanonicalPath(path.Join(aliasDir, slug+".json")); ok { + return canonical, true + } + if canonical, ok := scanAliasCanonicalPath(aliasDir, slug); ok { + return canonical, true + } + if alias == "by-username" { + if canonical, ok := readAliasCanonicalPath(path.Join(aliasDir, strings.TrimPrefix(slug, "@")+".json")); ok { + return canonical, true + } + } + } + if canonical, ok := findContainerIndexCanonical(cfg, func(row map[string]any) bool { + keys := []string{ + stringField(row, "title"), + stringField(row, "name"), + stringField(row, "username"), + stringField(row, "id"), + } + if cfg.AdditionalKeys != nil { + keys = append(keys, cfg.AdditionalKeys(row)...) + } + for _, key := range keys { + if key == "" { + continue + } + if stringEqualFold(key, name) || slugifyNativeResource(key) == slug { + return true + } + } + return false + }); ok { + return canonical, true + } + if cfg.Provider == "linear" { + return scanLinearTeamFiles(name) + } + return "", false +} + +func scanAliasCanonicalPath(aliasDirRemotePath, slug string) (string, bool) { + record, ok := activeWorkspaceRecordForMountResolution() + if !ok || strings.TrimSpace(record.LocalDir) == "" { + return "", false + } + aliasDir := filepath.Join(record.LocalDir, filepath.FromSlash(strings.TrimPrefix(aliasDirRemotePath, "/"))) + entries, err := os.ReadDir(aliasDir) + if err != nil { + return "", false + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if name == slug+".json" || strings.HasPrefix(name, slug+"__") || strings.HasPrefix(name, slug+"-") { + if canonical, ok := readAliasCanonicalPath(path.Join(aliasDirRemotePath, name)); ok { + return canonical, true + } + } + } + return "", false +} + +func findContainerIndexCanonical(cfg integrationContainerResolver, match func(map[string]any) bool) (string, bool) { + rows, ok := readIndexRows(path.Join(cfg.Root, cfg.Container, "_index.json")) + if !ok { + return "", false + } + for _, row := range rows { + if !match(row) { + continue + } + if canonical := stringField(row, "canonicalPath"); canonical != "" { + return canonical, true + } + if p := stringField(row, "path"); p != "" { + return p, true + } + if id := stringField(row, "id"); id != "" { + return findMountedContainerEntry(cfg, id) + } + } + return "", false +} + +func readAliasCanonicalPath(remotePath string) (string, bool) { + var payload map[string]any + if !readMountedJSON(remotePath, &payload) { + return "", false + } + for _, key := range []string{"canonicalPath", "path", "targetPath"} { + if value := stringField(payload, key); value != "" { + return value, true + } + } + if id := stringField(payload, "id"); id != "" { + if strings.Contains(remotePath, "/slack/channels/") { + return findMountedContainerEntry(integrationContainerResolver{Root: "/slack", Container: "channels", DirectoryGlob: true}, id) + } + if strings.Contains(remotePath, "/telegram/chats/") { + return findMountedContainerEntry(integrationContainerResolver{Root: "/telegram", Container: "chats", DirectoryGlob: true}, id) + } + if strings.Contains(remotePath, "/linear/teams/") { + return path.Join("/linear/teams", encodeVFSPathSegment(id)+".json"), true + } + } + return "", false +} + +func readIndexRows(remotePath string) ([]map[string]any, bool) { + var raw any + if !readMountedJSON(remotePath, &raw) { + return nil, false + } + switch value := raw.(type) { + case []any: + return mapsFromArray(value), true + case map[string]any: + if rows, ok := value["rows"].([]any); ok { + return mapsFromArray(rows), true + } + } + return nil, false +} + +func mapsFromArray(values []any) []map[string]any { + rows := make([]map[string]any, 0, len(values)) + for _, value := range values { + if row, ok := value.(map[string]any); ok { + rows = append(rows, row) + } + } + return rows +} + +func findMountedContainerEntry(cfg integrationContainerResolver, id string) (string, bool) { + record, ok := activeWorkspaceRecordForMountResolution() + if !ok || strings.TrimSpace(record.LocalDir) == "" { + return "", false + } + dir := filepath.Join(record.LocalDir, filepath.FromSlash(strings.TrimPrefix(path.Join(cfg.Root, cfg.Container), "/"))) + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + encodedID := encodeVFSPathSegment(id) + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, "by-") || name == "_index.json" { + continue + } + if cfg.DirectoryGlob { + if entry.IsDir() && (name == encodedID || strings.HasPrefix(name, encodedID+"__")) { + return path.Join(cfg.Root, cfg.Container, name), true + } + continue + } + if !entry.IsDir() && name == encodedID+".json" { + return path.Join(cfg.Root, cfg.Container, name), true + } + } + return "", false +} + +func scanLinearTeamFiles(resource string) (string, bool) { + record, ok := activeWorkspaceRecordForMountResolution() + if !ok || strings.TrimSpace(record.LocalDir) == "" { + return "", false + } + dir := filepath.Join(record.LocalDir, "linear", "teams") + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + slug := slugifyNativeResource(resource) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, "by-") || !strings.HasSuffix(name, ".json") || name == "_index.json" { + continue + } + var payload map[string]any + if !readMountedJSON(path.Join("/linear/teams", name), &payload) { + continue + } + recordPayload, _ := payload["payload"].(map[string]any) + keys := []string{ + stringField(payload, "id"), + stringField(payload, "key"), + stringField(payload, "name"), + stringField(recordPayload, "id"), + stringField(recordPayload, "key"), + stringField(recordPayload, "name"), + } + for _, key := range keys { + if key != "" && (stringEqualFold(key, resource) || slugifyNativeResource(key) == slug) { + return path.Join("/linear/teams", name), true + } + } + } + return "", false +} + +func canonicalPathToBindGlob(canonical string, cfg integrationContainerResolver) string { + clean := "/" + strings.TrimLeft(strings.TrimSpace(canonical), "/") + clean = path.Clean(clean) + if cfg.DirectoryGlob { + parts := strings.Split(strings.Trim(clean, "/"), "/") + if len(parts) >= 3 && "/"+parts[0] == cfg.Root && parts[1] == cfg.Container { + return path.Join(cfg.Root, cfg.Container, parts[2], "**") + } + if strings.HasSuffix(clean, "/meta.json") { + return path.Join(path.Dir(clean), "**") + } + return path.Join(clean, "**") + } + return clean +} + +func unresolvedContainerFallbackGlob(cfg integrationContainerResolver) string { + if cfg.DirectoryGlob { + return path.Join(cfg.Root, cfg.Container, "*", "**") + } + return path.Join(cfg.Root, cfg.Container, "*") +} + +func readMountedJSON(remotePath string, out any) bool { + record, ok := activeWorkspaceRecordForMountResolution() + if !ok || strings.TrimSpace(record.LocalDir) == "" { + return false + } + localPath := filepath.Join(record.LocalDir, filepath.FromSlash(strings.TrimPrefix(remotePath, "/"))) + payload, err := os.ReadFile(localPath) + if err != nil { + return false + } + return json.Unmarshal(payload, out) == nil +} + +func activeWorkspaceRecordForMountResolution() (workspaceRecord, bool) { + creds, _ := loadCredentials() + name, _ := activeWorkspaceName(resolveToken("", creds)) + if strings.TrimSpace(name) == "" { + return workspaceRecord{}, false + } + if record, ok := workspaceRecordByName(name); ok { + return record, true + } + if record, ok := workspaceRecordByID(name); ok { + return record, true + } + return workspaceRecord{}, false +} + +func stringField(record map[string]any, key string) string { + if record == nil { + return "" + } + value, ok := record[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return strings.TrimSpace(typed.String()) + default: + return strings.TrimSpace(fmt.Sprint(typed)) + } +} + +func stringEqualFold(a, b string) bool { + return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) +} + +func slugifyNativeResource(value string) string { + var b strings.Builder + lastDash := false + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + +func encodeVFSPathSegment(value string) string { + return url.PathEscape(strings.TrimSpace(value)) +} + +func isUUIDLike(value string) bool { + trimmed := strings.TrimSpace(value) + if len(trimmed) == 36 { + for i, r := range trimmed { + if i == 8 || i == 13 || i == 18 || i == 23 { + if r != '-' { + return false + } + continue + } + if !isHexRune(r) { + return false + } + } + return true + } + if len(trimmed) == 32 { + for _, r := range trimmed { + if !isHexRune(r) { + return false + } + } + return true + } + return false +} + +func isHexRune(r rune) bool { + return r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F' +} + func delegatedCredentialsPathForRequest(workspaceValue string, scopes []string) string { workspaceKey := workspaceShardKey(workspaceValue) scopeKey := delegatedCredentialsScopeKey(scopes) diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 957006c7..014b55e1 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -292,6 +292,126 @@ func TestIntegrationBindListAndUnbind(t *testing.T) { } } +func TestIntegrationBindResolvesNativeResources(t *testing.T) { + tests := []struct { + name string + provider string + resource string + files map[string]string + wantGlob string + wantWarn bool + wantError string + }{ + { + name: "explicit glob passes through", + provider: "slack", + resource: "/slack/channels/C123__watchdog-test/**", + wantGlob: "/slack/channels/C123__watchdog-test/**", + }, + { + name: "slack channel name resolves through by-name alias", + provider: "slack", + resource: "#watchdog-test", + files: map[string]string{ + "slack/channels/by-name/watchdog-test.json": `{"id":"C123","canonicalPath":"/slack/channels/C123__watchdog-test/meta.json"}`, + }, + wantGlob: "/slack/channels/C123__watchdog-test/**", + }, + { + name: "github owner repo maps to repo subtree", + provider: "github", + resource: "AgentWorkforce/relayfile", + wantGlob: "/github/repos/AgentWorkforce/relayfile/**", + }, + { + name: "linear team key resolves by mounted team payload", + provider: "linear", + resource: "AR", + files: map[string]string{ + "linear/teams/team-ar.json": `{"provider":"linear","payload":{"id":"team-ar","key":"AR","name":"Agent Relay"}}`, + }, + wantGlob: "/linear/teams/team-ar.json", + }, + { + name: "telegram chat title resolves through id-suffixed alias", + provider: "telegram", + resource: "@release-room", + files: map[string]string{ + "telegram/chats/by-username/release-room__-100123.json": `{"id":"-100123","canonicalPath":"/telegram/chats/-100123__release-room/meta.json"}`, + }, + wantGlob: "/telegram/chats/-100123__release-room/**", + }, + { + name: "unresolved slack name warns and binds matcher-safe fallback", + provider: "slack", + resource: "#missing-channel", + wantGlob: "/slack/channels/*/**", + wantWarn: true, + }, + { + name: "unknown native provider still requires explicit VFS glob", + provider: "notion", + resource: "Engineering", + wantError: `provider "notion" has no native resource resolver`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + localDir := t.TempDir() + if _, err := upsertWorkspaceDetails(workspaceRecord{Name: "demo", ID: "ws_demo", LocalDir: localDir}); err != nil { + t.Fatalf("upsert workspace failed: %v", err) + } + for rel, content := range tc.files { + writeTestMountFile(t, localDir, rel, content) + } + + var stdout bytes.Buffer + err := run([]string{ + "integration", "bind", tc.provider, tc.resource, + "--channel", "#events", + "--webhook", "wh_1", + "--webhook-token", "tok_1", + }, strings.NewReader(""), &stdout, &stdout) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("expected error containing %q, got err=%v output=%q", tc.wantError, err, stdout.String()) + } + return + } + if err != nil { + t.Fatalf("bind failed: %v\n%s", err, stdout.String()) + } + if tc.wantWarn && !strings.Contains(stdout.String(), "Warning:") { + t.Fatalf("expected warning, got %q", stdout.String()) + } + bindings, err := readRelayIntegrationBindings() + if err != nil { + t.Fatalf("read bindings failed: %v", err) + } + if len(bindings) != 1 { + t.Fatalf("expected one binding, got %#v", bindings) + } + if bindings[0].PathGlob != tc.wantGlob { + t.Fatalf("expected glob %q, got %#v", tc.wantGlob, bindings[0]) + } + }) + } +} + +func writeTestMountFile(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir mount fixture failed: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write mount fixture failed: %v", err) + } +} + // TestOnOffAliasesDispatchLikeMountAndStop verifies that the `on` and `off` // verbs migrated from the agent-relay CLI route to the same handlers as // `mount` and `stop`. We assert on the handler-specific error surfaced for an diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a4379a5a..b179263a 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -6,7 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_No unreleased changes._ +### Fixed + +- `relayfile integration bind` now accepts provider-native resources for Slack channels, GitHub repositories, Linear teams, and Telegram chats, resolving them to matching VFS path globs while preserving explicit `/`-prefixed globs. ## [0.10.15] - 2026-06-28 From 6646d07257eabdb3c783951fe8011c47c4873ea2 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 29 Jun 2026 15:06:35 -0700 Subject: [PATCH 2/4] Address integration path review feedback --- cmd/relayfile-cli/main.go | 102 ++++++++++++++++++++++++--------- cmd/relayfile-cli/main_test.go | 48 +++++++++++++++- 2 files changed, 122 insertions(+), 28 deletions(-) diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index b52db18e..2bdffce1 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -708,11 +708,11 @@ func printIntegrationUsage(w io.Writer, subcommand string) { case "set-metadata": fmt.Fprintln(w, "Usage: relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes]") case "bind": - fmt.Fprintln(w, "Usage: relayfile integration bind PROVIDER PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") + fmt.Fprintln(w, "Usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") case "resolve-path": fmt.Fprintln(w, "Usage: relayfile integration resolve-path PROVIDER RESOURCE [--json]") case "unbind": - fmt.Fprintln(w, "Usage: relayfile integration unbind PROVIDER [PATH_GLOB|--resource PATH_GLOB]") + fmt.Fprintln(w, "Usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") default: fmt.Fprintln(w, `Usage: relayfile integration connect PROVIDER [--backend BACKEND] [--workspace NAME] [--wait-sync] @@ -722,9 +722,9 @@ func printIntegrationUsage(w io.Writer, subcommand string) { relayfile integration disconnect PROVIDER [--workspace NAME] [--yes] relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] - relayfile integration bind PROVIDER PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN + relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN relayfile integration resolve-path PROVIDER RESOURCE [--json] - relayfile integration unbind PROVIDER [PATH_GLOB|--resource PATH_GLOB] + relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB] relayfile integration writeback-secret --channel CHANNEL [--workspace WS] [--json]`) } } @@ -806,9 +806,9 @@ Usage: relayfile integration disconnect PROVIDER [--workspace NAME] [--yes] relayfile integration adopt PROVIDER --connection-id ID [--workspace NAME] [--provider-config-key KEY] [--yes] relayfile integration set-metadata PROVIDER KEY=VALUE [KEY=VALUE...] [--workspace NAME] [--yes] - relayfile integration bind PROVIDER PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN + relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN relayfile integration resolve-path PROVIDER RESOURCE [--json] - relayfile integration unbind PROVIDER [PATH_GLOB|--resource PATH_GLOB] + relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB] relayfile ops list [--workspace NAME] [--json] relayfile ops replay OPID [--workspace NAME] relayfile writeback list --state pending|dead [--workspace WS] [--json] @@ -2328,7 +2328,7 @@ func runIntegrationBind(args []string, stdout io.Writer) error { return writeJSON(stdout, bindings) } if fs.NArg() != 2 { - return errors.New("usage: relayfile integration bind PROVIDER PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") + return errors.New("usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") } provider := normalizeProviderID(fs.Arg(0)) if err := validateLocalProviderID(provider); err != nil { @@ -2445,7 +2445,7 @@ func runIntegrationUnbind(args []string, stdout io.Writer) error { return err } if fs.NArg() < 1 || fs.NArg() > 2 { - return errors.New("usage: relayfile integration unbind PROVIDER [PATH_GLOB|--resource PATH_GLOB]") + return errors.New("usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") } provider := normalizeProviderID(fs.Arg(0)) if err := validateLocalProviderID(provider); err != nil { @@ -2454,16 +2454,22 @@ func runIntegrationUnbind(args []string, stdout io.Writer) error { pathGlob := strings.TrimSpace(*resource) if fs.NArg() == 2 { if pathGlob != "" { - return errors.New("pass PATH_GLOB either positionally or with --resource, not both") + return errors.New("pass RESOURCE_OR_PATH_GLOB either positionally or with --resource, not both") } pathGlob = strings.TrimSpace(fs.Arg(1)) } - if pathGlob != "" && !strings.HasPrefix(pathGlob, "/") { + matchGlobs := []string{} + if pathGlob != "" { + matchGlobs = append(matchGlobs, pathGlob) + } + nativeResource := pathGlob != "" && !strings.HasPrefix(pathGlob, "/") + if nativeResource { resolved, err := resolveIntegrationBindPathGlob(provider, pathGlob) if err != nil { return err } pathGlob = resolved.PathGlob + matchGlobs = append([]string{pathGlob}, fallbackUnbindPathGlobsForNativeResource(provider)...) if resolved.Warning != "" { fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) } @@ -2475,7 +2481,7 @@ func runIntegrationUnbind(args []string, stdout io.Writer) error { kept := bindings[:0] removed := 0 for _, binding := range bindings { - if binding.Provider == provider && (pathGlob == "" || binding.PathGlob == pathGlob) { + if binding.Provider == provider && (pathGlob == "" || pathGlobMatchesAny(binding.PathGlob, matchGlobs)) { removed++ continue } @@ -7860,6 +7866,28 @@ func writeRelayIntegrationBindings(bindings []relayIntegrationBinding) error { return writeFileAtomically(relayIntegrationBindingsPath(), payload, 0o600) } +func pathGlobMatchesAny(pathGlob string, candidates []string) bool { + for _, candidate := range candidates { + if pathGlob == candidate { + return true + } + } + return false +} + +func fallbackUnbindPathGlobsForNativeResource(provider string) []string { + switch provider { + case "slack": + return []string{"/slack/channels/**", "/slack/channels/*/**"} + case "telegram": + return []string{"/telegram/chats/**", "/telegram/chats/*/**"} + case "linear": + return []string{"/linear/teams/*"} + default: + return nil + } +} + func delegatedCredentialsPath() string { return filepath.Join(configDir(), "delegated-credentials.json") } @@ -8055,7 +8083,7 @@ func scanAliasCanonicalPath(aliasDirRemotePath, slug string) (string, bool) { continue } name := entry.Name() - if name == slug+".json" || strings.HasPrefix(name, slug+"__") || strings.HasPrefix(name, slug+"-") { + if strings.HasSuffix(name, ".json") && (name == slug+".json" || strings.HasPrefix(name, slug+"__")) { if canonical, ok := readAliasCanonicalPath(path.Join(aliasDirRemotePath, name)); ok { return canonical, true } @@ -8221,7 +8249,7 @@ func canonicalPathToBindGlob(canonical string, cfg integrationContainerResolver) func unresolvedContainerFallbackGlob(cfg integrationContainerResolver) string { if cfg.DirectoryGlob { - return path.Join(cfg.Root, cfg.Container, "*", "**") + return path.Join(cfg.Root, cfg.Container, "**") } return path.Join(cfg.Root, cfg.Container, "*") } @@ -8239,19 +8267,37 @@ func readMountedJSON(remotePath string, out any) bool { return json.Unmarshal(payload, out) == nil } +var ( + activeWorkspaceRecordCache workspaceRecord + activeWorkspaceRecordCacheOK bool + activeWorkspaceRecordCacheOnce sync.Once +) + func activeWorkspaceRecordForMountResolution() (workspaceRecord, bool) { - creds, _ := loadCredentials() - name, _ := activeWorkspaceName(resolveToken("", creds)) - if strings.TrimSpace(name) == "" { - return workspaceRecord{}, false - } - if record, ok := workspaceRecordByName(name); ok { - return record, true - } - if record, ok := workspaceRecordByID(name); ok { - return record, true - } - return workspaceRecord{}, false + activeWorkspaceRecordCacheOnce.Do(func() { + creds, _ := loadCredentials() + name, _ := activeWorkspaceName(resolveToken("", creds)) + if strings.TrimSpace(name) == "" { + return + } + if record, ok := workspaceRecordByName(name); ok { + activeWorkspaceRecordCache = record + activeWorkspaceRecordCacheOK = true + return + } + if record, ok := workspaceRecordByID(name); ok { + activeWorkspaceRecordCache = record + activeWorkspaceRecordCacheOK = true + return + } + }) + return activeWorkspaceRecordCache, activeWorkspaceRecordCacheOK +} + +func resetActiveWorkspaceRecordForMountResolutionCache() { + activeWorkspaceRecordCache = workspaceRecord{} + activeWorkspaceRecordCacheOK = false + activeWorkspaceRecordCacheOnce = sync.Once{} } func stringField(record map[string]any, key string) string { @@ -8300,7 +8346,8 @@ func encodeVFSPathSegment(value string) string { func isUUIDLike(value string) bool { trimmed := strings.TrimSpace(value) if len(trimmed) == 36 { - for i, r := range trimmed { + for i := 0; i < len(trimmed); i++ { + r := rune(trimmed[i]) if i == 8 || i == 13 || i == 18 || i == 23 { if r != '-' { return false @@ -8314,7 +8361,8 @@ func isUUIDLike(value string) bool { return true } if len(trimmed) == 32 { - for _, r := range trimmed { + for i := 0; i < len(trimmed); i++ { + r := rune(trimmed[i]) if !isHexRune(r) { return false } diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 014b55e1..d843f9b4 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -292,6 +292,51 @@ func TestIntegrationBindListAndUnbind(t *testing.T) { } } +func TestIntegrationUnbindNativeResourceRemovesStoredFallbackGlob(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + localDir := t.TempDir() + if _, err := upsertWorkspaceDetails(workspaceRecord{Name: "demo", ID: "ws_demo", LocalDir: localDir}); err != nil { + t.Fatalf("upsert workspace failed: %v", err) + } + writeTestMountFile(t, localDir, "slack/channels/by-name/watchdog-test.json", `{"id":"C123","canonicalPath":"/slack/channels/C123__watchdog-test/meta.json"}`) + + if err := writeRelayIntegrationBindings([]relayIntegrationBinding{ + { + Provider: "slack", + PathGlob: "/slack/channels/*/**", + Channel: "#events", + WebhookID: "wh_1", + WebhookToken: "tok_1", + }, + { + Provider: "github", + PathGlob: "/github/repos/AgentWorkforce/relayfile/**", + Channel: "#events", + WebhookID: "wh_2", + WebhookToken: "tok_2", + }, + }); err != nil { + t.Fatalf("seed bindings failed: %v", err) + } + + var stdout bytes.Buffer + if err := run([]string{"integration", "unbind", "slack", "--resource", "#watchdog-test"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("unbind failed: %v\n%s", err, stdout.String()) + } + if !strings.Contains(stdout.String(), "slack binding removed") { + t.Fatalf("expected unbind confirmation, got %q", stdout.String()) + } + + bindings, err := readRelayIntegrationBindings() + if err != nil { + t.Fatalf("read bindings failed: %v", err) + } + if len(bindings) != 1 || bindings[0].Provider != "github" { + t.Fatalf("expected only github binding to remain, got %#v", bindings) + } +} + func TestIntegrationBindResolvesNativeResources(t *testing.T) { tests := []struct { name string @@ -345,7 +390,7 @@ func TestIntegrationBindResolvesNativeResources(t *testing.T) { name: "unresolved slack name warns and binds matcher-safe fallback", provider: "slack", resource: "#missing-channel", - wantGlob: "/slack/channels/*/**", + wantGlob: "/slack/channels/**", wantWarn: true, }, { @@ -2755,6 +2800,7 @@ func TestMountSkipsDataPlaneWhenDelegatedRefreshRejected(t *testing.T) { func clearRelayfileEnv(t *testing.T) { t.Helper() + resetActiveWorkspaceRecordForMountResolutionCache() t.Setenv("RELAYFILE_SERVER", "") t.Setenv("RELAYFILE_BASE_URL", "") t.Setenv("RELAYFILE_TOKEN", "") From f9c60d083c3d63d403c86ca1108b040f2fdb81da Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 29 Jun 2026 15:56:56 -0700 Subject: [PATCH 3/4] feat: add relayfile control-plane client --- .github/workflows/ci.yml | 32 + .github/workflows/publish.yml | 13 + cmd/relayfile-cli/control_plane.go | 566 +++++++++++++++++ cmd/relayfile-cli/control_plane_test.go | 330 ++++++++++ cmd/relayfile-cli/main.go | 215 ++++--- .../relayfile-control-plane-v1.openapi.yaml | 502 +++++++++++++++ package-lock.json | 308 ++++++++- package.json | 7 +- packages/cli/package.json | 2 +- packages/cli/scripts/build-binaries.js | 3 +- packages/client/CHANGELOG.md | 9 + packages/client/package.json | 52 ++ packages/client/src/client.test.ts | 162 +++++ packages/client/src/client.ts | 351 ++++++++++ .../client/src/generated/control-plane.ts | 598 ++++++++++++++++++ packages/client/src/index.ts | 25 + packages/client/tsconfig.json | 18 + packages/client/vitest.config.ts | 7 + scripts/finalize-changelogs.mjs | 1 + 19 files changed, 3113 insertions(+), 88 deletions(-) create mode 100644 cmd/relayfile-cli/control_plane.go create mode 100644 cmd/relayfile-cli/control_plane_test.go create mode 100644 openapi/relayfile-control-plane-v1.openapi.yaml create mode 100644 packages/client/CHANGELOG.md create mode 100644 packages/client/package.json create mode 100644 packages/client/src/client.test.ts create mode 100644 packages/client/src/client.ts create mode 100644 packages/client/src/generated/control-plane.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 272a5f92..42b89887 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,38 @@ jobs: working-directory: packages/sdk/typescript run: npm run test + client-typecheck: + name: Client Typecheck + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Check generated control-plane types + run: | + npm run codegen --workspace=@relayfile/client + git diff --exit-code -- packages/client/src/generated/control-plane.ts + + - name: Build client + run: npm run build --workspace=@relayfile/client + + - name: Typecheck client + run: npm run typecheck --workspace=@relayfile/client + + - name: Test client + run: npm run test --workspace=@relayfile/client + e2e: name: E2E runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2cc5d2ec..0fb86dd6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,7 @@ on: - all - core - sdk + - client - agents - cli - file-observer @@ -157,6 +158,7 @@ jobs: const packagePaths = [ 'packages/core/package.json', 'packages/sdk/typescript/package.json', + 'packages/client/package.json', 'packages/agents/package.json', 'packages/cli/package.json', 'packages/file-observer/package.json', @@ -205,6 +207,7 @@ jobs: run: | npm run build --workspace=packages/core npm run build --workspace=packages/sdk/typescript + npm run build --workspace=@relayfile/client npm run build --workspace=packages/agents npm run build --workspace=packages/cli npm run build --workspace=packages/local-mount @@ -223,6 +226,9 @@ jobs: packages/sdk/typescript/package.json packages/sdk/typescript/dist/ packages/sdk/typescript/CHANGELOG.md + packages/client/package.json + packages/client/dist/ + packages/client/CHANGELOG.md packages/agents/package.json packages/agents/dist/ packages/agents/CHANGELOG.md @@ -342,6 +348,9 @@ jobs: - package: sdk path: packages/sdk/typescript mount_binary: "" + - package: client + path: packages/client + mount_binary: "" - package: agents path: packages/agents mount_binary: "" @@ -455,6 +464,7 @@ jobs: case "${{ github.event.inputs.package }}" in core) echo "path=packages/core" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; sdk) echo "path=packages/sdk/typescript" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; + client) echo "path=packages/client" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; agents) echo "path=packages/agents" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; cli) echo "path=packages/cli" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; file-observer) echo "path=packages/file-observer" >> "$GITHUB_OUTPUT"; echo "mount_binary=" >> "$GITHUB_OUTPUT" ;; @@ -579,6 +589,7 @@ jobs: package.json package-lock.json \ packages/core/package.json packages/core/CHANGELOG.md \ packages/sdk/typescript/package.json packages/sdk/typescript/package-lock.json packages/sdk/typescript/CHANGELOG.md \ + packages/client/package.json packages/client/CHANGELOG.md \ packages/agents/package.json packages/agents/CHANGELOG.md \ packages/cli/package.json packages/cli/CHANGELOG.md \ packages/file-observer/package.json packages/file-observer/CHANGELOG.md \ @@ -604,6 +615,7 @@ jobs: ### Packages - `@relayfile/core@${{ needs.build.outputs.new_version }}` - `@relayfile/sdk@${{ needs.build.outputs.new_version }}` + - `@relayfile/client@${{ needs.build.outputs.new_version }}` - `@relayfile/agents@${{ needs.build.outputs.new_version }}` - `relayfile@${{ needs.build.outputs.new_version }}` - `@relayfile/file-observer@${{ needs.build.outputs.new_version }}` @@ -616,6 +628,7 @@ jobs: ### Install ```bash npm install @relayfile/sdk@${{ needs.build.outputs.new_version }} + npm install @relayfile/client@${{ needs.build.outputs.new_version }} npm install @relayfile/agents@${{ needs.build.outputs.new_version }} npm install relayfile@${{ needs.build.outputs.new_version }} npm install @relayfile/file-observer@${{ needs.build.outputs.new_version }} diff --git a/cmd/relayfile-cli/control_plane.go b/cmd/relayfile-cli/control_plane.go new file mode 100644 index 00000000..f2bf1949 --- /dev/null +++ b/cmd/relayfile-cli/control_plane.go @@ -0,0 +1,566 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const relayfileControlPlaneAPIVersion uint32 = 1 + +var relayfileControlPlaneSupportedVersions = []uint32{relayfileControlPlaneAPIVersion} + +type controlPlaneErrorCode string + +const ( + controlPlaneErrInvalidArgument controlPlaneErrorCode = "INVALID_ARGUMENT" + controlPlaneErrVersionIncompatible controlPlaneErrorCode = "VERSION_INCOMPATIBLE" + controlPlaneErrResourceUnresolved controlPlaneErrorCode = "RESOURCE_UNRESOLVED" + controlPlaneErrBindingNotFound controlPlaneErrorCode = "BINDING_NOT_FOUND" + controlPlaneErrDaemonUnavailable controlPlaneErrorCode = "DAEMON_UNAVAILABLE" +) + +type controlPlaneError struct { + Code controlPlaneErrorCode `json:"code"` + Message string `json:"message"` +} + +type helloRequest struct { + APIVersion uint32 `json:"apiVersion,omitempty"` +} + +type helloResponse struct { + DaemonVersion string `json:"daemonVersion"` + APIVersion uint32 `json:"apiVersion"` + SupportedAPIVersions []uint32 `json:"supportedApiVersions"` +} + +type listProvidersResponse struct { + Providers []integrationCatalogEntry `json:"providers"` +} + +type providerStatusRequest struct { + Provider string `json:"provider,omitempty"` + Workspace string `json:"workspace,omitempty"` + CloudAPIURL string `json:"cloudApiUrl,omitempty"` +} + +type connectProviderRequest struct { + Provider string `json:"provider"` + Workspace string `json:"workspace,omitempty"` + CloudAPIURL string `json:"cloudApiUrl,omitempty"` + Backend string `json:"backend,omitempty"` + NoOpen bool `json:"noOpen,omitempty"` + Timeout string `json:"timeout,omitempty"` + WaitSync bool `json:"waitSync,omitempty"` +} + +type connectProviderResponse struct { + Provider string `json:"provider"` + Output string `json:"output,omitempty"` +} + +type resolveResourcePathRequest struct { + Provider string `json:"provider"` + Resource string `json:"resource"` +} + +type resolveResourcePathResponse struct { + Provider string `json:"provider"` + Resource string `json:"resource"` + PathGlob string `json:"pathGlob"` + ResolvedExact bool `json:"resolvedExact"` + Warning string `json:"warning,omitempty"` +} + +type bindRequest struct { + Provider string `json:"provider"` + Resource string `json:"resource"` + Channel string `json:"channel"` + WebhookID string `json:"webhookId"` + WebhookToken string `json:"webhookToken"` + SubscriptionID string `json:"subscriptionId,omitempty"` +} + +type bindResponse struct { + Binding relayIntegrationBinding `json:"binding"` + Replaced bool `json:"replaced"` + Warning string `json:"warning,omitempty"` +} + +type listBindingsResponse struct { + Bindings []relayIntegrationBinding `json:"bindings"` +} + +type unbindRequest struct { + Provider string `json:"provider"` + Resource string `json:"resource,omitempty"` +} + +type writebackSecretRequest struct { + Workspace string `json:"workspace,omitempty"` + Channel string `json:"channel"` +} + +type writebackSecretData struct { + URL string `json:"url"` + Secret string `json:"secret"` +} + +func runControlPlane(args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("control-plane subcommand is required: serve") + } + switch args[0] { + case "serve": + return runControlPlaneServe(args[1:], stdout) + default: + return fmt.Errorf("unknown control-plane subcommand %q", args[0]) + } +} + +func runControlPlaneServe(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("control-plane serve", flag.ContinueOnError) + fs.SetOutput(io.Discard) + sock := fs.String("sock", defaultRelayfileSocketPath(), "unix socket path") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{"sock": true})); err != nil { + return err + } + if fs.NArg() != 0 { + return errors.New("usage: relayfile control-plane serve [--sock PATH]") + } + return serveControlPlaneSocket(strings.TrimSpace(*sock), stdout) +} + +func serveControlPlaneSocket(sock string, stdout io.Writer) error { + if sock == "" { + sock = defaultRelayfileSocketPath() + } + if err := os.MkdirAll(filepath.Dir(sock), 0o700); err != nil { + return err + } + _ = os.Remove(sock) + listener, err := net.Listen("unix", sock) + if err != nil { + return err + } + defer listener.Close() + defer os.Remove(sock) + if err := os.Chmod(sock, 0o600); err != nil { + return err + } + + server := &http.Server{Handler: newControlPlaneHandler()} + errCh := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + fmt.Fprintf(stdout, "relayfile control-plane listening on %s\n", sock) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigCh) + + select { + case sig := <-sigCh: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + return err + } + if sig != os.Interrupt && sig != syscall.SIGTERM { + return fmt.Errorf("control-plane stopped by signal %s", sig) + } + return nil + case err := <-errCh: + return err + } +} + +func newControlPlaneHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/v1/hello", handleControlPlaneHello) + mux.HandleFunc("/v1/integrations/providers", withControlPlaneVersion(handleControlPlaneListProviders)) + mux.HandleFunc("/v1/integrations/provider-status", withControlPlaneVersion(handleControlPlaneProviderStatus)) + mux.HandleFunc("/v1/integrations/connect", withControlPlaneVersion(handleControlPlaneConnectProvider)) + mux.HandleFunc("/v1/integrations/resolve-path", withControlPlaneVersion(handleControlPlaneResolvePath)) + mux.HandleFunc("/v1/integrations/bind", withControlPlaneVersion(handleControlPlaneBind)) + mux.HandleFunc("/v1/integrations/bindings", withControlPlaneVersion(handleControlPlaneListBindings)) + mux.HandleFunc("/v1/integrations/unbind", withControlPlaneVersion(handleControlPlaneUnbind)) + mux.HandleFunc("/v1/integrations/writeback-secret", withControlPlaneVersion(handleControlPlaneWritebackSecret)) + return mux +} + +func withControlPlaneVersion(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := checkControlPlaneVersion(r); err != nil { + writeControlPlaneError(w, http.StatusUpgradeRequired, controlPlaneErrVersionIncompatible, err.Error()) + return + } + next(w, r) + } +} + +func handleControlPlaneHello(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var requested uint32 + if r.Method == http.MethodPost && r.Body != nil { + var req helloRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + requested = req.APIVersion + } + if requested == 0 { + requested = controlPlaneVersionFromRequest(r) + } + if requested != 0 && !controlPlaneSupportsVersion(requested) { + writeControlPlaneError( + w, + http.StatusUpgradeRequired, + controlPlaneErrVersionIncompatible, + fmt.Sprintf("relayfile daemon speaks API v%d; this client needs v%d. Upgrade relayfile (or agent-relay).", relayfileControlPlaneAPIVersion, requested), + ) + return + } + writeControlPlaneJSON(w, http.StatusOK, controlPlaneHello()) +} + +func handleControlPlaneListProviders(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + writeControlPlaneJSON(w, http.StatusOK, listProvidersResponse{Providers: fallbackIntegrationCatalog()}) +} + +func handleControlPlaneProviderStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + req := providerStatusRequest{ + Provider: r.URL.Query().Get("provider"), + Workspace: r.URL.Query().Get("workspace"), + CloudAPIURL: r.URL.Query().Get("cloudApiUrl"), + } + if r.Method == http.MethodPost { + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + } + provider := normalizeProviderID(req.Provider) + if provider == "" { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, "provider is required") + return + } + entries, err := controlPlaneListIntegrationConnections(req.Workspace, req.CloudAPIURL) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + for _, entry := range entries { + if normalizeProviderID(entry.Provider) == provider { + writeControlPlaneJSON(w, http.StatusOK, entry) + return + } + } + writeControlPlaneError(w, http.StatusNotFound, controlPlaneErrBindingNotFound, fmt.Sprintf("provider %q is not connected", provider)) +} + +func handleControlPlaneConnectProvider(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req connectProviderRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + provider := normalizeProviderID(req.Provider) + if provider == "" { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, "provider is required") + return + } + args := []string{provider} + if strings.TrimSpace(req.Workspace) != "" { + args = append(args, "--workspace", strings.TrimSpace(req.Workspace)) + } + if strings.TrimSpace(req.CloudAPIURL) != "" { + args = append(args, "--cloud-api-url", strings.TrimSpace(req.CloudAPIURL)) + } + if strings.TrimSpace(req.Backend) != "" { + args = append(args, "--backend", strings.TrimSpace(req.Backend)) + } + if req.NoOpen { + args = append(args, "--no-open") + } + if strings.TrimSpace(req.Timeout) != "" { + args = append(args, "--timeout", strings.TrimSpace(req.Timeout)) + } + if req.WaitSync { + args = append(args, "--wait-sync") + } + var out bytes.Buffer + if err := runIntegrationConnect(args, strings.NewReader(""), &out); err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, connectProviderResponse{Provider: provider, Output: out.String()}) +} + +func handleControlPlaneResolvePath(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req resolveResourcePathRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + provider := normalizeProviderID(req.Provider) + if err := validateLocalProviderID(provider); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + resolved, err := resolveIntegrationBindPathGlob(provider, req.Resource) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, resolveResourcePathResponse{ + Provider: provider, + Resource: strings.TrimSpace(req.Resource), + PathGlob: resolved.PathGlob, + ResolvedExact: resolved.Warning == "", + Warning: resolved.Warning, + }) +} + +func handleControlPlaneBind(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req bindRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + binding, replaced, warning, err := bindRelayIntegration(relayIntegrationBindInput{ + Provider: req.Provider, + Resource: req.Resource, + Channel: req.Channel, + WebhookID: req.WebhookID, + WebhookToken: req.WebhookToken, + SubscriptionID: req.SubscriptionID, + }) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, bindResponse{Binding: binding, Replaced: replaced, Warning: warning}) +} + +func handleControlPlaneListBindings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + bindings, err := readRelayIntegrationBindings() + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, listBindingsResponse{Bindings: bindings}) +} + +func handleControlPlaneUnbind(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req unbindRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + result, err := unbindRelayIntegration(req.Provider, req.Resource) + if err != nil { + writeControlPlaneMappedError(w, err) + return + } + writeControlPlaneJSON(w, http.StatusOK, result) +} + +func handleControlPlaneWritebackSecret(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") + return + } + var req writebackSecretRequest + if err := decodeControlPlaneJSON(r, &req); err != nil { + writeControlPlaneError(w, http.StatusBadRequest, controlPlaneErrInvalidArgument, err.Error()) + return + } + args := []string{"--channel", strings.TrimSpace(req.Channel), "--json"} + if strings.TrimSpace(req.Workspace) != "" { + args = append(args, "--workspace", strings.TrimSpace(req.Workspace)) + } + var out bytes.Buffer + if err := runIntegrationWritebackSecret(args, &out); err != nil { + writeControlPlaneMappedError(w, err) + return + } + var data writebackSecretData + if err := json.Unmarshal(out.Bytes(), &data); err != nil { + writeControlPlaneMappedError(w, fmt.Errorf("parse writeback secret response: %w", err)) + return + } + writeControlPlaneJSON(w, http.StatusOK, data) +} + +func controlPlaneHello() helloResponse { + return helloResponse{ + DaemonVersion: relayfileVersion, + APIVersion: relayfileControlPlaneAPIVersion, + SupportedAPIVersions: append([]uint32(nil), relayfileControlPlaneSupportedVersions...), + } +} + +func checkControlPlaneVersion(r *http.Request) error { + requested := controlPlaneVersionFromRequest(r) + if requested == 0 { + return nil + } + if !controlPlaneSupportsVersion(requested) { + return fmt.Errorf("relayfile daemon speaks API v%d; this client needs v%d. Upgrade relayfile (or agent-relay).", relayfileControlPlaneAPIVersion, requested) + } + return nil +} + +func controlPlaneVersionFromRequest(r *http.Request) uint32 { + raw := strings.TrimSpace(r.Header.Get("X-Relayfile-API-Version")) + if raw == "" { + raw = strings.TrimSpace(r.URL.Query().Get("apiVersion")) + } + if raw == "" { + return 0 + } + value, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + return ^uint32(0) + } + return uint32(value) +} + +func controlPlaneSupportsVersion(version uint32) bool { + for _, supported := range relayfileControlPlaneSupportedVersions { + if supported == version { + return true + } + } + return false +} + +func controlPlaneListIntegrationConnections(workspace, cloudAPIURL string) ([]cloudIntegrationListEntry, error) { + args := []string{"--json"} + if strings.TrimSpace(workspace) != "" { + args = append(args, "--workspace", strings.TrimSpace(workspace)) + } + if strings.TrimSpace(cloudAPIURL) != "" { + args = append(args, "--cloud-api-url", strings.TrimSpace(cloudAPIURL)) + } + var out bytes.Buffer + if err := runIntegrationList(args, &out); err != nil { + return nil, err + } + var entries []cloudIntegrationListEntry + if err := json.Unmarshal(out.Bytes(), &entries); err != nil { + return nil, fmt.Errorf("parse integration list response: %w", err) + } + return entries, nil +} + +func decodeControlPlaneJSON(r *http.Request, out any) error { + defer r.Body.Close() + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(out); err != nil { + return err + } + return nil +} + +func writeControlPlaneMappedError(w http.ResponseWriter, err error) { + message := err.Error() + status := http.StatusBadRequest + code := controlPlaneErrInvalidArgument + switch { + case strings.Contains(message, "no binding found"): + status = http.StatusNotFound + code = controlPlaneErrBindingNotFound + case strings.Contains(message, "PATH_GLOB") || strings.Contains(message, "resource"): + status = http.StatusUnprocessableEntity + code = controlPlaneErrResourceUnresolved + case strings.Contains(message, "connection refused") || strings.Contains(message, "credentials not found") || strings.Contains(message, "agent-relay"): + status = http.StatusServiceUnavailable + code = controlPlaneErrDaemonUnavailable + } + writeControlPlaneError(w, status, code, message) +} + +func writeControlPlaneError(w http.ResponseWriter, status int, code controlPlaneErrorCode, message string) { + writeControlPlaneJSON(w, status, map[string]controlPlaneError{ + "error": { + Code: code, + Message: message, + }, + }) +} + +func writeControlPlaneJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + payload, err := json.MarshalIndent(value, "", " ") + if err != nil { + _, _ = w.Write([]byte(`{"error":{"code":"DAEMON_UNAVAILABLE","message":"failed to encode response"}}` + "\n")) + return + } + payload = append(payload, '\n') + _, _ = w.Write(payload) +} + +func defaultRelayfileSocketPath() string { + if value := strings.TrimSpace(os.Getenv("RELAYFILE_SOCK")); value != "" { + return value + } + if value := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); value != "" { + return filepath.Join(value, "relayfile.sock") + } + return filepath.Join(os.TempDir(), "relayfile.sock") +} diff --git a/cmd/relayfile-cli/control_plane_test.go b/cmd/relayfile-cli/control_plane_test.go new file mode 100644 index 00000000..29e76061 --- /dev/null +++ b/cmd/relayfile-cli/control_plane_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestControlPlaneHelloVersionAndCLIVersion(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + var stdout bytes.Buffer + if err := run([]string{"--version"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("relayfile --version failed: %v", err) + } + if got := strings.TrimSpace(stdout.String()); got != "0.10.16" { + t.Fatalf("relayfile --version = %q, want 0.10.16", got) + } + + client, baseURL, cleanup := startControlPlaneTestServer(t) + defer cleanup() + + var hello helloResponse + status := controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=1", nil, &hello) + if status != http.StatusOK { + t.Fatalf("hello status = %d", status) + } + if hello.DaemonVersion != "0.10.16" || hello.APIVersion != 1 { + t.Fatalf("unexpected hello response: %#v", hello) + } + + var errResp map[string]controlPlaneError + status = controlPlaneJSONWithoutVersion(t, client, http.MethodGet, baseURL+"/v1/hello?apiVersion=2", nil, &errResp) + if status != http.StatusUpgradeRequired { + t.Fatalf("incompatible hello status = %d, want %d", status, http.StatusUpgradeRequired) + } + if errResp["error"].Code != controlPlaneErrVersionIncompatible { + t.Fatalf("unexpected incompatible error: %#v", errResp) + } +} + +func TestControlPlaneBindingConformance(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + if _, err := upsertWorkspaceDetails(workspaceRecord{Name: "demo", ID: "ws_demo", LocalDir: localDir}); err != nil { + t.Fatalf("upsert workspace failed: %v", err) + } + writeTestMountFile(t, localDir, "slack/channels/by-name/watchdog-test.json", `{"id":"C123","canonicalPath":"/slack/channels/C123__watchdog-test/meta.json"}`) + + client, baseURL, cleanup := startControlPlaneTestServer(t) + defer cleanup() + + var resolved resolveResourcePathResponse + status := controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/resolve-path", resolveResourcePathRequest{ + Provider: "slack", + Resource: "#watchdog-test", + }, &resolved) + if status != http.StatusOK { + t.Fatalf("resolve-path status = %d", status) + } + if resolved.PathGlob != "/slack/channels/C123__watchdog-test/**" || !resolved.ResolvedExact { + t.Fatalf("unexpected resolve-path response: %#v", resolved) + } + + var bound bindResponse + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/bind", bindRequest{ + Provider: "slack", + Resource: "#watchdog-test", + Channel: "#events", + WebhookID: "wh_1", + WebhookToken: "tok_1", + }, &bound) + if status != http.StatusOK { + t.Fatalf("bind status = %d", status) + } + if bound.Binding.PathGlob != resolved.PathGlob || bound.Binding.WebhookToken != "tok_1" { + t.Fatalf("unexpected bind response: %#v", bound) + } + + var listed listBindingsResponse + status = controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/bindings", nil, &listed) + if status != http.StatusOK { + t.Fatalf("bindings status = %d", status) + } + if len(listed.Bindings) != 1 || listed.Bindings[0].PathGlob != resolved.PathGlob { + t.Fatalf("unexpected bindings response: %#v", listed) + } + + var stdout bytes.Buffer + if err := run([]string{"integration", "bind", "--list", "--json"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("bind --list --json failed: %v\n%s", err, stdout.String()) + } + var cliBindings []relayIntegrationBinding + if err := json.Unmarshal(stdout.Bytes(), &cliBindings); err != nil { + t.Fatalf("parse bind --list --json output failed: %v\n%s", err, stdout.String()) + } + if len(cliBindings) != 1 || cliBindings[0].PathGlob != resolved.PathGlob { + t.Fatalf("unexpected CLI bindings: %#v", cliBindings) + } + + var unbound relayIntegrationUnbindResult + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/unbind", unbindRequest{ + Provider: "slack", + Resource: "#watchdog-test", + }, &unbound) + if status != http.StatusOK { + t.Fatalf("unbind status = %d", status) + } + if unbound.Removed != 1 || unbound.PathGlob != resolved.PathGlob { + t.Fatalf("unexpected unbind response: %#v", unbound) + } + + status = controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/bindings", nil, &listed) + if status != http.StatusOK { + t.Fatalf("bindings after unbind status = %d", status) + } + if len(listed.Bindings) != 0 { + t.Fatalf("expected empty bindings after unbind, got %#v", listed) + } +} + +func TestControlPlaneCloudIntegrationConformance(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + if err := saveWorkspaceCatalog(workspaceCatalog{ + Default: "demo", + Workspaces: []workspaceRecord{{ + Name: "demo", + ID: "ws_123", + LocalDir: localDir, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Scopes: append([]string(nil), defaultJoinScopes...), + }}, + }); err != nil { + t.Fatalf("save workspace catalog failed: %v", err) + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/workspaces/ws_123/integrations": + if r.Method != http.MethodGet { + t.Fatalf("expected integrations GET, got %s", r.Method) + } + _, _ = w.Write([]byte(`[{"provider":"github","status":"ready","connectionId":"conn_123"}]`)) + case "/v1/workspaces/ws_123/sync/status": + _, _ = w.Write([]byte(`{"workspaceId":"ws_123","providers":[{"provider":"github","status":"ready","lagSeconds":0}]}`)) + case "/api/v1/workspaces/ws_123/relayfile/delegated-token": + if r.Method != http.MethodPost { + t.Fatalf("expected delegated-token POST, got %s", r.Method) + } + writeDelegatedBundleResponse(t, w, server.URL, "ws_123", testJWTWithWorkspace("ws_123"), "refresh_join") + case "/api/v1/workspaces/ws_123/integrations/connect-session": + if r.Method != http.MethodPost { + t.Fatalf("expected connect-session POST, got %s", r.Method) + } + var body cloudConnectSessionRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode connect-session body failed: %v", err) + } + if len(body.AllowedIntegrations) != 1 || body.AllowedIntegrations[0] != "github" { + t.Fatalf("unexpected connect-session body: %#v", body) + } + _, _ = w.Write([]byte(`{"connectLink":"https://connect.test/github","connectionId":"conn_123"}`)) + case "/api/v1/workspaces/ws_123/integrations/github/status": + if r.URL.Query().Get("connectionId") != "conn_123" { + t.Fatalf("unexpected connectionId: %q", r.URL.Query().Get("connectionId")) + } + _, _ = w.Write([]byte(`{"ready":true,"provider":"github","connectionId":"conn_123","state":"ready"}`)) + case "/v1/workspaces/ws_123/integrations/relay/writeback-secret": + if r.URL.Query().Get("channel") != "#events" { + t.Fatalf("unexpected writeback-secret channel: %q", r.URL.Query().Get("channel")) + } + _, _ = w.Write([]byte(`{"ok":true,"data":{"url":"https://relay.test/writeback","secret":"sec_123"}}`)) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + installFakeAgentRelaySession(t, server.URL, "cld_test", "demo", "ws_123", "ws_123") + + client, baseURL, cleanup := startControlPlaneTestServer(t) + defer cleanup() + + var providers listProvidersResponse + status := controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/providers", nil, &providers) + if status != http.StatusOK { + t.Fatalf("providers status = %d", status) + } + if len(providers.Providers) == 0 { + t.Fatalf("expected fallback providers") + } + + var connected connectProviderResponse + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/connect", connectProviderRequest{ + Provider: "github", + Workspace: "demo", + NoOpen: true, + Timeout: "250ms", + }, &connected) + if status != http.StatusOK { + t.Fatalf("connect status = %d", status) + } + if connected.Provider != "github" || !strings.Contains(connected.Output, "github connected") { + t.Fatalf("unexpected connect response: %#v", connected) + } + + var statusEntry cloudIntegrationListEntry + status = controlPlaneJSON(t, client, http.MethodGet, baseURL+"/v1/integrations/provider-status?provider=github&workspace=demo", nil, &statusEntry) + if status != http.StatusOK { + t.Fatalf("provider-status status = %d", status) + } + if statusEntry.Provider != "github" || statusEntry.Status != "ready" { + t.Fatalf("unexpected provider status: %#v", statusEntry) + } + + var secret writebackSecretData + status = controlPlaneJSON(t, client, http.MethodPost, baseURL+"/v1/integrations/writeback-secret", writebackSecretRequest{ + Workspace: "demo", + Channel: "#events", + }, &secret) + if status != http.StatusOK { + t.Fatalf("writeback-secret status = %d", status) + } + if secret.URL != "https://relay.test/writeback" || secret.Secret != "sec_123" { + t.Fatalf("unexpected writeback secret: %#v", secret) + } +} + +func startControlPlaneTestServer(t *testing.T) (*http.Client, string, func()) { + t.Helper() + sock := filepath.Join(os.TempDir(), fmt.Sprintf("rfcp-%d-%d.sock", os.Getpid(), time.Now().UnixNano())) + t.Cleanup(func() { _ = os.Remove(sock) }) + listener, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("listen unix socket failed: %v", err) + } + server := &http.Server{Handler: newControlPlaneHandler()} + errCh := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + errCh <- err + return + } + errCh <- nil + }() + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", sock) + }, + } + client := &http.Client{Transport: transport} + cleanup := func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = server.Shutdown(ctx) + transport.CloseIdleConnections() + select { + case err := <-errCh: + if err != nil { + t.Fatalf("control-plane server failed: %v", err) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for control-plane server shutdown") + } + _ = os.Remove(sock) + } + return client, "http://relayfile.test", cleanup +} + +func controlPlaneJSON(t *testing.T, client *http.Client, method, url string, body any, out any) int { + t.Helper() + return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, true) +} + +func controlPlaneJSONWithoutVersion(t *testing.T, client *http.Client, method, url string, body any, out any) int { + t.Helper() + return controlPlaneJSONWithVersionHeader(t, client, method, url, body, out, false) +} + +func controlPlaneJSONWithVersionHeader(t *testing.T, client *http.Client, method, url string, body any, out any, sendVersion bool) int { + t.Helper() + var reader *bytes.Reader + if body == nil { + reader = bytes.NewReader(nil) + } else { + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request failed: %v", err) + } + reader = bytes.NewReader(payload) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + t.Fatalf("new request failed: %v", err) + } + if sendVersion { + req.Header.Set("X-Relayfile-API-Version", "1") + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("control-plane request failed: %v", err) + } + defer resp.Body.Close() + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + t.Fatalf("decode response failed with status %d: %v", resp.StatusCode, err) + } + } + return resp.StatusCode +} diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 2bdffce1..3079c2d3 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -43,6 +43,7 @@ import ( ) const ( + relayfileDefaultVersion = "0.10.16" defaultServerURL = "https://file.agentrelay.com" defaultCloudAPIURL = "https://agentrelay.com/cloud" defaultObserverURL = "https://agentrelay.com/observer/file" @@ -55,6 +56,8 @@ const ( defaultMountTimeout = 15 * time.Second ) +var relayfileVersion = relayfileDefaultVersion + // defaultJoinScopes are the scopes minted for every delegated-credential // workspace join. ops:read is required for writeback op-status polling // (/v1/workspaces/{id}/ops/{opId}); sync:trigger is required for @@ -520,6 +523,10 @@ func main() { } func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if wantsVersion(args) { + fmt.Fprintln(stdout, relayfileVersion) + return nil + } if wantsHelp(args) { printHelpForArgs(args, stdout) return nil @@ -576,6 +583,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return runObserver(args[1:], stdout) case "listen", "watch": return runListen(args[1:], stdout) + case "control-plane": + return runControlPlane(args[1:], stdout) case "dev": return runDev(args[1:], nil, stdout) case "help", "-h", "--help": @@ -587,6 +596,10 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } } +func wantsVersion(args []string) bool { + return len(args) == 1 && (args[0] == "--version" || args[0] == "version") +} + func wantsHelp(args []string) bool { for _, arg := range args { if arg == "-h" || arg == "--help" { @@ -654,6 +667,8 @@ func printHelpForArgs(args []string, stdout io.Writer) { fmt.Fprintln(stdout, "Usage: relayfile observer [WORKSPACE] [--no-open]") case "listen", "watch": fmt.Fprintln(stdout, "Usage: relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]") + case "control-plane": + fmt.Fprintln(stdout, "Usage: relayfile control-plane serve [--sock PATH]") case "dev": fmt.Fprintln(stdout, "Usage: relayfile dev [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD]") case "help": @@ -836,6 +851,7 @@ Usage: relayfile status [WORKSPACE] relayfile logs [WORKSPACE] relayfile observer [WORKSPACE] [--no-open] + relayfile control-plane serve [--sock PATH] Subcommands: setup Sign in, connect an integration, and mount the workspace @@ -2300,65 +2316,45 @@ func runIntegration(args []string, stdin io.Reader, stdout io.Writer) error { } } -func runIntegrationBind(args []string, stdout io.Writer) error { - fs := flag.NewFlagSet("integration bind", flag.ContinueOnError) - fs.SetOutput(io.Discard) - list := fs.Bool("list", false, "list active relay bindings as JSON") - channel := fs.String("channel", "", "relay channel to receive provider records") - webhookID := fs.String("webhook", "", "RelayCast inbound webhook id") - webhookToken := fs.String("webhook-token", "", "RelayCast inbound webhook token") - subscriptionID := fs.String("subscription", "", "relay integration subscription id") - if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ - "list": false, - "channel": true, - "webhook": true, - "webhook-token": true, - "subscription": true, - })); err != nil { - return err - } - if *list { - if fs.NArg() != 0 { - return errors.New("usage: relayfile integration bind --list") - } - bindings, err := readRelayIntegrationBindings() - if err != nil { - return err - } - return writeJSON(stdout, bindings) - } - if fs.NArg() != 2 { - return errors.New("usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") - } - provider := normalizeProviderID(fs.Arg(0)) +type relayIntegrationBindInput struct { + Provider string + Resource string + Channel string + WebhookID string + WebhookToken string + SubscriptionID string +} + +func bindRelayIntegration(input relayIntegrationBindInput) (relayIntegrationBinding, bool, string, error) { + provider := normalizeProviderID(input.Provider) if err := validateLocalProviderID(provider); err != nil { - return err + return relayIntegrationBinding{}, false, "", err } - resolved, err := resolveIntegrationBindPathGlob(provider, fs.Arg(1)) + resolved, err := resolveIntegrationBindPathGlob(provider, input.Resource) if err != nil { - return err + return relayIntegrationBinding{}, false, "", err } binding := relayIntegrationBinding{ Provider: provider, PathGlob: resolved.PathGlob, - Channel: strings.TrimSpace(*channel), - WebhookID: strings.TrimSpace(*webhookID), - WebhookToken: strings.TrimSpace(*webhookToken), - SubscriptionID: strings.TrimSpace(*subscriptionID), + Channel: strings.TrimSpace(input.Channel), + WebhookID: strings.TrimSpace(input.WebhookID), + WebhookToken: strings.TrimSpace(input.WebhookToken), + SubscriptionID: strings.TrimSpace(input.SubscriptionID), } if binding.Channel == "" { - return errors.New("--channel is required") + return relayIntegrationBinding{}, false, "", errors.New("--channel is required") } if binding.WebhookID == "" { - return errors.New("--webhook is required") + return relayIntegrationBinding{}, false, "", errors.New("--webhook is required") } if binding.WebhookToken == "" { - return errors.New("--webhook-token is required") + return relayIntegrationBinding{}, false, "", errors.New("--webhook-token is required") } now := time.Now().UTC().Format(time.RFC3339) bindings, err := readRelayIntegrationBindings() if err != nil { - return err + return relayIntegrationBinding{}, false, "", err } replaced := false for i := range bindings { @@ -2382,10 +2378,56 @@ func runIntegrationBind(args []string, stdout io.Writer) error { bindings = append(bindings, binding) } if err := writeRelayIntegrationBindings(bindings); err != nil { + return relayIntegrationBinding{}, false, "", err + } + return binding, replaced, resolved.Warning, nil +} + +func runIntegrationBind(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("integration bind", flag.ContinueOnError) + fs.SetOutput(io.Discard) + list := fs.Bool("list", false, "list active relay bindings as JSON") + _ = fs.Bool("json", false, "accepted for consistency with other JSON-emitting integration commands") + channel := fs.String("channel", "", "relay channel to receive provider records") + webhookID := fs.String("webhook", "", "RelayCast inbound webhook id") + webhookToken := fs.String("webhook-token", "", "RelayCast inbound webhook token") + subscriptionID := fs.String("subscription", "", "relay integration subscription id") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ + "list": false, + "channel": true, + "webhook": true, + "webhook-token": true, + "subscription": true, + "json": false, + })); err != nil { return err } - if resolved.Warning != "" { - fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) + if *list { + if fs.NArg() != 0 { + return errors.New("usage: relayfile integration bind --list") + } + bindings, err := readRelayIntegrationBindings() + if err != nil { + return err + } + return writeJSON(stdout, bindings) + } + if fs.NArg() != 2 { + return errors.New("usage: relayfile integration bind PROVIDER RESOURCE_OR_PATH_GLOB --channel CHANNEL --webhook ID --webhook-token TOKEN") + } + binding, replaced, warning, err := bindRelayIntegration(relayIntegrationBindInput{ + Provider: fs.Arg(0), + Resource: fs.Arg(1), + Channel: *channel, + WebhookID: *webhookID, + WebhookToken: *webhookToken, + SubscriptionID: *subscriptionID, + }) + if err != nil { + return err + } + if warning != "" { + fmt.Fprintf(stdout, "Warning: %s\n", warning) } if replaced { fmt.Fprintf(stdout, "%s binding updated: %s -> %s\n", binding.Provider, binding.PathGlob, binding.Channel) @@ -2435,48 +2477,37 @@ func runIntegrationResolvePath(args []string, stdout io.Writer) error { return nil } -func runIntegrationUnbind(args []string, stdout io.Writer) error { - fs := flag.NewFlagSet("integration unbind", flag.ContinueOnError) - fs.SetOutput(io.Discard) - resource := fs.String("resource", "", "path glob/resource to unbind") - if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ - "resource": true, - })); err != nil { - return err - } - if fs.NArg() < 1 || fs.NArg() > 2 { - return errors.New("usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") - } - provider := normalizeProviderID(fs.Arg(0)) +type relayIntegrationUnbindResult struct { + Provider string + PathGlob string + Removed int + Warning string +} + +func unbindRelayIntegration(providerValue, resourceValue string) (relayIntegrationUnbindResult, error) { + provider := normalizeProviderID(providerValue) if err := validateLocalProviderID(provider); err != nil { - return err - } - pathGlob := strings.TrimSpace(*resource) - if fs.NArg() == 2 { - if pathGlob != "" { - return errors.New("pass RESOURCE_OR_PATH_GLOB either positionally or with --resource, not both") - } - pathGlob = strings.TrimSpace(fs.Arg(1)) + return relayIntegrationUnbindResult{}, err } + pathGlob := strings.TrimSpace(resourceValue) matchGlobs := []string{} if pathGlob != "" { matchGlobs = append(matchGlobs, pathGlob) } + warning := "" nativeResource := pathGlob != "" && !strings.HasPrefix(pathGlob, "/") if nativeResource { resolved, err := resolveIntegrationBindPathGlob(provider, pathGlob) if err != nil { - return err + return relayIntegrationUnbindResult{}, err } pathGlob = resolved.PathGlob matchGlobs = append([]string{pathGlob}, fallbackUnbindPathGlobsForNativeResource(provider)...) - if resolved.Warning != "" { - fmt.Fprintf(stdout, "Warning: %s\n", resolved.Warning) - } + warning = resolved.Warning } bindings, err := readRelayIntegrationBindings() if err != nil { - return err + return relayIntegrationUnbindResult{}, err } kept := bindings[:0] removed := 0 @@ -2489,17 +2520,51 @@ func runIntegrationUnbind(args []string, stdout io.Writer) error { } if removed == 0 { if pathGlob != "" { - return fmt.Errorf("no binding found for provider %q with path glob %q", provider, pathGlob) + return relayIntegrationUnbindResult{}, fmt.Errorf("no binding found for provider %q with path glob %q", provider, pathGlob) } - return fmt.Errorf("no binding found for provider %q", provider) + return relayIntegrationUnbindResult{}, fmt.Errorf("no binding found for provider %q", provider) } if err := writeRelayIntegrationBindings(kept); err != nil { + return relayIntegrationUnbindResult{}, err + } + return relayIntegrationUnbindResult{ + Provider: provider, + PathGlob: pathGlob, + Removed: removed, + Warning: warning, + }, nil +} + +func runIntegrationUnbind(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("integration unbind", flag.ContinueOnError) + fs.SetOutput(io.Discard) + resource := fs.String("resource", "", "path glob/resource to unbind") + if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ + "resource": true, + })); err != nil { + return err + } + if fs.NArg() < 1 || fs.NArg() > 2 { + return errors.New("usage: relayfile integration unbind PROVIDER [RESOURCE_OR_PATH_GLOB|--resource RESOURCE_OR_PATH_GLOB]") + } + pathGlob := strings.TrimSpace(*resource) + if fs.NArg() == 2 { + if pathGlob != "" { + return errors.New("pass RESOURCE_OR_PATH_GLOB either positionally or with --resource, not both") + } + pathGlob = strings.TrimSpace(fs.Arg(1)) + } + result, err := unbindRelayIntegration(fs.Arg(0), pathGlob) + if err != nil { return err } - if pathGlob == "" { - fmt.Fprintf(stdout, "%s bindings removed: %d\n", provider, removed) + if result.Warning != "" { + fmt.Fprintf(stdout, "Warning: %s\n", result.Warning) + } + if result.PathGlob == "" { + fmt.Fprintf(stdout, "%s bindings removed: %d\n", result.Provider, result.Removed) } else { - fmt.Fprintf(stdout, "%s binding removed: %s\n", provider, pathGlob) + fmt.Fprintf(stdout, "%s binding removed: %s\n", result.Provider, result.PathGlob) } return nil } diff --git a/openapi/relayfile-control-plane-v1.openapi.yaml b/openapi/relayfile-control-plane-v1.openapi.yaml new file mode 100644 index 00000000..3996f0ee --- /dev/null +++ b/openapi/relayfile-control-plane-v1.openapi.yaml @@ -0,0 +1,502 @@ +openapi: 3.1.0 +info: + title: Relayfile Local Control Plane API + version: 1.0.0 + description: | + Versioned local control-plane contract for the relayfile daemon/CLI. + The service listens on a user-owned Unix domain socket + (`RELAYFILE_SOCK`, or `$XDG_RUNTIME_DIR/relayfile.sock`) and uses + `X-Relayfile-API-Version: 1` for client compatibility checks. +servers: + - url: http://relayfile.local + description: Logical HTTP origin over the relayfile Unix domain socket. +paths: + /v1/hello: + get: + operationId: hello + summary: Negotiate control-plane API compatibility + parameters: + - $ref: "#/components/parameters/ApiVersionQuery" + responses: + "200": + description: Daemon version and supported API versions + content: + application/json: + schema: + $ref: "#/components/schemas/HelloResponse" + "426": + $ref: "#/components/responses/VersionIncompatible" + post: + operationId: helloPost + summary: Negotiate control-plane API compatibility + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/HelloRequest" + responses: + "200": + description: Daemon version and supported API versions + content: + application/json: + schema: + $ref: "#/components/schemas/HelloResponse" + "426": + $ref: "#/components/responses/VersionIncompatible" + /v1/integrations/providers: + get: + operationId: listProviders + summary: List known integration providers + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + responses: + "200": + description: Provider catalog + content: + application/json: + schema: + type: object + required: [providers] + properties: + providers: + type: array + items: + $ref: "#/components/schemas/Provider" + /v1/integrations/provider-status: + get: + operationId: getProviderStatus + summary: Get connected provider status for a workspace + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + - name: provider + in: query + required: true + schema: + type: string + - name: workspace + in: query + required: false + schema: + type: string + - name: cloudApiUrl + in: query + required: false + schema: + type: string + responses: + "200": + description: Provider status + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderStatus" + "404": + $ref: "#/components/responses/NotFound" + post: + operationId: getProviderStatusPost + summary: Get connected provider status for a workspace + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderStatusRequest" + responses: + "200": + description: Provider status + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderStatus" + "404": + $ref: "#/components/responses/NotFound" + /v1/integrations/connect: + post: + operationId: connectProvider + summary: Start or reuse a provider connection + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectProviderRequest" + responses: + "200": + description: Connection command completed + content: + application/json: + schema: + $ref: "#/components/schemas/ConnectProviderResponse" + /v1/integrations/resolve-path: + post: + operationId: resolveResourcePath + summary: Resolve a provider-native resource to a canonical VFS glob + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveResourcePathRequest" + responses: + "200": + description: Resolved path glob + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveResourcePathResponse" + "422": + $ref: "#/components/responses/ResourceUnresolved" + /v1/integrations/bind: + post: + operationId: bind + summary: Upsert a relay integration binding + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BindRequest" + responses: + "200": + description: Created or updated binding + content: + application/json: + schema: + $ref: "#/components/schemas/BindResponse" + /v1/integrations/bindings: + get: + operationId: listBindings + summary: List relay integration bindings + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + responses: + "200": + description: Existing bindings without one-time webhook token redaction + content: + application/json: + schema: + $ref: "#/components/schemas/ListBindingsResponse" + /v1/integrations/unbind: + post: + operationId: unbind + summary: Remove relay integration bindings + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UnbindRequest" + responses: + "200": + description: Removed binding count + content: + application/json: + schema: + $ref: "#/components/schemas/UnbindResponse" + "404": + $ref: "#/components/responses/NotFound" + /v1/integrations/writeback-secret: + post: + operationId: getWritebackBinding + summary: Fetch the per-channel writeback URL and signing secret + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WritebackSecretRequest" + responses: + "200": + description: Writeback URL and secret + content: + application/json: + schema: + $ref: "#/components/schemas/WritebackSecret" +components: + parameters: + ApiVersionHeader: + name: X-Relayfile-API-Version + in: header + required: false + schema: + type: integer + format: uint32 + const: 1 + ApiVersionQuery: + name: apiVersion + in: query + required: false + schema: + type: integer + format: uint32 + responses: + VersionIncompatible: + description: Client and daemon API versions are incompatible + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + NotFound: + description: Requested provider or binding was not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + ResourceUnresolved: + description: Provider-native resource could not be resolved + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + schemas: + HelloRequest: + type: object + properties: + apiVersion: + type: integer + format: uint32 + HelloResponse: + type: object + required: [daemonVersion, apiVersion, supportedApiVersions] + properties: + daemonVersion: + type: string + apiVersion: + type: integer + format: uint32 + supportedApiVersions: + type: array + items: + type: integer + format: uint32 + Provider: + type: object + required: [id] + properties: + id: + type: string + displayName: + type: string + configKey: + type: string + vfsRoot: + type: string + deprecated: + type: boolean + backend: + type: string + backends: + type: array + items: + type: string + sources: + type: array + items: + type: string + authMode: + type: string + categories: + type: array + items: + type: string + docs: + type: string + ProviderStatusRequest: + type: object + required: [provider] + properties: + provider: + type: string + workspace: + type: string + cloudApiUrl: + type: string + ProviderStatus: + type: object + required: [provider] + properties: + provider: + type: string + status: + type: string + lagSeconds: + type: integer + lastEventAt: + type: string + connectionId: + type: string + webhookHealthy: + type: boolean + deprecated: + type: boolean + ConnectProviderRequest: + type: object + required: [provider] + properties: + provider: + type: string + workspace: + type: string + cloudApiUrl: + type: string + backend: + type: string + noOpen: + type: boolean + timeout: + type: string + waitSync: + type: boolean + ConnectProviderResponse: + type: object + required: [provider] + properties: + provider: + type: string + output: + type: string + ResolveResourcePathRequest: + type: object + required: [provider, resource] + properties: + provider: + type: string + resource: + type: string + ResolveResourcePathResponse: + type: object + required: [provider, resource, pathGlob, resolvedExact] + properties: + provider: + type: string + resource: + type: string + pathGlob: + type: string + pattern: "^/" + resolvedExact: + type: boolean + warning: + type: string + BindRequest: + type: object + required: [provider, resource, channel, webhookId, webhookToken] + properties: + provider: + type: string + resource: + type: string + channel: + type: string + webhookId: + type: string + webhookToken: + type: string + subscriptionId: + type: string + BindResponse: + type: object + required: [binding, replaced] + properties: + binding: + $ref: "#/components/schemas/Binding" + replaced: + type: boolean + warning: + type: string + Binding: + type: object + required: [provider, pathGlob, channel, webhookId, webhookToken] + properties: + provider: + type: string + pathGlob: + type: string + pattern: "^/" + channel: + type: string + webhookId: + type: string + webhookToken: + type: string + subscriptionId: + type: string + createdAt: + type: string + updatedAt: + type: string + ListBindingsResponse: + type: object + required: [bindings] + properties: + bindings: + type: array + items: + $ref: "#/components/schemas/Binding" + UnbindRequest: + type: object + required: [provider] + properties: + provider: + type: string + resource: + type: string + UnbindResponse: + type: object + required: [provider, pathGlob, removed] + properties: + provider: + type: string + pathGlob: + type: string + removed: + type: integer + warning: + type: string + WritebackSecretRequest: + type: object + required: [channel] + properties: + workspace: + type: string + channel: + type: string + WritebackSecret: + type: object + required: [url, secret] + properties: + url: + type: string + secret: + type: string + ErrorEnvelope: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + enum: + - INVALID_ARGUMENT + - VERSION_INCOMPATIBLE + - RESOURCE_UNRESOLVED + - BINDING_NOT_FOUND + - DAEMON_UNAVAILABLE + message: + type: string diff --git a/package-lock.json b/package-lock.json index c1a9de13..865328a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "workspaces": [ "packages/core", "packages/sdk/typescript", + "packages/client", "packages/cli", "packages/local-mount", "packages/file-observer", @@ -855,7 +856,6 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -870,8 +870,7 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", @@ -879,7 +878,6 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2359,6 +2357,52 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.16", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.16.tgz", + "integrity": "sha512-zIgmQTT2TV/U/SJ3N4jlIw36erH6X8ga1UNIoyrlbr0yLEbsiII/16LZ0kMxWu2A8pw0xd56rwTz5sMudy2OAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, "node_modules/@relaycast/sdk": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.1.0.tgz", @@ -2382,6 +2426,10 @@ "resolved": "packages/agents", "link": true }, + "node_modules/@relayfile/client": { + "resolved": "packages/client", + "link": true + }, "node_modules/@relayfile/core": { "resolved": "packages/core", "link": true @@ -3293,9 +3341,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "dev": true, "license": "MIT", "dependencies": { @@ -3496,6 +3544,16 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/agent-trajectories": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.8.tgz", @@ -3578,6 +3636,16 @@ } } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "dev": true, @@ -3617,6 +3685,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -3638,6 +3713,13 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3710,6 +3792,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3840,6 +3932,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3931,6 +4030,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -4734,6 +4840,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4761,6 +4881,19 @@ "node": ">= 4" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4885,6 +5018,16 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -4902,6 +5045,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", @@ -5542,6 +5708,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -5778,6 +5957,40 @@ } } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -5843,6 +6056,24 @@ "node": ">=8" } }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -5936,6 +6167,16 @@ "node": ">=16.20.0" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -6848,6 +7089,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -6925,6 +7179,13 @@ "node": ">= 0.8" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -7309,6 +7570,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -7766,7 +8044,7 @@ }, "packages/cli": { "name": "relayfile", - "version": "0.10.15", + "version": "0.10.16", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -7776,6 +8054,20 @@ "node": ">=18" } }, + "packages/client": { + "name": "@relayfile/client", + "version": "0.10.16", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22.20.0", + "openapi-typescript": "^7.13.0", + "typescript": "^5.7.3", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/core": { "name": "@relayfile/core", "version": "0.10.15", diff --git a/package.json b/package.json index 24b7f94e..b11fd50b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "workspaces": [ "packages/core", "packages/sdk/typescript", + "packages/client", "packages/cli", "packages/local-mount", "packages/file-observer", @@ -14,10 +15,10 @@ ], "scripts": { "workflow": "agent-relay run", - "build": "npm run build --workspace=packages/core && npm run build --workspace=packages/sdk/typescript && npm run build --workspace=packages/agents && npm run build --workspace=packages/local-mount && npm run build --workspace=packages/cli", - "test": "npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test:go", + "build": "npm run build --workspace=packages/core && npm run build --workspace=packages/sdk/typescript && npm run build --workspace=@relayfile/client && npm run build --workspace=packages/agents && npm run build --workspace=packages/local-mount && npm run build --workspace=packages/cli", + "test": "npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=@relayfile/client && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test:go", "test:go": "go test ./...", - "typecheck": "npm run typecheck --workspace=packages/sdk/typescript && npm run typecheck --workspace=packages/agents && npm run typecheck --workspace=packages/local-mount && npm run typecheck:go", + "typecheck": "npm run typecheck --workspace=packages/sdk/typescript && npm run typecheck --workspace=@relayfile/client && npm run typecheck --workspace=packages/agents && npm run typecheck --workspace=packages/local-mount && npm run typecheck:go", "typecheck:go": "go vet ./...", "evals:compile": "node scripts/evals/compile-cases.mjs", "evals": "npm run evals:compile && node scripts/evals/run-relayfile-evals.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index 35546104..7783141e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "relayfile", - "version": "0.10.15", + "version": "0.10.16", "description": "CLI for relayfile — real-time filesystem for humans and agents", "bin": { "relayfile": "scripts/run.js" diff --git a/packages/cli/scripts/build-binaries.js b/packages/cli/scripts/build-binaries.js index 6546e36e..55c1c0d1 100644 --- a/packages/cli/scripts/build-binaries.js +++ b/packages/cli/scripts/build-binaries.js @@ -7,6 +7,7 @@ const path = require("path"); const repoRoot = path.resolve(__dirname, "..", "..", ".."); const packageRoot = path.resolve(__dirname, ".."); const binDir = path.join(packageRoot, "bin"); +const version = require(path.join(packageRoot, "package.json")).version; const targets = [ { goos: "darwin", goarch: "amd64" }, @@ -29,7 +30,7 @@ for (const target of targets) { [ "build", "-trimpath", - "-ldflags=-s -w", + `-ldflags=-s -w -X main.relayfileVersion=${version}`, "-o", output, "./cmd/relayfile-cli", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md new file mode 100644 index 00000000..3b9cbee7 --- /dev/null +++ b/packages/client/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this package will be documented in this file. + +## [Unreleased] + +- Added the initial typed relayfile control-plane client. + +[Unreleased]: https://github.com/AgentWorkforce/relayfile/compare/v0.10.15...HEAD diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 00000000..4c565ce5 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,52 @@ +{ + "name": "@relayfile/client", + "version": "0.10.16", + "description": "Typed client for the relayfile local control-plane (unix-socket HTTP/JSON), generated from the authoritative OpenAPI contract", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist" + ], + "scripts": { + "codegen": "openapi-typescript ../../openapi/relayfile-control-plane-v1.openapi.yaml -o src/generated/control-plane.ts", + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "openapi-typescript": "^7.13.0", + "typescript": "^5.7.3", + "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/relayfile", + "directory": "packages/client" + }, + "license": "Apache-2.0", + "keywords": [ + "relayfile", + "control-plane", + "client", + "integrations", + "agent" + ], + "engines": { + "node": ">=18" + } +} diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts new file mode 100644 index 00000000..b6ae73d2 --- /dev/null +++ b/packages/client/src/client.test.ts @@ -0,0 +1,162 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + MIN_RELAYFILE_VERSION, + RELAYFILE_API_VERSION, + RelayfileControlPlaneClient, + RelayfileControlPlaneError, + assertRelayfileVersion, + defaultRelayfileSocketPath, +} from './client.js'; + +describe('assertRelayfileVersion', () => { + it('accepts the minimum and newer, tolerates v-prefix/words', () => { + expect(() => assertRelayfileVersion(MIN_RELAYFILE_VERSION)).not.toThrow(); + expect(() => assertRelayfileVersion('v0.99.0')).not.toThrow(); + expect(() => assertRelayfileVersion('relayfile 1.2.3 (abc)')).not.toThrow(); + }); + it('rejects older + unparseable', () => { + expect(() => assertRelayfileVersion('0.10.15')).toThrow(/0\.10\.16 is required/); + expect(() => assertRelayfileVersion('nope')).toThrow(/unparseable version/); + }); +}); + +describe('defaultRelayfileSocketPath', () => { + const saved = { sock: process.env.RELAYFILE_SOCK, xdg: process.env.XDG_RUNTIME_DIR }; + afterEach(() => { + if (saved.sock === undefined) delete process.env.RELAYFILE_SOCK; + else process.env.RELAYFILE_SOCK = saved.sock; + if (saved.xdg === undefined) delete process.env.XDG_RUNTIME_DIR; + else process.env.XDG_RUNTIME_DIR = saved.xdg; + }); + it('prefers RELAYFILE_SOCK, then XDG, then tmpdir', () => { + process.env.RELAYFILE_SOCK = '/run/custom.sock'; + expect(defaultRelayfileSocketPath()).toBe('/run/custom.sock'); + delete process.env.RELAYFILE_SOCK; + process.env.XDG_RUNTIME_DIR = '/run/user/1000'; + expect(defaultRelayfileSocketPath()).toBe('/run/user/1000/relayfile.sock'); + delete process.env.XDG_RUNTIME_DIR; + expect(defaultRelayfileSocketPath()).toBe(join(tmpdir(), 'relayfile.sock')); + }); +}); + +describe('RelayfileControlPlaneClient lifecycle', () => { + it('require-daemon mode (autoStart:false) fails fast with an actionable error', async () => { + const client = new RelayfileControlPlaneClient({ + socketPath: join(tmpdir(), `rf-absent-${process.pid}.sock`), + autoStart: false, + }); + await expect(client.ensureReady()).rejects.toMatchObject({ code: 'DAEMON_UNAVAILABLE' }); + await expect(client.ensureReady()).rejects.toThrow(/control-plane serve|not running/); + }); + + it('rejects a daemon whose supportedApiVersions excludes this client', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + vi.spyOn(client, 'hello').mockResolvedValue({ + daemonVersion: '0.10.16', + apiVersion: 2, + supportedApiVersions: [2], + }); + await expect(client.ensureReady()).rejects.toMatchObject({ code: 'VERSION_INCOMPATIBLE' }); + }); + + it('rejects a daemon older than the minimum version', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + vi.spyOn(client, 'hello').mockResolvedValue({ + daemonVersion: '0.10.15', + apiVersion: RELAYFILE_API_VERSION, + supportedApiVersions: [RELAYFILE_API_VERSION], + }); + await expect(client.ensureReady()).rejects.toThrow(/0\.10\.16 is required/); + }); + + it('does not cache a failed readiness probe (retries next call)', async () => { + const client = new RelayfileControlPlaneClient({ socketPath: '/nope.sock', autoStart: false }); + const hello = vi + .spyOn(client, 'hello') + .mockRejectedValueOnce(new RelayfileControlPlaneError('DAEMON_UNAVAILABLE', 'down')) + .mockResolvedValue({ + daemonVersion: '0.10.16', + apiVersion: RELAYFILE_API_VERSION, + supportedApiVersions: [RELAYFILE_API_VERSION], + }); + await expect(client.ensureReady()).rejects.toMatchObject({ code: 'DAEMON_UNAVAILABLE' }); + await expect(client.ensureReady()).resolves.toBeUndefined(); + expect(hello).toHaveBeenCalledTimes(2); + }); +}); + +// Real-daemon contract tests — opt-in via RELAYFILE_BIN (CI builds the binary: +// `go build -o relayfile ./cmd/relayfile-cli`). Boots the daemon and drives the +// client over the socket. +const RELAYFILE_BIN = process.env.RELAYFILE_BIN?.trim(); +const describeContract = RELAYFILE_BIN ? describe : describe.skip; + +describeContract('control-plane client (real daemon)', () => { + const sock = join(tmpdir(), `rf-client-${process.pid}.sock`); + let home: string; + let daemon: ChildProcess; + const client = new RelayfileControlPlaneClient({ socketPath: sock, autoStart: false }); + + beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), 'relayfile-client-')); + daemon = spawn(RELAYFILE_BIN!, ['control-plane', 'serve', '--sock', sock], { + env: { ...process.env, HOME: home, RELAYFILE_SOCK: sock }, + stdio: 'ignore', + }); + const deadline = Date.now() + 5000; + for (;;) { + try { + await client.hello(); + return; + } catch (err) { + if (Date.now() > deadline) throw err; + await new Promise((r) => setTimeout(r, 100)); + } + } + }); + + afterAll(() => { + daemon?.kill('SIGTERM'); + rmSync(sock, { force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + beforeEach(() => undefined); + + it('hello() negotiates daemon version + api version', async () => { + const hello = await client.hello(); + expect(hello.supportedApiVersions).toContain(RELAYFILE_API_VERSION); + expect(() => assertRelayfileVersion(hello.daemonVersion)).not.toThrow(); + }); + + it('resolvePath() maps native -> glob and is idempotent', async () => { + expect((await client.resolvePath('github', 'owner/repo')).pathGlob).toBe('/github/repos/owner/repo/**'); + expect((await client.resolvePath('github', '/github/repos/owner/repo/**')).pathGlob).toBe( + '/github/repos/owner/repo/**' + ); + }); + + it('bind -> listBindings -> unbind round-trips on the resolved glob', async () => { + const { pathGlob } = await client.resolvePath('github', 'acme/widgets'); + await client.bind({ + provider: 'github', + resource: pathGlob, + channel: 'general', + webhookId: 'wh', + webhookToken: 'tok', + subscriptionId: 'sub', + }); + const after = await client.listBindings(); + const binding = after.find((b) => b.pathGlob === pathGlob); + expect(binding).toBeDefined(); + expect(binding!.channel).toBe('general'); + await client.unbind('github', pathGlob); + expect((await client.listBindings()).find((b) => b.pathGlob === pathGlob)).toBeUndefined(); + }); +}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 00000000..176286df --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,351 @@ +import { spawn } from 'node:child_process'; +import { request } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { components } from './generated/control-plane.js'; + +/** + * Typed client for the relayfile control-plane: HTTP/JSON over a local unix + * domain socket (`relayfile control-plane serve`). It replaces shelling out to + * the `relayfile` CLI and parsing stdout — the contract is now the socket's + * request/response shapes, version-negotiated via `/v1/hello`. + * + * Transport is Node's built-in `http.request({ socketPath })` — no dependency, + * unix-socket support is native. Request/response types are generated from the + * authoritative `openapi/relayfile-control-plane-v1.openapi.yaml`, so a field + * rename in the contract is a compile error here. + */ + +/** Control-plane API version this client speaks. Bump on a breaking wire change. */ +export const RELAYFILE_API_VERSION = 1; + +/** + * Minimum `relayfile` daemon version this client requires. The control-plane + * first shipped in 0.10.16, so anything that answers `/v1/hello` is already >= + * this — the check is belt-and-suspenders and the bump point for future + * contract changes. + */ +export const MIN_RELAYFILE_VERSION = '0.10.16'; + +const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].*)?$/; + +/** First semver-looking token in a string (tolerates a leading `v` and surrounding words). */ +export function firstSemver(value: string): string { + for (const raw of value.split(/\s+/)) { + const token = raw.replace(/^v/, ''); + if (SEMVER_RE.test(token)) return token; + } + return ''; +} + +/** Compare two semvers by numeric core; -1 / 0 / 1. Pre-release/build metadata is ignored. */ +export function compareSemver(a: string, b: string): number { + const core = (v: string) => v.split(/[-+]/)[0]!.split('.').map((n) => Number.parseInt(n, 10) || 0); + const [a0, a1, a2] = core(a); + const [b0, b1, b2] = core(b); + return (a0! - b0!) || (a1! - b1!) || (a2! - b2!); +} + +/** + * Pure version gate: throws an actionable error unless `version` (a daemon + * version string) is a semver >= MIN_RELAYFILE_VERSION. Exported so the contract + * it guards is unit-tested without a running daemon. + */ +export function assertRelayfileVersion(version: string): void { + const parsed = firstSemver(version); + if (!parsed || compareSemver(parsed, MIN_RELAYFILE_VERSION) < 0) { + throw new Error( + `relayfile >= ${MIN_RELAYFILE_VERSION} is required; found ${ + parsed ? `"${parsed}"` : `unparseable version "${version.trim()}"` + }. Update relayfile (npm install -g relayfile@latest) or set RELAYFILE_BIN.` + ); + } +} + +/** Resolve the control-plane socket path, mirroring relayfile's Go default. */ +export function defaultRelayfileSocketPath(): string { + const explicit = process.env.RELAYFILE_SOCK?.trim(); + if (explicit) return explicit; + const xdg = process.env.XDG_RUNTIME_DIR?.trim(); + if (xdg) return join(xdg, 'relayfile.sock'); + return join(tmpdir(), 'relayfile.sock'); +} + +function relayfileBinary(): string { + return process.env.RELAYFILE_BIN?.trim() || 'relayfile'; +} + +/** Structured control-plane error carrying the daemon's typed code + HTTP status. */ +export class RelayfileControlPlaneError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly status?: number + ) { + super(message); + this.name = 'RelayfileControlPlaneError'; + } +} + +// Request/response types are DERIVED from the control-plane OpenAPI contract +// (generated/control-plane.ts, from openapi/relayfile-control-plane-v1.openapi.yaml +// via `npm run codegen`). A field rename in the contract becomes a compile error +// here — the compile-time field-drift guarantee. +type Schemas = components['schemas']; + +export type HelloResponse = Schemas['HelloResponse']; +export type RelayfileBindingRecord = Schemas['Binding']; +export type ResolvePathResult = Schemas['ResolveResourcePathResponse']; +export type BindResult = Schemas['BindResponse']; +export type BindRequestBody = Schemas['BindRequest']; +export type ConnectRequestBody = Schemas['ConnectProviderRequest']; +export type ConnectResult = Schemas['ConnectProviderResponse']; +export type ProviderStatusResult = Schemas['ProviderStatus']; +export type WritebackSecretResult = Schemas['WritebackSecret']; + +export interface RelayfileClientOptions { + /** Socket to connect to. Defaults to defaultRelayfileSocketPath(). */ + socketPath?: string; + /** Binary used to auto-start the daemon. Defaults to RELAYFILE_BIN or `relayfile`. */ + binary?: string; + /** + * Auto-start `relayfile control-plane serve` when the socket is absent. + * Defaults to true unless RELAYFILE_REQUIRE_DAEMON=1 (strict, never-spawn mode). + */ + autoStart?: boolean; + /** How long to wait for an auto-started daemon to answer /v1/hello. */ + startTimeoutMs?: number; +} + +interface RequestOptions { + method: 'GET' | 'POST'; + path: string; + query?: Record; + body?: unknown; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class RelayfileControlPlaneClient { + private readonly socketPath: string; + private readonly binary: string; + private readonly autoStart: boolean; + private readonly startTimeoutMs: number; + private ready: Promise | undefined; + + constructor(options: RelayfileClientOptions = {}) { + this.socketPath = options.socketPath ?? defaultRelayfileSocketPath(); + this.binary = options.binary ?? relayfileBinary(); + this.autoStart = options.autoStart ?? process.env.RELAYFILE_REQUIRE_DAEMON !== '1'; + this.startTimeoutMs = options.startTimeoutMs ?? 5000; + } + + /** Low-level request over the unix socket. Throws RelayfileControlPlaneError. */ + private rawRequest(opts: RequestOptions): Promise { + const query = opts.query + ? Object.entries(opts.query) + .filter(([, v]) => v != null && v !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') + : ''; + const path = query ? `${opts.path}?${query}` : opts.path; + const payload = opts.body == null ? undefined : JSON.stringify(opts.body); + + return new Promise((resolve, reject) => { + const req = request( + { + socketPath: this.socketPath, + method: opts.method, + path, + headers: { + 'X-Relayfile-API-Version': String(RELAYFILE_API_VERSION), + Accept: 'application/json', + ...(payload + ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } + : {}), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c as Buffer)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const status = res.statusCode ?? 0; + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + reject( + new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `relayfile control-plane returned non-JSON (status ${status}): ${text.slice(0, 200)}`, + status + ) + ); + return; + } + if (status >= 200 && status < 300) { + resolve(parsed as T); + return; + } + const err = (parsed as { error?: { code?: string; message?: string } })?.error; + reject( + new RelayfileControlPlaneError( + err?.code ?? 'DAEMON_UNAVAILABLE', + err?.message ?? `relayfile control-plane error (status ${status})`, + status + ) + ); + }); + } + ); + req.on('error', (err: NodeJS.ErrnoException) => { + // ENOENT (no socket file) / ECONNREFUSED (nothing listening) => daemon down. + reject( + new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + err.code === 'ENOENT' || err.code === 'ECONNREFUSED' + ? `relayfile control-plane is not running at ${this.socketPath} (${err.code})` + : err.message, + undefined + ) + ); + }); + if (payload) req.write(payload); + req.end(); + }); + } + + /** Connect (auto-starting the daemon if configured), version-negotiating once. */ + async ensureReady(): Promise { + if (!this.ready) { + this.ready = this.connectAndNegotiate().catch((err) => { + // Don't cache a transient failure — let the next call retry. + this.ready = undefined; + throw err; + }); + } + return this.ready; + } + + private async connectAndNegotiate(): Promise { + let hello: HelloResponse; + try { + hello = await this.hello(); + } catch (err) { + if (!(err instanceof RelayfileControlPlaneError) || err.code !== 'DAEMON_UNAVAILABLE') throw err; + if (!this.autoStart) { + throw new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `relayfile control-plane is not running at ${this.socketPath}. ` + + `Start it with \`relayfile control-plane serve\` (or unset RELAYFILE_REQUIRE_DAEMON to auto-start).` + ); + } + hello = await this.startDaemonAndConnect(); + } + if (!hello.supportedApiVersions?.includes(RELAYFILE_API_VERSION)) { + throw new RelayfileControlPlaneError( + 'VERSION_INCOMPATIBLE', + `relayfile daemon speaks API v${hello.apiVersion} (supports ${ + hello.supportedApiVersions?.join(', ') || 'none' + }); this client needs v${RELAYFILE_API_VERSION}. Upgrade relayfile (or agent-relay).` + ); + } + assertRelayfileVersion(hello.daemonVersion); + } + + private async startDaemonAndConnect(): Promise { + let child; + try { + child = spawn(this.binary, ['control-plane', 'serve', '--sock', this.socketPath], { + detached: true, + stdio: 'ignore', + }); + } catch (err) { + throw new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `failed to start relayfile control-plane (${err instanceof Error ? err.message : String(err)}). ` + + `Install relayfile or set RELAYFILE_BIN.` + ); + } + child.unref?.(); + const deadline = Date.now() + this.startTimeoutMs; + let lastErr: unknown; + while (Date.now() < deadline) { + await sleep(100); + try { + return await this.hello(); + } catch (err) { + lastErr = err; + } + } + throw new RelayfileControlPlaneError( + 'DAEMON_UNAVAILABLE', + `relayfile control-plane did not become ready within ${this.startTimeoutMs}ms ` + + `(${lastErr instanceof Error ? lastErr.message : String(lastErr)}).` + ); + } + + // ── endpoints ────────────────────────────────────────────────────────────── + + hello(): Promise { + return this.rawRequest({ method: 'GET', path: '/v1/hello' }); + } + + resolvePath(provider: string, resource: string): Promise { + return this.request({ + method: 'POST', + path: '/v1/integrations/resolve-path', + body: { provider, resource }, + }); + } + + bind(input: BindRequestBody): Promise { + return this.request({ method: 'POST', path: '/v1/integrations/bind', body: input }); + } + + async listBindings(): Promise { + const res = await this.request<{ bindings?: RelayfileBindingRecord[] }>({ + method: 'GET', + path: '/v1/integrations/bindings', + }); + return res.bindings ?? []; + } + + unbind(provider: string, resource: string): Promise { + return this.request({ method: 'POST', path: '/v1/integrations/unbind', body: { provider, resource } }); + } + + /** Returns the connection status, or null when the provider is not connected. */ + async providerStatus(provider: string): Promise { + try { + return await this.request({ + method: 'GET', + path: '/v1/integrations/provider-status', + query: { provider }, + }); + } catch (err) { + if (err instanceof RelayfileControlPlaneError && err.status === 404) return null; + throw err; + } + } + + connect(input: ConnectRequestBody): Promise { + return this.request({ method: 'POST', path: '/v1/integrations/connect', body: input }); + } + + writebackSecret(channel: string, workspace?: string): Promise { + return this.request({ + method: 'POST', + path: '/v1/integrations/writeback-secret', + body: { channel, ...(workspace ? { workspace } : {}) }, + }); + } + + /** Endpoint request that ensures the daemon is up + version-compatible first. */ + private async request(opts: RequestOptions): Promise { + await this.ensureReady(); + return this.rawRequest(opts); + } +} diff --git a/packages/client/src/generated/control-plane.ts b/packages/client/src/generated/control-plane.ts new file mode 100644 index 00000000..9885fbcd --- /dev/null +++ b/packages/client/src/generated/control-plane.ts @@ -0,0 +1,598 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v1/hello": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Negotiate control-plane API compatibility */ + get: operations["hello"]; + put?: never; + /** Negotiate control-plane API compatibility */ + post: operations["helloPost"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List known integration providers */ + get: operations["listProviders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/provider-status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get connected provider status for a workspace */ + get: operations["getProviderStatus"]; + put?: never; + /** Get connected provider status for a workspace */ + post: operations["getProviderStatusPost"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/connect": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start or reuse a provider connection */ + post: operations["connectProvider"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/resolve-path": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve a provider-native resource to a canonical VFS glob */ + post: operations["resolveResourcePath"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/bind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upsert a relay integration binding */ + post: operations["bind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/bindings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List relay integration bindings */ + get: operations["listBindings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/unbind": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Remove relay integration bindings */ + post: operations["unbind"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/integrations/writeback-secret": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Fetch the per-channel writeback URL and signing secret */ + post: operations["getWritebackBinding"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + HelloRequest: { + /** Format: uint32 */ + apiVersion?: number; + }; + HelloResponse: { + daemonVersion: string; + /** Format: uint32 */ + apiVersion: number; + supportedApiVersions: number[]; + }; + Provider: { + id: string; + displayName?: string; + configKey?: string; + vfsRoot?: string; + deprecated?: boolean; + backend?: string; + backends?: string[]; + sources?: string[]; + authMode?: string; + categories?: string[]; + docs?: string; + }; + ProviderStatusRequest: { + provider: string; + workspace?: string; + cloudApiUrl?: string; + }; + ProviderStatus: { + provider: string; + status?: string; + lagSeconds?: number; + lastEventAt?: string; + connectionId?: string; + webhookHealthy?: boolean; + deprecated?: boolean; + }; + ConnectProviderRequest: { + provider: string; + workspace?: string; + cloudApiUrl?: string; + backend?: string; + noOpen?: boolean; + timeout?: string; + waitSync?: boolean; + }; + ConnectProviderResponse: { + provider: string; + output?: string; + }; + ResolveResourcePathRequest: { + provider: string; + resource: string; + }; + ResolveResourcePathResponse: { + provider: string; + resource: string; + pathGlob: string; + resolvedExact: boolean; + warning?: string; + }; + BindRequest: { + provider: string; + resource: string; + channel: string; + webhookId: string; + webhookToken: string; + subscriptionId?: string; + }; + BindResponse: { + binding: components["schemas"]["Binding"]; + replaced: boolean; + warning?: string; + }; + Binding: { + provider: string; + pathGlob: string; + channel: string; + webhookId: string; + webhookToken: string; + subscriptionId?: string; + createdAt?: string; + updatedAt?: string; + }; + ListBindingsResponse: { + bindings: components["schemas"]["Binding"][]; + }; + UnbindRequest: { + provider: string; + resource?: string; + }; + UnbindResponse: { + provider: string; + pathGlob: string; + removed: number; + warning?: string; + }; + WritebackSecretRequest: { + workspace?: string; + channel: string; + }; + WritebackSecret: { + url: string; + secret: string; + }; + ErrorEnvelope: { + error: { + /** @enum {string} */ + code: "INVALID_ARGUMENT" | "VERSION_INCOMPATIBLE" | "RESOURCE_UNRESOLVED" | "BINDING_NOT_FOUND" | "DAEMON_UNAVAILABLE"; + message: string; + }; + }; + }; + responses: { + /** @description Client and daemon API versions are incompatible */ + VersionIncompatible: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Requested provider or binding was not found */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Provider-native resource could not be resolved */ + ResourceUnresolved: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + parameters: { + ApiVersionHeader: 1; + ApiVersionQuery: number; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + hello: { + parameters: { + query?: { + apiVersion?: components["parameters"]["ApiVersionQuery"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Daemon version and supported API versions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HelloResponse"]; + }; + }; + 426: components["responses"]["VersionIncompatible"]; + }; + }; + helloPost: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["HelloRequest"]; + }; + }; + responses: { + /** @description Daemon version and supported API versions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HelloResponse"]; + }; + }; + 426: components["responses"]["VersionIncompatible"]; + }; + }; + listProviders: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Provider catalog */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + providers: components["schemas"]["Provider"][]; + }; + }; + }; + }; + }; + getProviderStatus: { + parameters: { + query: { + provider: string; + workspace?: string; + cloudApiUrl?: string; + }; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Provider status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderStatus"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + getProviderStatusPost: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProviderStatusRequest"]; + }; + }; + responses: { + /** @description Provider status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProviderStatus"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + connectProvider: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConnectProviderRequest"]; + }; + }; + responses: { + /** @description Connection command completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConnectProviderResponse"]; + }; + }; + }; + }; + resolveResourcePath: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResolveResourcePathRequest"]; + }; + }; + responses: { + /** @description Resolved path glob */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResolveResourcePathResponse"]; + }; + }; + 422: components["responses"]["ResourceUnresolved"]; + }; + }; + bind: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BindRequest"]; + }; + }; + responses: { + /** @description Created or updated binding */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BindResponse"]; + }; + }; + }; + }; + listBindings: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Existing bindings without one-time webhook token redaction */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListBindingsResponse"]; + }; + }; + }; + }; + unbind: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UnbindRequest"]; + }; + }; + responses: { + /** @description Removed binding count */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnbindResponse"]; + }; + }; + 404: components["responses"]["NotFound"]; + }; + }; + getWritebackBinding: { + parameters: { + query?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WritebackSecretRequest"]; + }; + }; + responses: { + /** @description Writeback URL and secret */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WritebackSecret"]; + }; + }; + }; + }; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 00000000..805c9b3a --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,25 @@ +export { + RelayfileControlPlaneClient, + RelayfileControlPlaneError, + RELAYFILE_API_VERSION, + MIN_RELAYFILE_VERSION, + assertRelayfileVersion, + compareSemver, + firstSemver, + defaultRelayfileSocketPath, +} from './client.js'; + +export type { + RelayfileClientOptions, + HelloResponse, + RelayfileBindingRecord, + ResolvePathResult, + BindResult, + BindRequestBody, + ConnectRequestBody, + ConnectResult, + ProviderStatusResult, + WritebackSecretResult, +} from './client.js'; + +export type { components, paths, operations } from './generated/control-plane.js'; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 00000000..2809ac29 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 00000000..6ec74eee --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/scripts/finalize-changelogs.mjs b/scripts/finalize-changelogs.mjs index 482069ac..27cbb6ed 100644 --- a/scripts/finalize-changelogs.mjs +++ b/scripts/finalize-changelogs.mjs @@ -22,6 +22,7 @@ const repoSlug = process.env.GITHUB_REPOSITORY || 'AgentWorkforce/relayfile'; const changelogs = [ 'packages/core/CHANGELOG.md', 'packages/sdk/typescript/CHANGELOG.md', + 'packages/client/CHANGELOG.md', 'packages/cli/CHANGELOG.md', 'packages/file-observer/CHANGELOG.md', 'packages/local-mount/CHANGELOG.md', From 962ea6b982a570af384327f8d217b22cec56da27 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 29 Jun 2026 16:14:44 -0700 Subject: [PATCH 4/4] Harden control plane feedback paths --- cmd/relayfile-cli/control_plane.go | 21 ++++++++++++------- .../control_plane_socket_unix.go | 14 +++++++++++++ .../control_plane_socket_windows.go | 9 ++++++++ cmd/relayfile-cli/main.go | 14 ++++++++++++- .../relayfile-control-plane-v1.openapi.yaml | 3 +++ packages/cli/CHANGELOG.md | 1 + .../client/src/generated/control-plane.ts | 8 +++++-- 7 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 cmd/relayfile-cli/control_plane_socket_unix.go create mode 100644 cmd/relayfile-cli/control_plane_socket_windows.go diff --git a/cmd/relayfile-cli/control_plane.go b/cmd/relayfile-cli/control_plane.go index f2bf1949..0c094e71 100644 --- a/cmd/relayfile-cli/control_plane.go +++ b/cmd/relayfile-cli/control_plane.go @@ -8,7 +8,6 @@ import ( "flag" "fmt" "io" - "net" "net/http" "os" "os/signal" @@ -153,7 +152,7 @@ func serveControlPlaneSocket(sock string, stdout io.Writer) error { return err } _ = os.Remove(sock) - listener, err := net.Listen("unix", sock) + listener, err := listenControlPlaneSocket(sock) if err != nil { return err } @@ -163,7 +162,13 @@ func serveControlPlaneSocket(sock string, stdout io.Writer) error { return err } - server := &http.Server{Handler: newControlPlaneHandler()} + server := &http.Server{ + Handler: newControlPlaneHandler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } errCh := make(chan error, 1) go func() { if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -391,7 +396,7 @@ func handleControlPlaneListBindings(w http.ResponseWriter, r *http.Request) { writeControlPlaneError(w, http.StatusMethodNotAllowed, controlPlaneErrInvalidArgument, "method not allowed") return } - bindings, err := readRelayIntegrationBindings() + bindings, err := listRelayIntegrationBindings() if err != nil { writeControlPlaneMappedError(w, err) return @@ -544,14 +549,14 @@ func writeControlPlaneError(w http.ResponseWriter, status int, code controlPlane } func writeControlPlaneJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) payload, err := json.MarshalIndent(value, "", " ") if err != nil { - _, _ = w.Write([]byte(`{"error":{"code":"DAEMON_UNAVAILABLE","message":"failed to encode response"}}` + "\n")) - return + status = http.StatusInternalServerError + payload = []byte(`{"error":{"code":"DAEMON_UNAVAILABLE","message":"failed to encode response"}}`) } payload = append(payload, '\n') + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) _, _ = w.Write(payload) } diff --git a/cmd/relayfile-cli/control_plane_socket_unix.go b/cmd/relayfile-cli/control_plane_socket_unix.go new file mode 100644 index 00000000..28c146b1 --- /dev/null +++ b/cmd/relayfile-cli/control_plane_socket_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main + +import ( + "net" + "syscall" +) + +func listenControlPlaneSocket(sock string) (net.Listener, error) { + oldUmask := syscall.Umask(0o177) + defer syscall.Umask(oldUmask) + return net.Listen("unix", sock) +} diff --git a/cmd/relayfile-cli/control_plane_socket_windows.go b/cmd/relayfile-cli/control_plane_socket_windows.go new file mode 100644 index 00000000..0f06e823 --- /dev/null +++ b/cmd/relayfile-cli/control_plane_socket_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "net" + +func listenControlPlaneSocket(sock string) (net.Listener, error) { + return net.Listen("unix", sock) +} diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 3079c2d3..9a8590b0 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -58,6 +58,8 @@ const ( var relayfileVersion = relayfileDefaultVersion +var relayIntegrationBindingsMu sync.Mutex + // defaultJoinScopes are the scopes minted for every delegated-credential // workspace join. ops:read is required for writeback op-status polling // (/v1/workspaces/{id}/ops/{opId}); sync:trigger is required for @@ -2351,6 +2353,8 @@ func bindRelayIntegration(input relayIntegrationBindInput) (relayIntegrationBind if binding.WebhookToken == "" { return relayIntegrationBinding{}, false, "", errors.New("--webhook-token is required") } + relayIntegrationBindingsMu.Lock() + defer relayIntegrationBindingsMu.Unlock() now := time.Now().UTC().Format(time.RFC3339) bindings, err := readRelayIntegrationBindings() if err != nil { @@ -2406,7 +2410,7 @@ func runIntegrationBind(args []string, stdout io.Writer) error { if fs.NArg() != 0 { return errors.New("usage: relayfile integration bind --list") } - bindings, err := readRelayIntegrationBindings() + bindings, err := listRelayIntegrationBindings() if err != nil { return err } @@ -2505,6 +2509,8 @@ func unbindRelayIntegration(providerValue, resourceValue string) (relayIntegrati matchGlobs = append([]string{pathGlob}, fallbackUnbindPathGlobsForNativeResource(provider)...) warning = resolved.Warning } + relayIntegrationBindingsMu.Lock() + defer relayIntegrationBindingsMu.Unlock() bindings, err := readRelayIntegrationBindings() if err != nil { return relayIntegrationUnbindResult{}, err @@ -7894,6 +7900,12 @@ func relayIntegrationBindingsPath() string { return filepath.Join(configDir(), "bindings.json") } +func listRelayIntegrationBindings() ([]relayIntegrationBinding, error) { + relayIntegrationBindingsMu.Lock() + defer relayIntegrationBindingsMu.Unlock() + return readRelayIntegrationBindings() +} + func readRelayIntegrationBindings() ([]relayIntegrationBinding, error) { payload, err := os.ReadFile(relayIntegrationBindingsPath()) if err != nil { diff --git a/openapi/relayfile-control-plane-v1.openapi.yaml b/openapi/relayfile-control-plane-v1.openapi.yaml index 3996f0ee..65f5aac8 100644 --- a/openapi/relayfile-control-plane-v1.openapi.yaml +++ b/openapi/relayfile-control-plane-v1.openapi.yaml @@ -16,6 +16,7 @@ paths: operationId: hello summary: Negotiate control-plane API compatibility parameters: + - $ref: "#/components/parameters/ApiVersionHeader" - $ref: "#/components/parameters/ApiVersionQuery" responses: "200": @@ -29,6 +30,8 @@ paths: post: operationId: helloPost summary: Negotiate control-plane API compatibility + parameters: + - $ref: "#/components/parameters/ApiVersionHeader" requestBody: required: false content: diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index b179263a..73e2c23c 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `relayfile integration bind` now accepts provider-native resources for Slack channels, GitHub repositories, Linear teams, and Telegram chats, resolving them to matching VFS path globs while preserving explicit `/`-prefixed globs. +- Hardened the local control-plane daemon with serialized binding updates, socket permission guarding, explicit HTTP timeouts, and an OpenAPI-documented API-version header on `/v1/hello`. ## [0.10.15] - 2026-06-28 diff --git a/packages/client/src/generated/control-plane.ts b/packages/client/src/generated/control-plane.ts index 9885fbcd..447bb810 100644 --- a/packages/client/src/generated/control-plane.ts +++ b/packages/client/src/generated/control-plane.ts @@ -320,7 +320,9 @@ export interface operations { query?: { apiVersion?: components["parameters"]["ApiVersionQuery"]; }; - header?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; path?: never; cookie?: never; }; @@ -341,7 +343,9 @@ export interface operations { helloPost: { parameters: { query?: never; - header?: never; + header?: { + "X-Relayfile-API-Version"?: components["parameters"]["ApiVersionHeader"]; + }; path?: never; cookie?: never; };