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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .trajectories/completed/2026-05/traj_4vdcwo2iy630.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"id": "traj_4vdcwo2iy630",
"version": 1,
"task": {
"title": "relayfile login: fall back to cloud browser flow when no --token"
},
"status": "completed",
"startedAt": "2026-05-09T18:25:18.473Z",
"completedAt": "2026-05-09T18:27:13.341Z",
"agents": [
{
"name": "default",
"role": "lead",
"joinedAt": "2026-05-09T18:25:23.176Z"
}
],
"chapters": [
{
"id": "chap_xmj2dpqakhb5",
"title": "Work",
"agentName": "default",
"startedAt": "2026-05-09T18:25:23.176Z",
"endedAt": "2026-05-09T18:27:13.341Z",
"events": [
{
"ts": 1778351123176,
"type": "decision",
"content": "When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key: When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key",
"raw": {
"question": "When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key",
"chosen": "When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key",
"alternatives": [],
"reasoning": "Matches what 'relayfile setup' already does. Old API-key prompt path stays available behind --api-key flag for self-hosted users who don't have cloud."
},
"significance": "high"
}
]
}
],
"retrospective": {
"summary": "relayfile login now defaults to the cloud browser flow when --token is absent; legacy API-key prompt is preserved behind --api-key. Adds --cloud-api-url/--cloud-token/--no-open/--login-timeout flags mirroring 'setup', plus 3 unit tests.",
"approach": "Standard approach",
"confidence": 0.85
},
"commits": [],
"filesChanged": [],
"projectId": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile",
"tags": [],
"_trace": {
"startRef": "9de722a6c6c747fca3e90fb62a9899253e742264",
"endRef": "9de722a6c6c747fca3e90fb62a9899253e742264"
}
}
31 changes: 31 additions & 0 deletions .trajectories/completed/2026-05/traj_4vdcwo2iy630.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Trajectory: relayfile login: fall back to cloud browser flow when no --token

> **Status:** ✅ Completed
> **Confidence:** 85%
> **Started:** May 9, 2026 at 08:25 PM
> **Completed:** May 9, 2026 at 08:27 PM

---

## Summary

relayfile login now defaults to the cloud browser flow when --token is absent; legacy API-key prompt is preserved behind --api-key. Adds --cloud-api-url/--cloud-token/--no-open/--login-timeout flags mirroring 'setup', plus 3 unit tests.

**Approach:** Standard approach

---

## Key Decisions

### When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key
- **Chose:** When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key
- **Reasoning:** Matches what 'relayfile setup' already does. Old API-key prompt path stays available behind --api-key flag for self-hosted users who don't have cloud.

---

## Chapters

### 1. Work
*Agent: default*

- When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key: When no --token is provided, runLogin will trigger ensureCloudCredentials (browser flow) instead of prompting for API key
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-05-09T18:22:10.451Z",
"lastUpdated": "2026-05-09T18:27:13.442Z",
"trajectories": {
"traj_mfyus7zfgxt2": {
"title": "062-sdk-setup-client-workflow",
Expand Down Expand Up @@ -221,6 +221,13 @@
"completedAt": "2026-05-09T13:57:04.293Z",
"path": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-05/traj_6lyjg41p6a28.json",
"compactedInto": "compact_xl96yexa79wg"
},
"traj_4vdcwo2iy630": {
"title": "relayfile login: fall back to cloud browser flow when no --token",
"status": "completed",
"startedAt": "2026-05-09T18:25:18.473Z",
"completedAt": "2026-05-09T18:27:13.341Z",
"path": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile/.trajectories/completed/2026-05/traj_4vdcwo2iy630.json"
}
}
}
62 changes: 49 additions & 13 deletions cmd/relayfile-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ func printUsage(w io.Writer) {
Usage:
relayfile
relayfile setup [--provider PROVIDER] [--workspace NAME] [--local-dir DIR]
relayfile login --server URL [--token TOKEN]
relayfile login [--no-open] [--api-key] [--server URL] [--token TOKEN]
relayfile workspace create NAME
relayfile workspace use NAME
relayfile workspace list
Expand All @@ -405,7 +405,7 @@ Usage:

Subcommands:
setup Sign in, connect an integration, and mount the workspace
login Store credentials in ~/.relayfile/credentials.json
login Sign in via the Relayfile Cloud browser flow (or --api-key for self-hosted)
workspace Create, select, list, or delete locally tracked workspaces
integration Connect, list, or disconnect workspace integrations
ops List or replay dead-lettered writeback ops
Expand Down Expand Up @@ -1140,28 +1140,64 @@ func waitForInitialSync(serverURL, token, workspaceID, provider, localDir string
func runLogin(args []string, stdin io.Reader, stdout io.Writer) error {
fs := flag.NewFlagSet("login", flag.ContinueOnError)
fs.SetOutput(io.Discard)
server := fs.String("server", envOrDefault("RELAYFILE_SERVER", envOrDefault("RELAYFILE_BASE_URL", defaultServerURL)), "relayfile server URL")
token := fs.String("token", strings.TrimSpace(os.Getenv("RELAYFILE_TOKEN")), "API token")
if err := fs.Parse(args); err != nil {
server := fs.String("server", envOrDefault("RELAYFILE_SERVER", envOrDefault("RELAYFILE_BASE_URL", defaultServerURL)), "relayfile server URL (only used with --api-key)")
token := fs.String("token", strings.TrimSpace(os.Getenv("RELAYFILE_TOKEN")), "relayfile API token")
cloudAPIURL := fs.String("cloud-api-url", envOrDefault("RELAYFILE_CLOUD_API_URL", defaultCloudAPIURL), "Relayfile Cloud API URL")
cloudToken := fs.String("cloud-token", strings.TrimSpace(os.Getenv("RELAYFILE_CLOUD_TOKEN")), "Relayfile Cloud access token; skips browser login when set")
apiKey := fs.Bool("api-key", false, "use the legacy API-key flow against --server instead of the cloud browser login")
noOpen := fs.Bool("no-open", false, "print the cloud sign-in URL instead of opening it")
loginTimeout := fs.Duration("login-timeout", 5*time.Minute, "cloud login timeout")
if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
"server": true,
"token": true,
"cloud-api-url": true,
"cloud-token": true,
"api-key": false,
"no-open": false,
"login-timeout": true,
})); err != nil {
return err
}

serverValue := strings.TrimSpace(*server)
if serverValue == "" {
serverValue = defaultServerURL
}
tokenValue := strings.TrimSpace(*token)
if tokenValue == "" {

// Token explicitly provided → legacy server-credential flow.
if tokenValue != "" {
return loginWithAPIKey(strings.TrimSpace(*server), tokenValue, stdout)
}

// Legacy interactive prompt, opt-in.
if *apiKey {
prompted, err := promptLine(stdin, stdout, "API key: ")
if err != nil {
return err
}
tokenValue = strings.TrimSpace(prompted)
prompted = strings.TrimSpace(prompted)
if prompted == "" {
return errors.New("token is required")
}
return loginWithAPIKey(strings.TrimSpace(*server), prompted, stdout)
}
if tokenValue == "" {
return errors.New("token is required")

// Default: cloud browser flow.
cloudAPI := strings.TrimRight(strings.TrimSpace(*cloudAPIURL), "/")
if cloudAPI == "" {
cloudAPI = defaultCloudAPIURL
}
creds, err := ensureCloudCredentials(cloudAPI, strings.TrimSpace(*cloudToken), *loginTimeout, !*noOpen, stdout)
if err != nil {
return err
}
fmt.Fprintf(stdout, "Signed in to Relayfile Cloud at %s\n", creds.APIURL)
fmt.Fprintln(stdout, "Run 'relayfile setup' to create or join a workspace, or 'relayfile mount WORKSPACE' if you already have one.")
return nil
}

func loginWithAPIKey(serverValue, tokenValue string, stdout io.Writer) error {
serverValue = strings.TrimSpace(serverValue)
if serverValue == "" {
serverValue = defaultServerURL
}
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(serverValue, "/")+"/health", nil)
if err != nil {
return err
Expand Down
104 changes: 104 additions & 0 deletions cmd/relayfile-cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1499,3 +1499,107 @@ func TestOpsReplayPostsToCloudAndRemovesLocalRecord(t *testing.T) {
t.Fatalf("expected dead-letter record removed, got err=%v", err)
}
}

// TestLoginWithExplicitTokenPersistsServerCreds covers the legacy API-key
// path: when --token is supplied, runLogin validates it against /health and
// writes ~/.relayfile/credentials.json (no cloud creds touched).
func TestLoginWithExplicitTokenPersistsServerCreds(t *testing.T) {
t.Setenv("HOME", t.TempDir())
clearRelayfileEnv(t)

var healthCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/health" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer rf_test" {
t.Fatalf("unexpected Authorization: %q", got)
}
healthCalls++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

var stdout bytes.Buffer
if err := run([]string{"login", "--server", server.URL, "--token", "rf_test"}, strings.NewReader(""), &stdout, &stdout); err != nil {
t.Fatalf("run login failed: %v\noutput:\n%s", err, stdout.String())
}
if healthCalls != 1 {
t.Fatalf("expected 1 /health call, got %d", healthCalls)
}
creds, err := loadCredentials()
if err != nil {
t.Fatalf("loadCredentials failed: %v", err)
}
if creds.Token != "rf_test" {
t.Fatalf("expected stored token rf_test, got %q", creds.Token)
}
if _, err := os.Stat(cloudCredentialsPath()); !os.IsNotExist(err) {
t.Fatalf("expected no cloud credentials file written, got err=%v", err)
}
}

// TestLoginDefaultsToCloudBrowserFlow covers the new default behavior: when
// no --token is provided, runLogin runs the cloud browser flow. We use
// --cloud-token to skip the actual browser handshake — ensureCloudCredentials
// short-circuits to writing cloud credentials when an explicit token is set.
func TestLoginDefaultsToCloudBrowserFlow(t *testing.T) {
t.Setenv("HOME", t.TempDir())
clearRelayfileEnv(t)

var stdout bytes.Buffer
if err := run([]string{
"login",
"--cloud-api-url", "https://cloud.relayfile.test",
"--cloud-token", "cld_browser_token",
}, strings.NewReader(""), &stdout, &stdout); err != nil {
t.Fatalf("run login failed: %v\noutput:\n%s", err, stdout.String())
}

got := stdout.String()
if !strings.Contains(got, "Signed in to Relayfile Cloud") {
t.Fatalf("expected cloud sign-in confirmation, got %q", got)
}
creds, err := loadCloudCredentials()
if err != nil {
t.Fatalf("loadCloudCredentials failed: %v", err)
}
if creds.AccessToken != "cld_browser_token" {
t.Fatalf("expected stored cloud access token, got %q", creds.AccessToken)
}
if creds.APIURL != "https://cloud.relayfile.test" {
t.Fatalf("expected APIURL stored, got %q", creds.APIURL)
}
if _, err := os.Stat(credentialsPath()); !os.IsNotExist(err) {
t.Fatalf("expected no server credentials written, got err=%v", err)
}
}

// TestLoginAPIKeyFlagPromptsForToken covers the opt-in legacy interactive
// flow: --api-key forces runLogin to prompt for an API key on stdin instead
// of running the cloud browser flow.
func TestLoginAPIKeyFlagPromptsForToken(t *testing.T) {
t.Setenv("HOME", t.TempDir())
clearRelayfileEnv(t)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer prompted_key" {
t.Fatalf("unexpected Authorization: %q", got)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

stdin := strings.NewReader("prompted_key\n")
var stdout bytes.Buffer
if err := run([]string{"login", "--api-key", "--server", server.URL}, stdin, &stdout, &stdout); err != nil {
t.Fatalf("run login --api-key failed: %v\noutput:\n%s", err, stdout.String())
}
creds, err := loadCredentials()
if err != nil {
t.Fatalf("loadCredentials failed: %v", err)
}
if creds.Token != "prompted_key" {
t.Fatalf("expected stored token prompted_key, got %q", creds.Token)
}
}
Loading