diff --git a/.trajectories/completed/2026-08/traj_6am5rjhe0hnh.json b/.trajectories/completed/2026-08/traj_6am5rjhe0hnh.json new file mode 100644 index 00000000..9816c95b --- /dev/null +++ b/.trajectories/completed/2026-08/traj_6am5rjhe0hnh.json @@ -0,0 +1,75 @@ +{ + "id": "traj_6am5rjhe0hnh", + "version": 1, + "task": { + "title": "Implement zero-friction self-serve Relayfile Cloud onboarding" + }, + "status": "completed", + "startedAt": "2026-08-23T10:52:02.847Z", + "completedAt": "2026-08-23T11:02:20.669Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-23T11:01:06.253Z" + } + ], + "chapters": [ + { + "id": "chap_6v4shtkh4e5n", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-23T11:01:06.253Z", + "endedAt": "2026-08-23T11:02:20.669Z", + "events": [ + { + "ts": 1787482866254, + "type": "decision", + "content": "Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI: Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI", + "raw": { + "question": "Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI", + "chosen": "Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI", + "alternatives": [], + "reasoning": "The native localhost callback keeps npx relayfile@latest self-contained while preserving the canonical shared cloud-auth.json session, lock discipline, refresh behavior, and existing CI token precedence." + }, + "significance": "high" + }, + { + "ts": 1787482866488, + "type": "reflection", + "content": "The full Relayfile suite, Cloud callback tests/typecheck, contract check, packaged binary smoke test, and site build are green. Public docs and Cloud admission now describe the same no-invite one-command path.", + "raw": { + "focalPoints": [ + "clean-machine-auth", + "verified-email-admission", + "package-smoke", + "docs" + ], + "confidence": 0.9 + }, + "significance": "high", + "tags": [ + "focal:clean-machine-auth", + "focal:verified-email-admission", + "focal:package-smoke", + "focal:docs", + "confidence:0.9" + ] + } + ] + } + ], + "retrospective": { + "summary": "Added a native Relayfile Cloud browser login, zero-argument GitHub quickstart defaults, verified-email self-service Cloud admission, and matching public docs. Verified the full Relayfile suite, Cloud callback/typecheck, package smoke test, and website build/tests.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "c21360274534e79cb43b3aff2882067180fe6a57", + "endRef": "c21360274534e79cb43b3aff2882067180fe6a57" + } +} diff --git a/.trajectories/completed/2026-08/traj_6am5rjhe0hnh.md b/.trajectories/completed/2026-08/traj_6am5rjhe0hnh.md new file mode 100644 index 00000000..46430e37 --- /dev/null +++ b/.trajectories/completed/2026-08/traj_6am5rjhe0hnh.md @@ -0,0 +1,32 @@ +# Trajectory: Implement zero-friction self-serve Relayfile Cloud onboarding + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** August 23, 2026 at 12:52 PM +> **Completed:** August 23, 2026 at 01:02 PM + +--- + +## Summary + +Added a native Relayfile Cloud browser login, zero-argument GitHub quickstart defaults, verified-email self-service Cloud admission, and matching public docs. Verified the full Relayfile suite, Cloud callback/typecheck, package smoke test, and website build/tests. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI +- **Chose:** Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI +- **Reasoning:** The native localhost callback keeps npx relayfile@latest self-contained while preserving the canonical shared cloud-auth.json session, lock discipline, refresh behavior, and existing CI token precedence. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI: Made Relayfile own clean-machine Cloud login instead of depending on the agent-relay CLI +- The full Relayfile suite, Cloud callback tests/typecheck, contract check, packaged binary smoke test, and site build are green. Public docs and Cloud admission now describe the same no-invite one-command path. diff --git a/.trajectories/index.json b/.trajectories/index.json index e0d0bf45..9b2eb00e 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-08-15T21:42:38.397Z", + "lastUpdated": "2026-08-23T11:02:20.805Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -190,6 +190,13 @@ "startedAt": "2026-08-15T21:38:24.295Z", "completedAt": "2026-08-15T21:42:38.104Z", "path": ".trajectories/completed/2026-08/traj_1pmb0dufncg0.json" + }, + "traj_6am5rjhe0hnh": { + "title": "Implement zero-friction self-serve Relayfile Cloud onboarding", + "status": "completed", + "startedAt": "2026-08-23T10:52:02.847Z", + "completedAt": "2026-08-23T11:02:20.669Z", + "path": ".trajectories/completed/2026-08/traj_6am5rjhe0hnh.json" } } } diff --git a/README.md b/README.md index f45a724d..cb3875ac 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ That's the entire interface. No new SDK to learn, no MCP schemas eating your con ## Quick paths -- **Hosted integrations:** `npx relayfile setup --provider notion --workspace research-room --local-dir ./relayfile-mount` +- **Hosted integrations:** `npx relayfile@latest` — sign in, connect GitHub, and mount the current project with no prior account or CLI setup. - **Local OSS:** run the Docker stack below, then mount `ws_demo` as a normal directory. - **Sandbox SDK:** use `RelayfileSetup.ensureMountedWorkspace()` when your runtime already has a cloud access token. - **Programmatic agents:** use [`@relayfile/agents`](packages/agents/README.md) for Vercel AI SDK, OpenAI Agents SDK, and LangChain, or wrap `RelayFileClient.readFile()` / `writeFile()` directly in any custom harness. @@ -320,18 +320,23 @@ Hosted Agent Relay runs these pieces for you. Fully self-hosted provider-backed If you want Notion, Slack, Linear, GitHub, or other provider-backed files without running any infrastructure, use hosted Agent Relay. Agent Relay Cloud runs the workspace, relayfile API, scoped auth, Nango OAuth, provider sync workers, and writeback workers for you. -Use the [`setting-up-relayfile` skill](https://github.com/AgentWorkforce/skills/blob/main/skills/setting-up-relayfile/SKILL.md) when an agent should set up hosted files: +From the project where your agent will work, run: ```bash -relayfile setup \ +npx relayfile@latest +``` + +That one command opens Google sign-in, creates the account and workspace, opens GitHub OAuth, and mounts files at `./relayfile-mount`. No invite code, API token, or separate `agent-relay` installation is required. The command stays open to keep the mount synchronized and prints the exact path and starter prompt for a second terminal. + +For another provider or a custom workspace name, use the explicit setup form: + +```bash +npx relayfile@latest setup \ --provider notion \ --workspace my-agent \ - --local-dir ./relayfile-mount \ - --no-open + --local-dir ./relayfile-mount ``` -That command connects to `agentrelay.com`, creates or joins a cloud workspace, completes provider auth, waits for sync, and mounts the resulting files for the agent. The local directory is just the agent's file interface; the integration stack is hosted. - Use the OSS repo when you want to run the file server yourself. Use hosted Agent Relay when you want the whole integration path managed: | Need | Local OSS | Hosted Agent Relay | diff --git a/cmd/relayfile-cli/cloudlogin.go b/cmd/relayfile-cli/cloudlogin.go new file mode 100644 index 00000000..9a635080 --- /dev/null +++ b/cmd/relayfile-cli/cloudlogin.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" +) + +var cloudLoginBrowserOpener = openBrowser + +type cloudBrowserLoginResult struct { + auth agentRelayStoredAuth + err error +} + +// ensureSetupCloudCredentials is the clean-machine authentication path used by +// `relayfile setup`. Existing Agent Relay sessions and explicit CI tokens keep +// their current precedence. When neither exists, Relayfile owns the localhost +// browser callback itself so `npx relayfile@latest` does not require a separate +// agent-relay CLI installation. +func ensureSetupCloudCredentials( + cloudAPIURL string, + explicitToken string, + timeout time.Duration, + shouldOpenBrowser bool, + stdout io.Writer, +) (cloudCredentials, error) { + creds, err := ensureCloudCredentials(cloudAPIURL, explicitToken, timeout, shouldOpenBrowser, stdout) + if err == nil || strings.TrimSpace(explicitToken) != "" { + return creds, err + } + if !errors.Is(err, ErrCloudRefreshExpired) { + return cloudCredentials{}, err + } + // Environment-backed credentials belong to their caller. If they are + // unusable, do not silently replace them with an interactive file-backed + // session that the same environment would continue to shadow. + if strings.TrimSpace(os.Getenv("CLOUD_API_ACCESS_TOKEN")) != "" { + return cloudCredentials{}, err + } + + if timeout <= 0 { + timeout = 5 * time.Minute + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + auth, loginErr := loginToAgentRelayCloud(ctx, cloudAPIURL, shouldOpenBrowser, stdout) + if loginErr != nil { + return cloudCredentials{}, loginErr + } + return cloudCredentials{ + APIURL: strings.TrimRight(auth.APIURL, "/"), + AccessToken: auth.AccessToken, + AccessTokenExpiresAt: auth.AccessTokenExpiresAt, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +func loginToAgentRelayCloud( + ctx context.Context, + cloudAPIURL string, + shouldOpenBrowser bool, + stdout io.Writer, +) (agentRelayStoredAuth, error) { + if stdout == nil { + stdout = io.Discard + } + cloudAPI, err := buildCloudURL(cloudAPIURL, "") + if err != nil { + return agentRelayStoredAuth{}, err + } + cloudAPI.RawQuery = "" + cloudAPI.Fragment = "" + canonicalCloudAPI := strings.TrimRight(cloudAPI.String(), "/") + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return agentRelayStoredAuth{}, fmt.Errorf("start Relayfile cloud login callback: %w", err) + } + defer listener.Close() + + state, err := randomURLSafe(32) + if err != nil { + return agentRelayStoredAuth{}, fmt.Errorf("create Relayfile cloud login state: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + callbackURL := &url.URL{ + Scheme: "http", + Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), + Path: "/callback", + } + loginURL, err := buildCloudURL(canonicalCloudAPI, "api/v1/cli/login") + if err != nil { + return agentRelayStoredAuth{}, err + } + query := loginURL.Query() + query.Set("redirect_uri", callbackURL.String()) + query.Set("state", state) + loginURL.RawQuery = query.Encode() + + result := make(chan cloudBrowserLoginResult, 1) + var settle sync.Once + finish := func(value cloudBrowserLoginResult) { + settle.Do(func() { result <- value }) + } + + handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/callback" { + http.NotFound(response, request) + return + } + returnedState := request.URL.Query().Get("state") + if subtle.ConstantTimeCompare([]byte(returnedState), []byte(state)) != 1 { + http.Error(response, "Ignored invalid CLI login callback. Return to your terminal to continue login.", http.StatusBadRequest) + return + } + if callbackError := strings.TrimSpace(request.URL.Query().Get("error")); callbackError != "" { + redirectCloudLoginResult(response, request, canonicalCloudAPI, "error", callbackError) + finish(cloudBrowserLoginResult{err: fmt.Errorf("Relayfile cloud login failed: %s", callbackError)}) + return + } + + auth := agentRelayStoredAuth{ + APIURL: firstNonEmpty(strings.TrimRight(strings.TrimSpace(request.URL.Query().Get("api_url")), "/"), canonicalCloudAPI), + AccessToken: strings.TrimSpace(request.URL.Query().Get("access_token")), + RefreshToken: strings.TrimSpace(request.URL.Query().Get("refresh_token")), + AccessTokenExpiresAt: strings.TrimSpace(request.URL.Query().Get("access_token_expires_at")), + RefreshTokenExpiresAt: strings.TrimSpace(request.URL.Query().Get("refresh_token_expires_at")), + } + if !auth.valid() { + const detail = "The CLI login callback was missing a valid API URL or token expiration set." + redirectCloudLoginResult(response, request, canonicalCloudAPI, "error", detail) + finish(cloudBrowserLoginResult{err: errors.New(detail)}) + return + } + returnedAPI, parseErr := buildCloudURL(auth.APIURL, "") + if parseErr != nil || (returnedAPI.Scheme != "http" && returnedAPI.Scheme != "https") { + const detail = "The CLI login callback returned an invalid API URL." + redirectCloudLoginResult(response, request, canonicalCloudAPI, "error", detail) + finish(cloudBrowserLoginResult{err: errors.New(detail)}) + return + } + + redirectCloudLoginResult(response, request, auth.APIURL, "success", "You can return to your terminal.") + finish(cloudBrowserLoginResult{auth: auth}) + }) + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + serveErr := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + + fmt.Fprintf(stdout, "Sign in to Relayfile Cloud:\n %s\n", loginURL.String()) + if shouldOpenBrowser { + if err := cloudLoginBrowserOpener(loginURL.String()); err != nil { + fmt.Fprintf(stdout, "Could not open a browser automatically: %v\nPaste the URL above into your browser.\n", err) + } + } else { + fmt.Fprintln(stdout, "Open the URL above in a browser to continue.") + } + + var completed cloudBrowserLoginResult + select { + case completed = <-result: + case err := <-serveErr: + return agentRelayStoredAuth{}, fmt.Errorf("serve Relayfile cloud login callback: %w", err) + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return agentRelayStoredAuth{}, errors.New("timed out waiting for Relayfile cloud login") + } + return agentRelayStoredAuth{}, ctx.Err() + } + if completed.err != nil { + return agentRelayStoredAuth{}, completed.err + } + + release, err := acquireAgentRelayAuthLock(ctx) + if err != nil { + return agentRelayStoredAuth{}, fmt.Errorf("lock the Relayfile cloud session: %w", err) + } + defer release() + if err := writeAgentRelayStoredAuthFile(completed.auth); err != nil { + return agentRelayStoredAuth{}, fmt.Errorf("persist the Relayfile cloud session: %w", err) + } + fmt.Fprintln(stdout, "Relayfile Cloud sign-in complete.") + return completed.auth, nil +} + +func redirectCloudLoginResult( + response http.ResponseWriter, + request *http.Request, + cloudAPIURL string, + status string, + detail string, +) { + resultURL, err := buildCloudURL(cloudAPIURL, "cli/auth-result") + if err != nil { + http.Error(response, detail, http.StatusBadRequest) + return + } + query := resultURL.Query() + query.Set("status", status) + query.Set("detail", detail) + resultURL.RawQuery = query.Encode() + http.Redirect(response, request, resultURL.String(), http.StatusFound) +} diff --git a/cmd/relayfile-cli/cloudlogin_test.go b/cmd/relayfile-cli/cloudlogin_test.go new file mode 100644 index 00000000..8ebe73c8 --- /dev/null +++ b/cmd/relayfile-cli/cloudlogin_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" +) + +func TestEnsureSetupCloudCredentialsLogsInWithoutAgentRelayCLI(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + t.Setenv("PATH", t.TempDir()) + + var cloud *httptest.Server + cloud = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/cli/login": + callback, err := url.Parse(r.URL.Query().Get("redirect_uri")) + if err != nil { + t.Errorf("parse callback URL: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + query := callback.Query() + query.Set("state", r.URL.Query().Get("state")) + query.Set("access_token", "cld_at_setup_secret") + query.Set("refresh_token", "cld_rt_setup_secret") + query.Set("access_token_expires_at", time.Now().Add(time.Hour).UTC().Format(time.RFC3339)) + query.Set("refresh_token_expires_at", time.Now().Add(30*24*time.Hour).UTC().Format(time.RFC3339)) + callback.RawQuery = query.Encode() + http.Redirect(w, r, callback.String(), http.StatusFound) + case "/cli/auth-result": + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "Signed in") + default: + t.Errorf("unexpected cloud path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer cloud.Close() + + opened := make(chan string, 1) + browserResult := make(chan error, 1) + previousOpener := cloudLoginBrowserOpener + cloudLoginBrowserOpener = func(target string) error { + opened <- target + go func() { + response, err := http.Get(target) + if response != nil { + _ = response.Body.Close() + } + browserResult <- err + }() + return nil + } + t.Cleanup(func() { cloudLoginBrowserOpener = previousOpener }) + + var stdout bytes.Buffer + creds, err := ensureSetupCloudCredentials(cloud.URL, "", 2*time.Second, true, &stdout) + if err != nil { + t.Fatalf("ensure setup credentials: %v\noutput:\n%s", err, stdout.String()) + } + if creds.APIURL != cloud.URL || creds.AccessToken != "cld_at_setup_secret" { + t.Fatalf("unexpected setup credentials: %#v", creds) + } + select { + case loginURL := <-opened: + if !strings.HasPrefix(loginURL, cloud.URL+"/api/v1/cli/login?") { + t.Fatalf("unexpected login URL: %s", loginURL) + } + case <-time.After(time.Second): + t.Fatal("browser login was not opened") + } + select { + case browserErr := <-browserResult: + if browserErr != nil { + t.Fatalf("browser login request failed: %v", browserErr) + } + case <-time.After(time.Second): + t.Fatal("browser login request did not complete") + } + + auth, err := readAgentRelayStoredAuthFile() + if err != nil { + t.Fatalf("read canonical cloud auth: %v", err) + } + if auth.AccessToken != "cld_at_setup_secret" || auth.RefreshToken != "cld_rt_setup_secret" { + t.Fatalf("unexpected persisted cloud auth: %#v", auth) + } + if output := stdout.String(); strings.Contains(output, auth.AccessToken) || strings.Contains(output, auth.RefreshToken) { + t.Fatalf("setup output leaked a cloud credential: %q", output) + } + + cloudLoginBrowserOpener = func(string) error { + t.Fatal("a valid stored login must be reused without opening a browser") + return nil + } + reused, err := ensureSetupCloudCredentials(cloud.URL, "", 2*time.Second, true, io.Discard) + if err != nil { + t.Fatalf("reuse setup credentials: %v", err) + } + if reused.AccessToken != auth.AccessToken { + t.Fatalf("reused access token = %q, want stored token", reused.AccessToken) + } +} + +func TestEnsureSetupCloudCredentialsKeepsExplicitTokenNonInteractive(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + previousOpener := cloudLoginBrowserOpener + cloudLoginBrowserOpener = func(string) error { + t.Fatal("an explicit token must not start browser login") + return nil + } + t.Cleanup(func() { cloudLoginBrowserOpener = previousOpener }) + + creds, err := ensureSetupCloudCredentials("https://cloud.example", "cld_explicit", time.Second, true, io.Discard) + if err != nil { + t.Fatalf("ensure explicit setup credentials: %v", err) + } + if creds.AccessToken != "cld_explicit" { + t.Fatalf("explicit token was not preserved: %#v", creds) + } + if _, err := os.Stat(mustAgentRelayCloudAuthPath(t)); !os.IsNotExist(err) { + t.Fatalf("explicit token unexpectedly created a stored login: %v", err) + } +} diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 9ebb12b0..31666b9b 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -601,7 +601,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return nil } if len(args) == 0 { - return runSetup(nil, stdin, stdout) + return runSetupWithOptions( + quickStartSetupArgs(), + stdin, + stdout, + setupRunOptions{preserveExistingLocalDir: true}, + ) } switch args[0] { @@ -665,6 +670,23 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } } +func quickStartSetupArgs() []string { + workingDir, _ := os.Getwd() + return quickStartSetupArgsForDir(workingDir, time.Now()) +} + +func quickStartSetupArgsForDir(workingDir string, now time.Time) []string { + workspaceName := "relayfile-" + now.UTC().Format("20060102-150405") + if base := strings.TrimSpace(filepath.Base(workingDir)); base != "" && base != "." && base != string(filepath.Separator) { + workspaceName = base + } + return []string{ + "--provider", "github", + "--workspace", workspaceName, + "--local-dir", "./relayfile-mount", + } +} + func wantsVersion(args []string) bool { return len(args) == 1 && (args[0] == "--version" || args[0] == "version") } @@ -874,7 +896,7 @@ func printUsage(w io.Writer) { fmt.Fprintln(w, `relayfile is the RelayFile CLI. Usage: - relayfile + relayfile (hosted GitHub quickstart for the current project) relayfile setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR] relayfile login [--no-open] [--provision-messaging-only] [--api-key] [--server URL] [--token TOKEN] relayfile logout @@ -961,7 +983,15 @@ Subcommands: observer Open the hosted file observer for a workspace`) } +type setupRunOptions struct { + preserveExistingLocalDir bool +} + func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { + return runSetupWithOptions(args, stdin, stdout, setupRunOptions{}) +} + +func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, options setupRunOptions) error { fs := flag.NewFlagSet("setup", flag.ContinueOnError) fs.SetOutput(io.Discard) cloudAPIURL := fs.String("cloud-api-url", envOrDefault("RELAYFILE_CLOUD_API_URL", defaultCloudAPIURL), "Relayfile Cloud API URL") @@ -1001,7 +1031,7 @@ func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { fmt.Fprintln(stdout, "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount.") - tokenSet, err := ensureCloudCredentials(cloudAPI, strings.TrimSpace(*cloudToken), *loginTimeout, !*noOpen, stdout) + tokenSet, err := ensureSetupCloudCredentials(cloudAPI, strings.TrimSpace(*cloudToken), *loginTimeout, !*noOpen, stdout) if err != nil { return err } @@ -1048,6 +1078,7 @@ func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { localDir = "./relayfile-mount" } } + localDir = resolveSetupLocalDir(name, localDir, options.preserveExistingLocalDir) absLocalDir, err := filepath.Abs(localDir) if err != nil { return err @@ -1128,10 +1159,31 @@ func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { return nil } - fmt.Fprintf(stdout, "Starting VFS mount at %s\n", localDir) + fmt.Fprintf(stdout, "Starting VFS mount at %s\n", absLocalDir) + fmt.Fprintln(stdout, "Keep this terminal open while Relayfile syncs. In another terminal, start your agent and give it this prompt:") + if selectedProvider != "" && selectedProvider != "none" && selectedProvider != "skip" { + fmt.Fprintf(stdout, " Use %s as the source of truth. Read LAYOUT.md first, then show me what needs attention.\n", setupAgentPromptPath(absLocalDir, selectedProvider)) + } else { + fmt.Fprintf(stdout, " Use %s as our shared workspace. Read LAYOUT.md first.\n", absLocalDir) + } return runMount(mountArgs) } +func resolveSetupLocalDir(workspaceName, requestedLocalDir string, preserveExisting bool) string { + if preserveExisting { + if existing, ok := workspaceRecordByName(workspaceName); ok { + if localDir := strings.TrimSpace(existing.LocalDir); localDir != "" { + return localDir + } + } + } + return requestedLocalDir +} + +func setupAgentPromptPath(absLocalDir, provider string) string { + return filepath.Join(absLocalDir, mountscope.ProviderRoot(provider)) +} + func ensureCloudCredentials(cloudAPIURL, explicitToken string, timeout time.Duration, shouldOpenBrowser bool, stdout io.Writer) (cloudCredentials, error) { explicitToken = strings.TrimSpace(explicitToken) cloudAPIURL = strings.TrimRight(strings.TrimSpace(cloudAPIURL), "/") diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 02e471ac..9bcd863c 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync/atomic" @@ -210,6 +211,62 @@ func parseBrowserFragment(t *testing.T, rawURL string) url.Values { return fragment } +func TestQuickStartUsesProjectNameGitHubAndLocalMountDefaults(t *testing.T) { + args := quickStartSetupArgsForDir( + filepath.Join(string(filepath.Separator), "workspaces", "acme-api"), + time.Date(2026, time.August, 23, 12, 0, 0, 0, time.UTC), + ) + want := []string{ + "--provider", "github", + "--workspace", "acme-api", + "--local-dir", "./relayfile-mount", + } + if !slices.Equal(args, want) { + t.Fatalf("quick-start args = %#v, want %#v", args, want) + } +} + +func TestQuickStartFallsBackToTimestampedWorkspaceOutsideAProject(t *testing.T) { + args := quickStartSetupArgsForDir( + string(filepath.Separator), + time.Date(2026, time.August, 23, 12, 34, 56, 0, time.UTC), + ) + if got, want := args[3], "relayfile-20260823-123456"; got != want { + t.Fatalf("fallback workspace = %q, want %q", got, want) + } +} + +func TestSetupAgentPromptUsesProviderMountRoot(t *testing.T) { + got := setupAgentPromptPath( + filepath.Join(string(filepath.Separator), "workspace", "relayfile-mount"), + "slack-my-senior-dev", + ) + want := filepath.Join(string(filepath.Separator), "workspace", "relayfile-mount", "slack-msd") + if got != want { + t.Fatalf("setup agent prompt path = %q, want %q", got, want) + } +} + +func TestQuickStartPreservesExistingWorkspaceMirror(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + existingDir := filepath.Join(t.TempDir(), "existing-mirror") + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "acme-api", + ID: "ws_acme", + LocalDir: existingDir, + }); err != nil { + t.Fatalf("store existing workspace: %v", err) + } + + if got := resolveSetupLocalDir("acme-api", "./relayfile-mount", true); got != existingDir { + t.Fatalf("quickstart local dir = %q, want existing %q", got, existingDir) + } + if got := resolveSetupLocalDir("acme-api", "./explicit-mount", false); got != "./explicit-mount" { + t.Fatalf("explicit setup local dir = %q, want caller value", got) + } +} + func TestHelpFlagPrintsUsageForCommandsAndSubcommands(t *testing.T) { cases := []struct { name string diff --git a/docs/cli-design.md b/docs/cli-design.md index 0d976040..52d4b266 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -12,7 +12,7 @@ The `relayfile` CLI is the primary interface for humans and CI systems to intera ### Design principles - **Minimal flags, sensible defaults.** The happy path should require as few arguments as possible. -- **Canonical auth over local fallbacks.** Cloud login and active workspace selection are owned by `agent-relay cloud login` and the `@agent-relay/cloud` session. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. +- **Canonical auth over local fallbacks.** Cloud login uses the shared `@agent-relay/cloud` session. `relayfile setup` can establish it directly on a clean machine, while Agent Relay commands can reuse it. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. - **Composable with pipes and scripts.** All commands emit structured JSON when `--json` is passed; human-readable tables otherwise. - **No implicit destructive actions.** Deletes require confirmation unless `--yes` is passed. @@ -32,16 +32,17 @@ The `relayfile` CLI is the primary interface for humans and CI systems to intera ### Auth flow: Cloud-hosted ``` -agent-relay cloud login -agent-relay workspace switch my-project -relayfile mount +npx relayfile@latest ``` -1. `agent-relay cloud login` writes the canonical cloud session in the relay SDK store. -2. `agent-relay workspace switch ` selects the active relay workspace. -3. `relayfile` commands resolve the cloud session without running any CLI, and - call `agent-relay workspace active --json` for the canonical - `relayfileWorkspaceId`. Session resolution order: +1. Relayfile opens the hosted login and writes the canonical cloud session in + the shared relay SDK store. +2. The quickstart creates a Cloud workspace named after the current directory, + connects GitHub, and mounts it at `./relayfile-mount`. +3. `relayfile setup` establishes and resolves the shared Cloud session without + invoking `agent-relay`. Some other workspace-resolution paths still call + `agent-relay workspace active --json` for the canonical + `relayfileWorkspaceId`. Cloud session resolution itself uses this order: - A `CLOUD_API_ACCESS_TOKEN` in the environment wins, together with `CLOUD_API_URL`, `CLOUD_API_REFRESH_TOKEN`, `CLOUD_API_ACCESS_TOKEN_EXPIRES_AT` and @@ -100,7 +101,7 @@ relayfile login --api-key --server https://api.relayfile.dev - `server` — base URL for all API calls. Default: `https://api.relayfile.dev`. - `token` — Bearer JWT or API key. -- `refreshToken` / `expiresAt` — legacy fields. Cloud-hosted refresh is owned by `agent-relay`. +- `refreshToken` / `expiresAt` — legacy fields. Cloud-hosted refresh uses the shared canonical Cloud session instead. - File permissions: `0600` (user-only read/write). --- @@ -116,6 +117,10 @@ relayfile relayfile setup [--provider github] [--workspace my-project] [--local-dir ./relayfile-mount] ``` +With no arguments, `relayfile` supplies `github`, the current directory name, +and `./relayfile-mount` automatically. The flag defaults below describe the +explicit `relayfile setup` wizard. + | Flag | Default | Description | |------|---------|-------------| | `--cloud-api-url` | `https://agentrelay.com/cloud` | Relayfile Cloud API URL | @@ -129,12 +134,14 @@ relayfile setup [--provider github] [--workspace my-project] [--local-dir ./rela **Behavior:** -1. Ensure the user has run `agent-relay cloud login`; `relayfile setup` reads the canonical relay session instead of starting its own login flow. +1. Read the canonical relay session. If none exists, start the localhost + browser callback and establish that same shared session directly. 2. Read the Cloud access token from the canonical credential file `~/.agentworkforce/relay/cloud-auth.json` (or the `CLOUD_API_*` environment), refreshing it in place when it is inside its expiry window. -3. Use `agent-relay workspace active --json` for the canonical workspace descriptor and `relayfileWorkspaceId`. -4. Create/join the Cloud workspace when needed, minting Relayfile runtime credentials without persisting them as a second login. +3. Create or reuse the named Cloud workspace directly; setup does not require + an installed `agent-relay` binary or a preselected Agent Relay workspace. +4. Mint Relayfile runtime credentials without persisting them as a second login. 5. Request a hosted Nango connect session for the selected integration and wait until the Cloud status endpoint reports it ready. 6. Start the existing `relayfile mount` sync loop so the user and agent see ordinary files. diff --git a/docs/guides/cloud-integration.md b/docs/guides/cloud-integration.md index c00df22b..f12e0cbb 100644 --- a/docs/guides/cloud-integration.md +++ b/docs/guides/cloud-integration.md @@ -4,17 +4,19 @@ Hosted Agent Relay is the managed cloud path for provider-backed files. Agent Re ## Hosted Agent Relay -Use the `setting-up-relayfile` skill from [AgentWorkforce/skills#28](https://github.com/AgentWorkforce/skills/pull/28) when an agent needs provider-backed files from `agentrelay.com`. +From the project where the agent will work, run the clean-machine quickstart: ```bash -relayfile setup \ - --provider notion \ - --workspace my-agent \ - --local-dir ./relayfile-mount \ - --no-open +npx relayfile@latest ``` -The skill covers the full hosted flow: cloud login, workspace creation, provider OAuth, initial sync, local mount verification, writeback checks, and recovery guidance. No relayfile server, relayauth service, Nango instance, adapter, or worker has to run on the user's machine. +Relayfile owns the full hosted flow: Google sign-in, automatic account and workspace creation, GitHub OAuth, initial sync, and the local mount. No invite code, separate Agent Relay CLI, copied token, relayfile server, relayauth service, Nango instance, adapter, or worker has to run on the user's machine. + +Use the explicit form for a different provider or workspace: + +```bash +npx relayfile@latest setup --provider notion --workspace my-agent --local-dir ./relayfile-mount +``` After setup, hand the agent the mount path: diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 00e48f7a..d1857a4f 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -11,14 +11,12 @@ This repo is the file server and mount layer. For the rest of the ecosystem, see If you want Notion, Slack, Linear, GitHub, or other provider-backed files without running any infrastructure, start here. Agent Relay Cloud runs the daemon, OAuth, sync workers, and writeback workers for you. ```bash -relayfile setup \ - --provider notion \ - --workspace my-agent \ - --local-dir ./relayfile-mount \ - --no-open +npx relayfile@latest ``` -That command connects to `agentrelay.com`, completes provider auth, waits for sync, and mounts provider files locally. Your agent reads and reacts to the mounted files; the integration stack is hosted. +That command signs the user in with Google, creates the account and workspace without an invite code, connects GitHub, and mounts provider files at `./relayfile-mount`. No separate Agent Relay CLI or copied token is needed. Your agent reads and reacts to the mounted files; the integration stack is hosted. + +Use `npx relayfile@latest setup --provider notion --workspace my-agent --local-dir ./relayfile-mount` when you want a different provider or explicit names. ## Local OSS Quickstart diff --git a/docs/guides/vfs-cloud-setup.md b/docs/guides/vfs-cloud-setup.md index 845d83ad..fdb2e779 100644 --- a/docs/guides/vfs-cloud-setup.md +++ b/docs/guides/vfs-cloud-setup.md @@ -15,20 +15,26 @@ The mirror has real limitations. Read [Known Limitations](#known-limitations) be ### Interactive (recommended for humans) ```bash -relayfile +npx relayfile@latest ``` -This runs the full setup wizard: +This runs the zero-configuration GitHub quickstart: 1. Opens a browser to sign in to Relayfile Cloud. -2. Prompts for a workspace name (default: `relayfile-`). -3. Prompts for an integration provider (GitHub, Notion, Linear, Slack, or none). -4. Prompts for a local directory (default: `./relayfile-mount`). +2. Creates the user's account without an invite code and names the workspace after the current directory. +3. Selects GitHub as the first integration. +4. Uses `./relayfile-mount` as the local directory. 5. Opens the integration OAuth consent page. 6. Waits for the initial sync to complete, showing live progress. 7. Starts the sync loop in the foreground. -Press `Ctrl+C` at any time to stop. The local files remain; re-run `relayfile` or `relayfile mount ` to resume. +No separate `agent-relay` CLI, API token, or prior Cloud session is required. Press `Ctrl+C` at any time to stop. The local files remain; re-run `npx relayfile@latest` or `relayfile mount ` to resume. + +For interactive provider/workspace/directory prompts, run `relayfile setup` without flags. For an explicit path, use: + +```bash +npx relayfile@latest setup --provider notion --workspace my-project --local-dir ./relayfile-mount +``` ### Non-interactive / CI diff --git a/docs/productized-cloud-mount-contract.md b/docs/productized-cloud-mount-contract.md index 9e6bfffd..a07ada2b 100644 --- a/docs/productized-cloud-mount-contract.md +++ b/docs/productized-cloud-mount-contract.md @@ -29,7 +29,9 @@ mitigation. We do not paper over the difference. ### 1.1 Invocation forms -The CLI **MUST** support these equivalent first-run forms: +The CLI **MUST** support these first-run forms. Bare `relayfile` is the +zero-prompt quickstart; explicit `relayfile setup` retains its prompt-driven +defaults and flag overrides: ``` relayfile @@ -55,6 +57,10 @@ below are normative for `setup`; defaults are listed in parentheses. | `--login-timeout` | `5m` | OAuth callback timeout | | `--connect-timeout` | `5m` | Integration readiness timeout | +The bare `relayfile` quickstart supplies GitHub, the current directory name, +and `./relayfile-mount` as explicit setup values. The prompt defaults above +apply when a user invokes `relayfile setup` without those flags. + ### 1.2 Required ordered steps `relayfile setup` **MUST** execute these steps in this order, and every @@ -63,11 +69,13 @@ step **MUST** be idempotent on re-run: 1. **Print intent.** A single line stating: "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount." 2. **Cloud login.** - - Cloud authentication is owned by `agent-relay cloud login` and the - `@agent-relay/cloud` session store. - - Relayfile obtains Cloud auth by reading that store's canonical credential - file, `~/.agentworkforce/relay/cloud-auth.json` — never by invoking the - `agent-relay` CLI. + - Cloud authentication is owned by the shared `@agent-relay/cloud` session + store. Both `agent-relay cloud login` and `relayfile setup` may establish + that same canonical session. + - Relayfile first reads `~/.agentworkforce/relay/cloud-auth.json`. When setup + finds no usable file-backed session, it completes the hosted localhost + browser callback itself and writes that file at `0600`; it never invokes + the `agent-relay` CLI for login. - Relayfile does not persist `~/.relayfile/cloud-credentials.json` as a Cloud session source of truth. 3. **Workspace name.** Use `--workspace`, else prompt. Default suggestion: @@ -77,11 +85,11 @@ step **MUST** be idempotent on re-run: 5. **Local dir.** Use `--local-dir`, else prompt; default `./relayfile-mount`. The directory **MUST** be created with `0o755` and a `.relay/` subdir with `0o755`. -6. **Workspace create + join.** Resolve the canonical active workspace via - `agent-relay workspace active --json`, then `POST - /api/v1/workspaces/{cloudWorkspaceId}/join` with `{agentName: - "relayfile-cli", scopes: ["fs:read", "fs:write"]}`. Use the returned - Relayfile VFS token in memory and add/update local workspace metadata in +6. **Workspace create + join.** Reuse the locally tracked named workspace or + `POST /api/v1/workspaces` to create it, then mint a scoped runtime bundle at + `/api/v1/workspaces/{cloudWorkspaceId}/relayfile/delegated-token` with + `agentName: "relayfile-cli"`. Use the returned Relayfile VFS token in memory + and add/update local workspace metadata in `~/.relayfile/workspaces.json` without marking a separate Relayfile workspace default. 7. **Integration connect.** If a provider was chosen and not `none`: @@ -369,8 +377,8 @@ two layers: Cloud login tokens (user identity) and Relayfile VFS tokens ### 5.1 Credential files -- `~/.agentworkforce/relay/cloud-auth.json` (`0600`, written by - `agent-relay cloud login`; read but never created by Relayfile): +- `~/.agentworkforce/relay/cloud-auth.json` (`0600`, written by either + `agent-relay cloud login` or Relayfile's clean-machine setup): ```json { "apiUrl": "https://agentrelay.com/cloud", @@ -384,7 +392,7 @@ two layers: Cloud login tokens (user identity) and Relayfile VFS tokens `relayfileWorkspaceId` used by Relayfile data-plane calls. - The cloud session itself is read from the canonical credential file (`~/.agentworkforce/relay/cloud-auth.json`, `0600`) that - `agent-relay cloud login` writes, or from `CLOUD_API_URL` / + either login path writes, or from `CLOUD_API_URL` / `CLOUD_API_ACCESS_TOKEN` / `CLOUD_API_REFRESH_TOKEN` / `CLOUD_API_ACCESS_TOKEN_EXPIRES_AT`. Relayfile refreshes an expiring access token through Cloud's `/api/v1/auth/token/refresh` and writes the rotated @@ -395,9 +403,9 @@ two layers: Cloud login tokens (user identity) and Relayfile VFS tokens `RELAYFILE_AGENT_RELAY_BIN` when set, for **workspace resolution only**. It must never read `AGENT_RELAY_BIN`: across Agent Relay that variable names the broker binary, and reading it here made relayfile exec `agent-relay-broker`. - The runtime environment, including sandbox and - Daytona/base images, must provide `agent-relay` CLI `8.7.0` or newer before - using the Cloud-hosted path. Relayfile must fail fast with an actionable + Setup itself does not require this binary. Runtime environments only need + `agent-relay` CLI `8.7.0` or newer when a command explicitly resolves or + switches an Agent Relay workspace. Relayfile must fail fast with an actionable message naming the argv it probed, the binary it used, and how that binary was resolved, when the binary is missing, stale, or lacks the required `workspace active` or `workspace switch` commands. @@ -419,9 +427,9 @@ two layers: Cloud login tokens (user identity) and Relayfile VFS tokens ### 5.2 Cloud token refresh -Relayfile **MUST NOT** own a Cloud session of its own. It reads the canonical -session and refreshes it in place, against the same endpoint and with the same -windows as `@agent-relay/cloud`: +Relayfile **MUST NOT** create a second Cloud session store. Setup may establish +the shared canonical session, and all commands refresh it in place against the +same endpoint and with the same windows as `@agent-relay/cloud`: - Read `~/.agentworkforce/relay/cloud-auth.json` (or the `CLOUD_API_*` environment). Never write a second session store. @@ -434,9 +442,9 @@ windows as `@agent-relay/cloud`: would invalidate the copy `agent-relay` reads. - A session supplied through the environment is owned by whoever exported it: refresh it in memory, never to disk. -- When refresh is refused, surface an actionable `agent-relay cloud login` - recovery hint. The mount process **MUST** keep running on the existing VFS - token until that token also expires. +- When refresh is refused, surface an actionable `npx relayfile@latest` or + `agent-relay cloud login` recovery hint. The mount process **MUST** keep + running on the existing VFS token until that token also expires. An earlier revision of this contract required Relayfile to obtain Cloud credentials by invoking `agent-relay cloud session --json` and forbade it from @@ -482,9 +490,9 @@ If the refresh token also expires (default 7 days), the mount **MUST**: - Continue serving local reads from disk (already-synced state). - Refuse local writes for affected paths and place them in `.relay/permissions-denied.log` with reason `cloud_session_expired`. -- Print one stderr line per minute (capped) directing the user to run - `agent-relay cloud login`. This is the only condition under which v1 mounts - enter a degraded read-only state. +- Print one stderr line per minute (capped) directing the user to rerun + `npx relayfile@latest` or `agent-relay cloud login`. This is the only + condition under which v1 mounts enter a degraded read-only state. --- diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index f96328ec..4ceee67d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - Patch] +### Added + +- `npx relayfile@latest` is now a true clean-machine Cloud quickstart. With no arguments it uses the current directory name for the workspace, connects GitHub, mounts at `./relayfile-mount`, and prints a ready-to-paste agent prompt. When no Cloud session exists, Relayfile completes the localhost browser login itself and writes the canonical shared session; a separately installed `agent-relay` CLI is no longer required for setup. + ### Fixed - Mount bootstrap stalls now terminate the CLI daemon after the persisted retry limit, survive supervisor restarts without rereading already committed files, and surface as `mount: stalled` with the blocked path. Bootstrap progress uses the tree API's authoritative file total when safe, suppresses totals that include pruned runtime subtrees, and never renders a synthetic `/0` denominator.