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
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-21T09:02:24.648Z",
"lastUpdated": "2026-05-21T13:20:34.829Z",
"trajectories": {
"traj_mfyus7zfgxt2": {
"title": "062-sdk-setup-client-workflow",
Expand Down Expand Up @@ -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"
}
}
}
100 changes: 100 additions & 0 deletions cmd/relayfile-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines +1866 to +1868
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 --skip-workspace-refresh deletes existing server credentials despite user opting out of workspace changes

The new removeCredentialFile(credentialsPath()) call at cmd/relayfile-cli/main.go:1866 runs unconditionally after the if !*skipWorkspace { ... } block (lines 1843–1864). When --skip-workspace-refresh is true, the entire workspace-refresh block is skipped, so execution falls straight through to line 1866 which deletes the server credentials file.

This breaks the following workflow:

  1. User runs relayfile setup → workspace created, server credentials saved
  2. User runs relayfile login --skip-workspace-refresh → intends to only refresh cloud auth
  3. Server credentials (workspace token) are deleted
  4. Subsequent commands like relayfile status or relayfile mount fail with "credentials not found"

The flag's documented semantics are "sign into the cloud only; do not refresh the workspace token" — removing the workspace token violates this contract. The existing test TestLoginSkipsWorkspaceRefreshWhenFlagSet doesn't catch this because it never pre-saves server credentials before running the command.

Prompt for agents
In runLogin (cmd/relayfile-cli/main.go), the removeCredentialFile(credentialsPath()) call at line 1866 runs even when *skipWorkspace is true, which deletes the user's existing workspace token despite them explicitly opting out of workspace changes via --skip-workspace-refresh.

The fix should guard the credential removal so it only runs when the cloud flow actually attempted (and failed) a workspace refresh. One approach: wrap the removal in an if !*skipWorkspace check, or move it inside the existing if !*skipWorkspace block (after line 1864, before the closing brace). The setup message on line 1869 should probably also be guarded by the same condition, since telling a --skip-workspace-refresh user to run relayfile setup is confusing when they already have a workspace.

Also update TestLoginSkipsWorkspaceRefreshWhenFlagSet to pre-save server credentials and verify they survive the login command.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

fmt.Fprintln(stdout, "Run 'relayfile setup' to create or join a workspace, or 'relayfile mount WORKSPACE' if you already have one.")
return nil
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
151 changes: 149 additions & 2 deletions cmd/relayfile-cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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{
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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:
Expand Down
Loading