diff --git a/cmd/relayfile-cli/control_plane_test.go b/cmd/relayfile-cli/control_plane_test.go index 0f4d3e61..7a574018 100644 --- a/cmd/relayfile-cli/control_plane_test.go +++ b/cmd/relayfile-cli/control_plane_test.go @@ -421,6 +421,55 @@ func TestControlPlaneCloudIntegrationConformance(t *testing.T) { } } +func TestControlPlaneProviderStatusUsesCloudTokenFromEnvironment(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + if err := saveWorkspaceCatalog(workspaceCatalog{ + Default: "demo", + Workspaces: []workspaceRecord{{ + Name: "demo", + ID: "ws_123", + LocalDir: t.TempDir(), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }}, + }); err != nil { + t.Fatalf("save workspace catalog failed: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/workspaces/ws_123/integrations" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer cld_explicit" { + t.Fatalf("unexpected Authorization: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"provider":"github","status":"ready"}]`)) + })) + defer server.Close() + t.Setenv("RELAYFILE_CLOUD_API_URL", server.URL) + t.Setenv("RELAYFILE_CLOUD_TOKEN", "cld_explicit") + t.Setenv("AGENT_RELAY_BIN", filepath.Join(t.TempDir(), "missing-agent-relay")) + + client, baseURL, cleanup := startControlPlaneTestServer(t) + defer cleanup() + var statusEntry cloudIntegrationListEntry + status := controlPlaneJSON( + t, + client, + http.MethodGet, + baseURL+"/v1/integrations/provider-status?provider=github&workspace=demo", + nil, + &statusEntry, + ) + if status != http.StatusOK { + t.Fatalf("provider-status status = %d", status) + } + if statusEntry.Provider != "github" || statusEntry.Status != "ready" { + t.Fatalf("unexpected provider status: %#v", statusEntry) + } +} + func startControlPlaneTestServer(t *testing.T) (*http.Client, string, func()) { t.Helper() sock := filepath.Join(os.TempDir(), fmt.Sprintf("rfcp-%d-%d.sock", os.Getpid(), time.Now().UnixNano())) diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 37085fda..058cad84 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -3550,21 +3550,28 @@ func runIntegrationList(args []string, stdout io.Writer) error { workspaceName := fs.String("workspace", "", "workspace name or id") jsonOutput := fs.Bool("json", false, "emit JSON") cloudAPIURL := fs.String("cloud-api-url", envOrDefault("RELAYFILE_CLOUD_API_URL", defaultCloudAPIURL), "Relayfile Cloud API URL") + inheritedCloudToken := strings.TrimSpace(os.Getenv("RELAYFILE_CLOUD_TOKEN")) + cloudToken := fs.String("cloud-token", inheritedCloudToken, "Relayfile Cloud access token") if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{ "workspace": true, "json": false, "cloud-api-url": true, + "cloud-token": true, })); err != nil { return err } if fs.NArg() > 0 { - return errors.New("usage: relayfile integration list [--workspace NAME] [--json]") + return errors.New("usage: relayfile integration list [--workspace NAME] [--json] [--cloud-token TOKEN]") } + cloudTokenPassedExplicitly := false + fs.Visit(func(item *flag.Flag) { + cloudTokenPassedExplicitly = cloudTokenPassedExplicitly || item.Name == "cloud-token" + }) record, err := resolveWorkspaceRecord(strings.TrimSpace(*workspaceName)) if err != nil { return err } - cloudCreds, err := ensureCloudCredentials(strings.TrimSpace(*cloudAPIURL), "", 5*time.Minute, false, io.Discard) + cloudCreds, err := ensureCloudCredentials(strings.TrimSpace(*cloudAPIURL), strings.TrimSpace(*cloudToken), 5*time.Minute, false, io.Discard) if err != nil { return err } @@ -3573,7 +3580,28 @@ func runIntegrationList(args []string, stdout io.Writer) error { return err } var entries []cloudIntegrationListEntry - if err := client.getJSON(context.Background(), fmt.Sprintf("/api/v1/workspaces/%s/integrations", url.PathEscape(record.ID)), &entries); err != nil { + integrationsPath := fmt.Sprintf("/api/v1/workspaces/%s/integrations", url.PathEscape(record.ID)) + err = client.getJSON(context.Background(), integrationsPath, &entries) + if err != nil && inheritedCloudToken != "" && !cloudTokenPassedExplicitly && isAPIAuthError(err) { + // A detached control-plane daemon inherits its environment once, while + // Cloud access tokens rotate. On an authentication failure, refresh from + // the canonical Agent Relay session and retry once. An explicit CLI flag + // remains authoritative and is never silently replaced. Preserve the + // caller-selected endpoint: the refreshed session supplies only the new + // token and may belong to a different default Cloud deployment. + refreshAPIURL := client.baseURL + cloudCreds, refreshErr := ensureCloudCredentials(strings.TrimSpace(*cloudAPIURL), "", 5*time.Minute, false, io.Discard) + if refreshErr != nil { + return refreshErr + } + client, refreshErr = newAPIClient(refreshAPIURL, cloudCreds.AccessToken) + if refreshErr != nil { + return refreshErr + } + entries = nil + err = client.getJSON(context.Background(), integrationsPath, &entries) + } + if err != nil { return err } entries = overlayIntegrationListRuntimeStatus(entries, record) @@ -3637,7 +3665,13 @@ func runtimeSyncStatusForIntegrationList(record workspaceRecord) (syncStatusResp if runtimeWorkspaceID == "" { runtimeWorkspaceID = workspaceID } - status, err := fetchWorkspaceSyncStatus(client, runtimeWorkspaceID) + // Runtime status only enriches the authoritative Cloud integration list. + // Keep that optional overlay well inside the control-plane client's deadline + // so an unhealthy data plane cannot make provider-status time out. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var status syncStatusResponse + err = client.getJSON(ctx, fmt.Sprintf("/v1/workspaces/%s/sync/status", url.PathEscape(runtimeWorkspaceID)), &status) if err != nil { return syncStatusResponse{}, false } diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 7af83e1b..48c06e25 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -1798,6 +1798,117 @@ func TestIntegrationListOverlaysRuntimeReadyStatus(t *testing.T) { } } +func TestIntegrationListUsesCloudTokenFromEnvironment(t *testing.T) { + _, _ = setupAdoptWorkspace(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/workspaces/ws_123/integrations" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer cld_explicit" { + t.Fatalf("unexpected Authorization: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"provider":"github","status":"ready","lagSeconds":0}]`)) + })) + defer server.Close() + t.Setenv("RELAYFILE_CLOUD_API_URL", server.URL) + t.Setenv("RELAYFILE_CLOUD_TOKEN", "cld_explicit") + t.Setenv("AGENT_RELAY_BIN", filepath.Join(t.TempDir(), "missing-agent-relay")) + + var stdout bytes.Buffer + if err := run([]string{"integration", "list", "--workspace", "demo", "--json"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("integration list failed: %v\noutput:\n%s", err, stdout.String()) + } + if got := stdout.String(); !strings.Contains(got, `"provider": "github"`) { + t.Fatalf("expected github integration JSON, got %q", got) + } +} + +func TestIntegrationListRefreshesInheritedCloudTokenWithoutChangingCloudAPIURL(t *testing.T) { + _, _ = setupAdoptWorkspace(t) + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/workspaces/ws_123/integrations" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + requestCount.Add(1) + w.Header().Set("Content-Type", "application/json") + switch got := r.Header.Get("Authorization"); got { + case "Bearer cld_expired": + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":"unauthorized","message":"expired token"}`)) + case "Bearer cld_refreshed": + _, _ = w.Write([]byte(`[{"provider":"github","status":"ready","lagSeconds":0}]`)) + default: + t.Fatalf("unexpected Authorization: %q", got) + } + })) + defer server.Close() + + // Session discovery may report a different default deployment. It is the + // canonical source of the refreshed token, not an override for the endpoint + // explicitly selected by this integration-list invocation. + var sessionAPIRequests atomic.Int32 + sessionAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionAPIRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"provider":"wrong-deployment","status":"ready"}]`)) + })) + defer sessionAPI.Close() + installFakeAgentRelaySession(t, sessionAPI.URL, "cld_refreshed", "demo", "ws_123", "ws_123") + t.Setenv("RELAYFILE_CLOUD_API_URL", server.URL) + t.Setenv("RELAYFILE_CLOUD_TOKEN", "cld_expired") + + var stdout bytes.Buffer + if err := run([]string{"integration", "list", "--workspace", "demo", "--json"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("integration list failed: %v\noutput:\n%s", err, stdout.String()) + } + if got := requestCount.Load(); got != 2 { + t.Fatalf("integration list request count = %d, want 2", got) + } + if got := sessionAPIRequests.Load(); got != 0 { + t.Fatalf("refreshed token changed the selected Cloud API URL; session endpoint requests = %d, want 0", got) + } + if got := stdout.String(); !strings.Contains(got, `"provider": "github"`) { + t.Fatalf("expected github integration JSON, got %q", got) + } +} + +func TestIntegrationListBoundsOptionalRuntimeStatusOverlay(t *testing.T) { + _, _ = setupAdoptWorkspace(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/workspaces/ws_123/integrations": + _, _ = w.Write([]byte(`[{"provider":"github","status":"connected","lagSeconds":0}]`)) + case "/v1/workspaces/ws_123/sync/status": + <-r.Context().Done() + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + t.Setenv("RELAYFILE_CLOUD_API_URL", server.URL) + t.Setenv("RELAYFILE_CLOUD_TOKEN", "cld_explicit") + writeDelegatedCredentialsForTest(t, delegatedauth.Bundle{ + RelayfileURL: server.URL, + RelayfileWorkspaceID: "ws_123", + AccessToken: "rf_join", + }) + + started := time.Now() + var stdout bytes.Buffer + if err := run([]string{"integration", "list", "--workspace", "demo", "--json"}, strings.NewReader(""), &stdout, &stdout); err != nil { + t.Fatalf("integration list failed: %v\noutput:\n%s", err, stdout.String()) + } + if elapsed := time.Since(started); elapsed > 3*time.Second { + t.Fatalf("optional runtime overlay delayed integration list for %s", elapsed) + } + if got := stdout.String(); !strings.Contains(got, `"status": "connected"`) { + t.Fatalf("expected authoritative Cloud status after overlay timeout, got %q", got) + } +} + func TestIntegrationListUsesSavedWorkspaceScopesForRuntimeStatus(t *testing.T) { record, _ := setupAdoptWorkspace(t) var seenRuntimeStatus bool diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index e5084efa..3c3a8686 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `relayfile integration list` and the local integration control plane now honor `RELAYFILE_CLOUD_TOKEN` and bound optional runtime-status enrichment, avoiding provider-status timeouts when explicit Cloud credentials are available or the runtime data plane is slow. - `relayfile status` now distinguishes queue lag from provider-event silence, warns when a feed has been idle for 24 hours by default (configurable via `RELAYFILE_EVENT_SILENCE_THRESHOLD`), and exposes `eventStatus` and `eventIdleSeconds` in JSON output. ## [0.10.39] - 2026-07-31