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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .trajectories/completed/2026-08/traj_6am5rjhe0hnh.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
32 changes: 32 additions & 0 deletions .trajectories/completed/2026-08/traj_6am5rjhe0hnh.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion .trajectories/index.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
}
}
}
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
226 changes: 226 additions & 0 deletions cmd/relayfile-cli/cloudlogin.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading