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
49 changes: 49 additions & 0 deletions cmd/relayfile-cli/control_plane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
42 changes: 38 additions & 4 deletions cmd/relayfile-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
khaliqgant marked this conversation as resolved.
if err != nil {
return err
}
Expand All @@ -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)
Comment thread
khaliqgant marked this conversation as resolved.
}
if err != nil {
return err
}
entries = overlayIntegrationListRuntimeStatus(entries, record)
Expand Down Expand Up @@ -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
}
Expand Down
111 changes: 111 additions & 0 deletions cmd/relayfile-cli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
khaliqgant marked this conversation as resolved.
- `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
Expand Down
Loading