diff --git a/.trajectories/index.json b/.trajectories/index.json index b8d70e4..67603b6 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-05-21T09:02:24.648Z", + "lastUpdated": "2026-05-21T13:20:34.829Z", "trajectories": { "traj_mfyus7zfgxt2": { "title": "062-sdk-setup-client-workflow", @@ -270,6 +270,13 @@ "startedAt": "2026-05-21T08:54:30.035Z", "completedAt": "2026-05-21T08:54:30.166Z", "path": "/Users/khaliqgant/Projects/AgentWorkforce/.msd-autofix-b09c607b/.trajectories/completed/2026-05/traj_5310ok4zat2n.json" + }, + "traj_eud9obtnr1br": { + "title": "Fix relayfile auth refresh and status warnings for issues 188 and 189", + "status": "completed", + "startedAt": "2026-05-21T13:14:23.504Z", + "completedAt": "2026-05-21T13:20:34.729Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile-issues-188-189/.trajectories/completed/2026-05/traj_eud9obtnr1br.json" } } } \ No newline at end of file diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 81bc7aa..66d8435 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -1863,6 +1863,9 @@ func runLogin(args []string, stdin io.Reader, stdout io.Writer) error { } } + if err := removeCredentialFile(credentialsPath()); err != nil { + fmt.Fprintf(stdout, "warning: could not clear stale server credentials: %v\n", err) + } fmt.Fprintln(stdout, "Run 'relayfile setup' to create or join a workspace, or 'relayfile mount WORKSPACE' if you already have one.") return nil } @@ -1895,6 +1898,9 @@ func loginWithAPIKey(serverValue, tokenValue string, stdout io.Writer) error { if err := saveCredentials(creds); err != nil { return err } + if err := removeCredentialFile(cloudCredentialsPath()); err != nil { + return fmt.Errorf("clear stale cloud credentials: %w", err) + } fmt.Fprintf(stdout, "Stored credentials for %s\n", creds.Server) return nil } @@ -4437,6 +4443,9 @@ func runStatus(args []string, stdout io.Writer) error { } status, err := fetchWorkspaceSyncStatus(client, workspaceID) if err != nil { + if isUnauthorizedAPIError(err) { + return ErrCloudRefreshExpired + } return err } var ingress *syncIngressStatusResponse @@ -4459,6 +4468,9 @@ func runStatus(args []string, stdout io.Writer) error { workspaceLabel = fmt.Sprintf("%s (%s)", workspaceID, record.Name) } fmt.Fprintf(stdout, "workspace %s mode: %s lag: %s\n", workspaceLabel, snapshot.Mode, formatLag(maxLagSeconds(status.Providers))) + if authLine := statusAuthLine(record.LocalDir, time.Now().UTC()); authLine != "" { + fmt.Fprintln(stdout, authLine) + } for _, provider := range status.Providers { lastEvent := "-" if hasNonEmptyString(provider.WatermarkTs) { @@ -4521,6 +4533,87 @@ func readPersistedStallReason(localDir string) string { return strings.TrimSpace(snapshot.StallReason) } +func statusAuthLine(localDir string, now time.Time) string { + if line := daemonCredentialFreshnessAuthLine(localDir); line != "" { + return line + } + return cloudCredentialAuthLine(now) +} + +func daemonCredentialFreshnessAuthLine(localDir string) string { + state, ok := readDaemonPIDState(localDir) + if !ok || strings.TrimSpace(state.StartedAt) == "" { + return "" + } + startedAt, ok := parseRFC3339(state.StartedAt) + if !ok { + return "" + } + latest, ok := latestCredentialModTime(credentialsPath(), cloudCredentialsPath()) + if ok && latest.After(startedAt) { + return "auth: daemon predates last login - restart the daemon" + } + return "" +} + +func latestCredentialModTime(paths ...string) (time.Time, bool) { + var latest time.Time + found := false + for _, path := range paths { + info, err := os.Stat(path) + if err != nil { + continue + } + if !found || info.ModTime().After(latest) { + latest = info.ModTime() + found = true + } + } + return latest, found +} + +func cloudCredentialAuthLine(now time.Time) string { + if _, err := os.Stat(cloudCredentialsPath()); errors.Is(err, os.ErrNotExist) { + return "auth: server credentials only" + } + creds, err := loadCloudCredentials() + if err != nil { + return "auth: cloud credentials unreadable - run 'relayfile login'" + } + if strings.TrimSpace(creds.AccessToken) == "" { + return "auth: cloud access token missing - run 'relayfile login'" + } + if expiry, ok := parseRFC3339(creds.RefreshTokenExpiresAt); ok && !now.Before(expiry) { + return "auth: cloud session expired - run 'relayfile login'" + } + if expiry, ok := parseRFC3339(creds.AccessTokenExpiresAt); ok { + remaining := expiry.Sub(now) + if remaining <= 0 { + return "auth: access token expired - run 'relayfile login'" + } + if remaining <= 15*time.Minute { + return fmt.Sprintf("auth: access token expires in %s", formatAuthDuration(remaining)) + } + } + return "auth: ok" +} + +func formatAuthDuration(d time.Duration) string { + if d <= 0 { + return "0m" + } + minutes := int((d + time.Minute - time.Nanosecond) / time.Minute) + if minutes < 1 { + return "<1m" + } + return fmt.Sprintf("%dm", minutes) +} + +func isUnauthorizedAPIError(err error) bool { + var apiErr *apiError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized +} + func runStop(args []string, stdout io.Writer) error { fs := flag.NewFlagSet("stop", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -4986,6 +5079,13 @@ func saveCredentials(creds credentials) error { return writeFileAtomically(credentialsPath(), payload, 0o600) } +func removeCredentialFile(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + func saveCloudCredentials(creds cloudCredentials) error { if err := ensureConfigDir(); err != nil { return err diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index ad7b41b..7a8b1bc 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -1051,6 +1051,128 @@ func TestStatusIncludesLocalMirrorAndDaemonCounts(t *testing.T) { } } +func TestStatusRendersExpiredCloudSessionAuthLine(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + if err := ensureMirrorLayout(localDir); err != nil { + t.Fatalf("ensureMirrorLayout failed: %v", err) + } + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + LastUsedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("upsertWorkspaceDetails failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"workspaceId":"ws_demo","providers":[]}`)) + })) + defer server.Close() + if err := saveCredentials(credentials{Server: server.URL, Token: "token"}); err != nil { + t.Fatalf("saveCredentials failed: %v", err) + } + if err := saveCloudCredentials(cloudCredentials{ + APIURL: "https://cloud.relayfile.test", + AccessToken: "cloud_token", + AccessTokenExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339), + RefreshToken: "refresh_token", + RefreshTokenExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339), + }); err != nil { + t.Fatalf("saveCloudCredentials failed: %v", err) + } + + var stdout bytes.Buffer + if err := run([]string{"status", "demo"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("run status failed: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "auth: cloud session expired - run 'relayfile login'") { + t.Fatalf("expected expired auth line, got %q", got) + } +} + +func TestStatusWarnsWhenDaemonPredatesLastLogin(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + localDir := t.TempDir() + if err := ensureMirrorLayout(localDir); err != nil { + t.Fatalf("ensureMirrorLayout failed: %v", err) + } + startedAt := time.Now().UTC().Add(-time.Hour) + if err := writeDaemonPIDState(mountPIDFile(localDir), daemonPIDState{ + PID: 4242, + WorkspaceID: "ws_demo", + LocalDir: localDir, + StartedAt: startedAt.Format(time.RFC3339), + }); err != nil { + t.Fatalf("writeDaemonPIDState failed: %v", err) + } + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "demo", + ID: "ws_demo", + LocalDir: localDir, + CreatedAt: startedAt.Format(time.RFC3339), + LastUsedAt: startedAt.Format(time.RFC3339), + }); err != nil { + t.Fatalf("upsertWorkspaceDetails failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"workspaceId":"ws_demo","providers":[]}`)) + })) + defer server.Close() + if err := saveCredentials(credentials{Server: server.URL, Token: "token"}); err != nil { + t.Fatalf("saveCredentials failed: %v", err) + } + loginTime := startedAt.Add(30 * time.Minute) + if err := os.Chtimes(credentialsPath(), loginTime, loginTime); err != nil { + t.Fatalf("chtimes credentials failed: %v", err) + } + + var stdout bytes.Buffer + if err := run([]string{"status", "demo"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("run status failed: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "auth: daemon predates last login - restart the daemon") { + t.Fatalf("expected daemon stale auth line, got %q", got) + } +} + +func TestStatusUnauthorizedReturnsLoginHint(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "demo", + ID: "ws_demo", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatalf("upsertWorkspaceDetails failed: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Token has expired"}`)) + })) + defer server.Close() + if err := saveCredentials(credentials{Server: server.URL, Token: "expired"}); err != nil { + t.Fatalf("saveCredentials failed: %v", err) + } + + var stdout bytes.Buffer + err := run([]string{"status", "demo"}, strings.NewReader(""), &stdout, &stdout) + if !errors.Is(err, ErrCloudRefreshExpired) { + t.Fatalf("expected ErrCloudRefreshExpired, got %v", err) + } +} + func TestStatusSurfacesOrphanDaemonFromProcessScan(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) @@ -2599,6 +2721,12 @@ func TestOpsReplayPostsToCloudAndRemovesLocalRecord(t *testing.T) { func TestLoginWithExplicitTokenPersistsServerCreds(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) + if err := saveCloudCredentials(cloudCredentials{ + APIURL: "https://cloud.relayfile.test", + AccessToken: "stale_cloud_token", + }); err != nil { + t.Fatalf("saveCloudCredentials failed: %v", err) + } var healthCalls int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -2628,7 +2756,7 @@ func TestLoginWithExplicitTokenPersistsServerCreds(t *testing.T) { 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) + t.Fatalf("expected stale cloud credentials removed, got err=%v", err) } } @@ -2639,6 +2767,12 @@ func TestLoginWithExplicitTokenPersistsServerCreds(t *testing.T) { func TestLoginDefaultsToCloudBrowserFlow(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) + if err := saveCredentials(credentials{ + Server: "https://relayfile-old.test", + Token: "stale_server_token", + }); err != nil { + t.Fatalf("saveCredentials failed: %v", err) + } var stdout bytes.Buffer if err := run([]string{ @@ -2664,7 +2798,7 @@ func TestLoginDefaultsToCloudBrowserFlow(t *testing.T) { 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) + t.Fatalf("expected stale server credentials removed, got err=%v", err) } } @@ -2731,6 +2865,19 @@ func TestLoginRefreshesWorkspaceTokenForDefaultWorkspace(t *testing.T) { if creds.Server != "https://relayfile.test" { t.Fatalf("expected refreshed server URL, got %q", creds.Server) } + if strings.TrimSpace(creds.UpdatedAt) == "" { + t.Fatalf("expected credentials updatedAt to be set") + } + cloud, err := loadCloudCredentials() + if err != nil { + t.Fatalf("loadCloudCredentials failed: %v", err) + } + if cloud.AccessToken != "cld_browser_token" { + t.Fatalf("expected refreshed cloud token to remain stored, got %q", cloud.AccessToken) + } + if strings.TrimSpace(cloud.UpdatedAt) == "" { + t.Fatalf("expected cloud credentials updatedAt to be set") + } } // TestLoginSkipsWorkspaceRefreshWhenFlagSet covers --skip-workspace-refresh: