diff --git a/cmd/relayfile-cli/cloudauth.go b/cmd/relayfile-cli/cloudauth.go new file mode 100644 index 00000000..de4a170f --- /dev/null +++ b/cmd/relayfile-cli/cloudauth.go @@ -0,0 +1,383 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Agent Relay's cloud session lives in exactly one place, and relayfile reads +// it the same way every other Agent Relay client does: +// +// 1. the CLOUD_API_* environment overrides (CI / non-interactive), then +// 2. the canonical credential file written by `agent-relay cloud login`. +// +// This mirrors, field for field, relay's packages/cloud/src/auth.ts +// (readStoredAuth / requestStoredAuthRefresh / writeStoredAuth) and relayfile's +// own packages/agents/src/connect.ts (readCloudCreds). It is deliberately NOT a +// third credential store: the file below is relay's file, the refresh endpoint +// below is relay's endpoint, and the rotated tokens are written back so the +// next `agent-relay` command sees them. +// +// relayfile used to obtain this session by shelling out to +// `agent-relay cloud session --json --reveal-token`. That coupled a routine +// token expiry to the ability to locate and execute a Node CLI, and the binary +// it executed was chosen by AGENT_RELAY_BIN — a variable relay uses for the +// *broker*, not the CLI. Under any relay-spawned agent that variable points at +// agent-relay-broker, which has no `cloud` subcommand, so auto-recovery from an +// expired access token failed and reported itself as a CLI version problem. + +const ( + // Same windows as relay packages/cloud/src/types.ts. + agentRelayAccessTokenRefreshWindow = 5 * time.Minute + agentRelayRefreshTokenRefreshWindow = 24 * time.Hour + + // relay's DEFAULT_REFRESH_TIMEOUT_MS (packages/cloud/src/types.ts:278). + // This MUST stay well below agentRelayAuthLockStaleAfter: the lock's mtime + // is not heartbeaten while the holder waits on the refresh HTTP call, so a + // holder that can outlive the stale window would have its lock reclaimed + // mid-flight and two processes would refresh the same single-use token. + // relay keeps a 3x margin (10s vs 30s); matching its constant restores it. + agentRelayCloudRefreshTimeout = 10 * time.Second + + // Same lock discipline as relay packages/cloud/src/auth.ts, so a relayfile + // refresh and an agent-relay refresh cannot interleave on the same file. + // These must stay equal to relay's: a longer stale window here would let + // relayfile reclaim a lock relay still considers live, and a shorter one + // would let relay reclaim relayfile's. + agentRelayAuthLockRetryDelay = 50 * time.Millisecond + agentRelayAuthLockStaleAfter = 30 * time.Second + agentRelayAuthLockTimeout = 30 * time.Second +) + +// agentRelayStoredAuth is the on-disk shape of cloud-auth.json. +type agentRelayStoredAuth struct { + APIURL string `json:"apiUrl"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + AccessTokenExpiresAt string `json:"accessTokenExpiresAt"` + RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt,omitempty"` +} + +// agentRelayCloudSessionSource records where a session came from, so error +// messages and `relayfile status` can name it. +type agentRelayCloudSessionSource string + +const ( + agentRelayCloudSessionFromEnv agentRelayCloudSessionSource = "CLOUD_API_* environment" + agentRelayCloudSessionFromFile agentRelayCloudSessionSource = "cloud-auth.json" +) + +// agentRelayCloudAuthPath resolves the canonical credential file. It returns an +// error rather than a relative fallback when the home directory cannot be +// resolved: a relative path would read and, after a refresh, *write* Cloud +// tokens into the process's working directory — usually a repository — and that +// copy would be invisible to `agent-relay`, silently forking the session. +func agentRelayCloudAuthPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("locate the Agent Relay cloud session: resolve the home directory: %w", err) + } + return filepath.Join(home, ".agentworkforce", "relay", "cloud-auth.json"), nil +} + +func agentRelayCloudAuthLockPath() (string, error) { + path, err := agentRelayCloudAuthPath() + if err != nil { + return "", err + } + return path + ".lock", nil +} + +func (a agentRelayStoredAuth) valid() bool { + if strings.TrimSpace(a.AccessToken) == "" || + strings.TrimSpace(a.RefreshToken) == "" || + strings.TrimSpace(a.AccessTokenExpiresAt) == "" || + strings.TrimSpace(a.APIURL) == "" { + return false + } + if _, ok := parseRFC3339(a.AccessTokenExpiresAt); !ok { + return false + } + if strings.TrimSpace(a.RefreshTokenExpiresAt) != "" { + if _, ok := parseRFC3339(a.RefreshTokenExpiresAt); !ok { + return false + } + } + return true +} + +// needsRefresh mirrors relay's shouldRefreshStoredAuth: roll the access token +// inside its window, and roll the pair early when the refresh token itself is +// within a day of expiring. +func (a agentRelayStoredAuth) needsRefresh(now time.Time) bool { + // Never attempt a refresh without a refresh credential to present. This + // matters for the access-token-only environment shape, where there is + // nothing to roll and rolling would fail rather than recover. + if strings.TrimSpace(a.RefreshToken) == "" { + return false + } + expiresAt, ok := parseRFC3339(a.AccessTokenExpiresAt) + if !ok { + return true + } + if expiresAt.Sub(now) <= agentRelayAccessTokenRefreshWindow { + return true + } + if strings.TrimSpace(a.RefreshTokenExpiresAt) == "" { + return false + } + refreshExpiresAt, ok := parseRFC3339(a.RefreshTokenExpiresAt) + if !ok { + return true + } + return refreshExpiresAt.Sub(now) <= agentRelayRefreshTokenRefreshWindow +} + +// agentRelayStoredAuthFromEnv reads the non-interactive escape hatch. The +// contract is relayfile's own, from packages/agents/src/connect.ts:93-108: an +// access token alone is a usable session. CI commonly exports only +// CLOUD_API_ACCESS_TOKEN, having no refresh token to give and no need of one. +// +// This is deliberately looser than relay's readEnvAuth, which requires the full +// quartet. relay can demand it because a partial set there falls through to a +// login flow; here it would fall through to an unrelated credential file, or +// to "no session exists" while the caller has plainly supplied one. +func agentRelayStoredAuthFromEnv() (agentRelayStoredAuth, bool) { + accessToken := strings.TrimSpace(os.Getenv("CLOUD_API_ACCESS_TOKEN")) + if accessToken == "" { + return agentRelayStoredAuth{}, false + } + auth := agentRelayStoredAuth{ + APIURL: strings.TrimSpace(os.Getenv("CLOUD_API_URL")), + AccessToken: accessToken, + RefreshToken: strings.TrimSpace(os.Getenv("CLOUD_API_REFRESH_TOKEN")), + AccessTokenExpiresAt: strings.TrimSpace(os.Getenv("CLOUD_API_ACCESS_TOKEN_EXPIRES_AT")), + RefreshTokenExpiresAt: strings.TrimSpace(os.Getenv("CLOUD_API_REFRESH_TOKEN_EXPIRES_AT")), + } + if auth.APIURL == "" { + auth.APIURL = defaultCloudAPIURL + } + if _, ok := parseRFC3339(auth.AccessTokenExpiresAt); !ok { + // Same defaulting as connect.ts: with no refresh token there is nothing + // to roll, so pin the expiry far out rather than treating an unstated + // expiry as "expired" and failing a session that works. + if auth.RefreshToken == "" { + auth.AccessTokenExpiresAt = time.Now().Add(365 * 24 * time.Hour).UTC().Format(time.RFC3339) + } else { + auth.AccessTokenExpiresAt = time.Now().Add(time.Minute).UTC().Format(time.RFC3339) + } + } + return auth, true +} + +func readAgentRelayStoredAuthFile() (agentRelayStoredAuth, error) { + path, err := agentRelayCloudAuthPath() + if err != nil { + return agentRelayStoredAuth{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return agentRelayStoredAuth{}, err + } + var auth agentRelayStoredAuth + if err := json.Unmarshal(data, &auth); err != nil { + return agentRelayStoredAuth{}, fmt.Errorf("parse %s: %w", path, err) + } + return auth, nil +} + +func writeAgentRelayStoredAuthFile(auth agentRelayStoredAuth) error { + path, err := agentRelayCloudAuthPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + payload, err := json.MarshalIndent(auth, "", " ") + if err != nil { + return err + } + payload = append(payload, '\n') + return writeFileAtomically(path, payload, 0o600) +} + +func acquireAgentRelayAuthLock(ctx context.Context) (func(), error) { + lockPath, err := agentRelayCloudAuthLockPath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { + return nil, err + } + release := func() { _ = os.RemoveAll(lockPath) } + deadline := time.Now().Add(agentRelayAuthLockTimeout) + for { + if err := os.Mkdir(lockPath, 0o700); err == nil { + return release, nil + } else if !errors.Is(err, os.ErrExist) { + return nil, err + } + // Reclaim a lock whose owner died mid-refresh. + if info, err := os.Stat(lockPath); err == nil && time.Since(info.ModTime()) >= agentRelayAuthLockStaleAfter { + _ = os.RemoveAll(lockPath) + continue + } else if errors.Is(err, os.ErrNotExist) { + continue + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timed out waiting for the Agent Relay cloud auth lock at %s", lockPath) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(agentRelayAuthLockRetryDelay): + } + } +} + +// refreshAgentRelayStoredAuth calls the same endpoint relay's CLI and the +// relayfile TypeScript SDK call: POST /api/v1/auth/token/refresh. +func refreshAgentRelayStoredAuth(ctx context.Context, auth agentRelayStoredAuth) (agentRelayStoredAuth, error) { + apiURL := strings.TrimRight(strings.TrimSpace(auth.APIURL), "/") + if apiURL == "" { + apiURL = defaultCloudAPIURL + } + endpoint := apiURL + "/api/v1/auth/token/refresh" + + body, err := json.Marshal(map[string]string{"refreshToken": auth.RefreshToken}) + if err != nil { + return agentRelayStoredAuth{}, err + } + ctx, cancel := context.WithTimeout(ctx, agentRelayCloudRefreshTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return agentRelayStoredAuth{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "relayfile-cli/"+relayfileVersion) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return agentRelayStoredAuth{}, fmt.Errorf("refresh the Agent Relay cloud session at %s: %w", endpoint, err) + } + defer func() { _ = resp.Body.Close() }() + + var payload agentRelayStoredAuth + decodeErr := json.NewDecoder(resp.Body).Decode(&payload) + if resp.StatusCode != http.StatusOK { + return agentRelayStoredAuth{}, fmt.Errorf( + "the stored Agent Relay cloud login has expired (%s returned HTTP %d); run `agent-relay cloud login`", + endpoint, resp.StatusCode, + ) + } + if decodeErr != nil { + return agentRelayStoredAuth{}, fmt.Errorf("parse the refresh response from %s: %w", endpoint, decodeErr) + } + if strings.TrimSpace(payload.AccessToken) == "" || + strings.TrimSpace(payload.RefreshToken) == "" || + strings.TrimSpace(payload.AccessTokenExpiresAt) == "" { + return agentRelayStoredAuth{}, fmt.Errorf( + "the refresh response from %s did not include a complete token set; run `agent-relay cloud login`", + endpoint, + ) + } + next := agentRelayStoredAuth{ + APIURL: firstNonEmpty(strings.TrimSpace(payload.APIURL), apiURL), + AccessToken: strings.TrimSpace(payload.AccessToken), + RefreshToken: strings.TrimSpace(payload.RefreshToken), + AccessTokenExpiresAt: strings.TrimSpace(payload.AccessTokenExpiresAt), + // Deliberately NOT inherited from the previous token when the server + // omits it. refreshTokenExpiresAt describes a specific refresh token, + // and the response carries a *new* one. Carrying the old token's expiry + // forward would re-arm the 24-hour refresh-token window immediately, so + // every subsequent command would rotate again — an endless refresh loop + // that also multiplies rotation races. Absent means unknown, and + // needsRefresh treats unknown as "do not force a refresh". + RefreshTokenExpiresAt: strings.TrimSpace(payload.RefreshTokenExpiresAt), + } + return next, nil +} + +// ensureAgentRelayCloudSession resolves a usable cloud session, rolling the +// access token in place when it is inside its refresh window. Rotated tokens +// are written back to the canonical file, because relay rotates the refresh +// token on every refresh: keeping the new pair to ourselves would invalidate +// the copy `agent-relay` reads. +func ensureAgentRelayCloudSession(ctx context.Context) (agentRelayStoredAuth, agentRelayCloudSessionSource, error) { + if auth, ok := agentRelayStoredAuthFromEnv(); ok { + // Environment-supplied sessions are owned by whoever exported them; + // never write them to disk, and never rotate them out from under that + // owner. Refresh in memory only when they are already stale. + if !auth.needsRefresh(time.Now()) { + return auth, agentRelayCloudSessionFromEnv, nil + } + refreshed, err := refreshAgentRelayStoredAuth(ctx, auth) + if err != nil { + return agentRelayStoredAuth{}, agentRelayCloudSessionFromEnv, err + } + return refreshed, agentRelayCloudSessionFromEnv, nil + } + + path, err := agentRelayCloudAuthPath() + if err != nil { + return agentRelayStoredAuth{}, agentRelayCloudSessionFromFile, err + } + auth, err := readAgentRelayStoredAuthFile() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return agentRelayStoredAuth{}, agentRelayCloudSessionFromFile, fmt.Errorf( + "no Agent Relay cloud session: %s does not exist. Run `agent-relay cloud login`, or set CLOUD_API_URL, CLOUD_API_ACCESS_TOKEN, CLOUD_API_REFRESH_TOKEN and CLOUD_API_ACCESS_TOKEN_EXPIRES_AT", + path, + ) + } + return agentRelayStoredAuth{}, agentRelayCloudSessionFromFile, err + } + if !auth.valid() { + return agentRelayStoredAuth{}, agentRelayCloudSessionFromFile, fmt.Errorf( + "the Agent Relay cloud session at %s is incomplete (needs apiUrl, accessToken, refreshToken and an RFC3339 accessTokenExpiresAt); run `agent-relay cloud login`", + path, + ) + } + if !auth.needsRefresh(time.Now()) { + return auth, agentRelayCloudSessionFromFile, nil + } + + release, err := acquireAgentRelayAuthLock(ctx) + if err != nil { + return agentRelayStoredAuth{}, agentRelayCloudSessionFromFile, err + } + defer release() + + // Another process may have refreshed — or a fresh `agent-relay cloud login` + // may have replaced the session entirely — while we waited for the lock. + // Re-read and ADOPT whatever is on disk: we hold the lock, so the file is + // now the authoritative session, and the pre-lock copy's refresh token may + // already have been rotated out from under us. Refreshing the stale copy + // would present a dead refresh token and, on success, overwrite the newer + // session — the lost update this double-check exists to prevent. + if latest, readErr := readAgentRelayStoredAuthFile(); readErr == nil && latest.valid() { + if !latest.needsRefresh(time.Now()) { + return latest, agentRelayCloudSessionFromFile, nil + } + auth = latest + } + + refreshed, err := refreshAgentRelayStoredAuth(ctx, auth) + if err != nil { + return agentRelayStoredAuth{}, agentRelayCloudSessionFromFile, err + } + if err := writeAgentRelayStoredAuthFile(refreshed); err != nil { + return agentRelayStoredAuth{}, agentRelayCloudSessionFromFile, fmt.Errorf("persist the refreshed Agent Relay cloud session to %s: %w", path, err) + } + return refreshed, agentRelayCloudSessionFromFile, nil +} diff --git a/cmd/relayfile-cli/cloudauth_test.go b/cmd/relayfile-cli/cloudauth_test.go new file mode 100644 index 00000000..ac2e797a --- /dev/null +++ b/cmd/relayfile-cli/cloudauth_test.go @@ -0,0 +1,673 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// installBrokerShapedAgentRelayBin reproduces the environment every +// relay-spawned agent runs in: AGENT_RELAY_BIN points at agent-relay-broker, +// a binary that answers `--version` but rejects every agent-relay CLI +// subcommand. It also empties PATH of a real agent-relay, so any surviving +// shell-out fails loudly rather than silently succeeding on the developer's +// own installation. +func installBrokerShapedAgentRelayBin(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "agent-relay-broker") + // Version output and failure text copied from the real + // agent-relay-broker: it satisfies the >= 8.7.0 check and then rejects + // every agent-relay CLI subcommand, which is exactly why the old error + // message blamed the CLI version. + script := `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "agent-relay-broker 11.5.4" + exit 0 +fi +echo "error: unrecognized subcommand '$1'" >&2 +exit 2 +` + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write broker stub failed: %v", err) + } + t.Setenv("AGENT_RELAY_BIN", path) + // An empty PATH means `agent-relay` cannot be found either, so a shell-out + // cannot accidentally pass by reaching a real CLI on the test machine. + t.Setenv("PATH", filepath.Join(dir, "empty")) + return path +} + +// MUST FIRE. +// +// Before this change relayfile resolved its cloud session by execing +// $AGENT_RELAY_BIN — relay's *broker* variable — so a valid, unexpired +// credential file was unreachable and a routine token expiry became an +// outage reported as "agent-relay CLI >= 8.7.0 required". +// +// This test fails on origin/main (11f8d98) with that error and passes here. +func TestCloudCredentialsIgnoreBrokerShapedAgentRelayBin(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + writeAgentRelayCloudAuthForTest(t, "https://cloud.test", "cld_at_from_canonical_file") + + creds, err := cloudCredentialsFromAgentRelay() + if err != nil { + t.Fatalf("cloud credentials must resolve from the canonical file regardless of AGENT_RELAY_BIN: %v", err) + } + if creds.AccessToken != "cld_at_from_canonical_file" { + t.Fatalf("unexpected access token: %q", creds.AccessToken) + } + if creds.APIURL != "https://cloud.test" { + t.Fatalf("unexpected api url: %q", creds.APIURL) + } +} + +// MUST NOT FIRE. +// +// The paired negative: with the same broker-shaped AGENT_RELAY_BIN but no +// credential file and no CLOUD_API_* environment, resolution must still fail. +// Reading a file instead of running a CLI must not turn "not logged in" into +// success, and the error must name the credential file it looked for rather +// than blaming the agent-relay CLI version. +func TestCloudCredentialsStillFailWithoutACanonicalSession(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + _, err := cloudCredentialsFromAgentRelay() + if err == nil { + t.Fatal("expected an error when no cloud session exists") + } + if !strings.Contains(err.Error(), "cloud-auth.json") { + t.Fatalf("error must name the credential file it looked for, got: %v", err) + } + if !strings.Contains(err.Error(), "agent-relay cloud login") { + t.Fatalf("error must name the recovery command, got: %v", err) + } + if strings.Contains(err.Error(), minAgentRelayCLIVersion) { + t.Fatalf("a missing login must not be reported as a CLI version problem, got: %v", err) + } +} + +// An incomplete credential file is a distinct failure from a missing one, and +// must not be silently treated as a session. +func TestCloudCredentialsRejectIncompleteCanonicalSession(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + path := mustAgentRelayCloudAuthPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(path, []byte(`{"apiUrl":"https://cloud.test","accessToken":"cld_at_no_refresh"}`), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + _, err := cloudCredentialsFromAgentRelay() + if err == nil || !strings.Contains(err.Error(), "incomplete") { + t.Fatalf("expected an incomplete-session error, got: %v", err) + } +} + +// The CLOUD_API_* environment is the documented non-interactive escape hatch +// and is read the same way relay's own readStoredAuth reads it. +func TestCloudCredentialsPreferCloudAPIEnvironment(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + writeAgentRelayCloudAuthForTest(t, "https://file.test", "cld_at_from_file") + + t.Setenv("CLOUD_API_URL", "https://env.test") + t.Setenv("CLOUD_API_ACCESS_TOKEN", "cld_at_from_env") + t.Setenv("CLOUD_API_REFRESH_TOKEN", "cld_rt_from_env") + t.Setenv("CLOUD_API_ACCESS_TOKEN_EXPIRES_AT", time.Now().Add(time.Hour).UTC().Format(time.RFC3339)) + + creds, err := cloudCredentialsFromAgentRelay() + if err != nil { + t.Fatalf("cloudCredentialsFromAgentRelay failed: %v", err) + } + if creds.AccessToken != "cld_at_from_env" { + t.Fatalf("expected the environment session to win, got %q", creds.AccessToken) + } +} + +// Auto-recovery is the point of the change: an access token inside its expiry +// window is rolled through relay's own refresh endpoint, and the rotated pair +// is written back so the next `agent-relay` command sees it. Keeping the +// rotated refresh token to ourselves would invalidate relay's copy. +func TestCloudCredentialsRefreshExpiredSessionAndPersistRotation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + var gotRefreshToken string + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/auth/token/refresh" { + t.Errorf("unexpected refresh path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + requests++ + var body struct { + RefreshToken string `json:"refreshToken"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode refresh body: %v", err) + } + gotRefreshToken = body.RefreshToken + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "accessToken": "cld_at_rotated", + "refreshToken": "cld_rt_rotated", + "accessTokenExpiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + "refreshTokenExpiresAt": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339), + }) + })) + defer server.Close() + + path := writeAgentRelayCloudAuthExpiringForTest(t, server.URL, "cld_at_expired", time.Now().Add(-time.Minute)) + + creds, err := cloudCredentialsFromAgentRelay() + if err != nil { + t.Fatalf("expired session must auto-recover through refresh: %v", err) + } + if requests != 1 { + t.Fatalf("expected exactly one refresh request, got %d", requests) + } + if gotRefreshToken != "cld_rt_test_refresh" { + t.Fatalf("unexpected refresh token sent: %q", gotRefreshToken) + } + if creds.AccessToken != "cld_at_rotated" { + t.Fatalf("expected the rotated access token, got %q", creds.AccessToken) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back credential file: %v", err) + } + var persisted agentRelayStoredAuth + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatalf("parse credential file: %v", err) + } + if persisted.AccessToken != "cld_at_rotated" || persisted.RefreshToken != "cld_rt_rotated" { + t.Fatalf("rotated tokens were not written back: accessToken=%q refreshToken=%q", + persisted.AccessToken, persisted.RefreshToken) + } + if info, err := os.Stat(path); err != nil { + t.Fatalf("stat credential file: %v", err) + } else if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("credential file must stay 0600, got %o", perm) + } +} + +// A refused refresh is a real expiry, not a CLI problem, and must say so. +func TestCloudCredentialsReportExpiredLoginWhenRefreshRejected(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + writeAgentRelayCloudAuthExpiringForTest(t, server.URL, "cld_at_expired", time.Now().Add(-time.Minute)) + + _, err := cloudCredentialsFromAgentRelay() + if err == nil || !strings.Contains(err.Error(), "agent-relay cloud login") { + t.Fatalf("expected an expired-login error naming the recovery command, got: %v", err) + } + if strings.Contains(err.Error(), minAgentRelayCLIVersion) { + t.Fatalf("an expired login must not be reported as a CLI version problem, got: %v", err) + } +} + +// MUST FIRE. +// +// The name collision itself: AGENT_RELAY_BIN belongs to relay's broker, and +// relayfile must not read it for any purpose. Fails on origin/main, where +// agentRelayBinary() returned the broker path. +func TestAgentRelayBinaryNeverResolvesFromBrokerEnvVar(t *testing.T) { + clearRelayfileEnv(t) + t.Setenv("AGENT_RELAY_BIN", "/usr/local/bin/agent-relay-broker") + + bin, origin := agentRelayBinary() + if bin != "agent-relay" { + t.Fatalf("AGENT_RELAY_BIN must not select relayfile's CLI, got %q", bin) + } + if origin != "PATH" { + t.Fatalf("unexpected origin: %q", origin) + } +} + +// MUST NOT FIRE: relayfile's own override still works, so operators keep a +// supported way to point at a non-PATH CLI build. +func TestAgentRelayBinaryHonoursRelayfileOverride(t *testing.T) { + clearRelayfileEnv(t) + t.Setenv("AGENT_RELAY_BIN", "/usr/local/bin/agent-relay-broker") + t.Setenv(agentRelayCLIOverrideEnv, "/opt/agent-relay/bin/agent-relay") + + bin, origin := agentRelayBinary() + if bin != "/opt/agent-relay/bin/agent-relay" { + t.Fatalf("unexpected binary: %q", bin) + } + if origin != agentRelayCLIOverrideEnv { + t.Fatalf("unexpected origin: %q", origin) + } +} + +// The probe error must name the argv it ran, the binary it ran it with, and +// how that binary was chosen — the information the old "agent-relay CLI >= +// 8.7.0 required" message withheld while the real cause was a wrong binary. +func TestAgentRelayCLIProbeErrorNamesWhatWasProbed(t *testing.T) { + clearRelayfileEnv(t) + broker := installBrokerShapedAgentRelayBin(t) + t.Setenv(agentRelayCLIOverrideEnv, broker) + + err := ensureAgentRelayCLICompatible() + if err == nil { + t.Fatal("expected the broker stub to fail the CLI probe") + } + for _, want := range []string{ + "agent-relay workspace active --help", + broker, + agentRelayCLIOverrideEnv, + "unrecognized subcommand", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("probe error must mention %q, got: %v", want, err) + } + } +} + +// The CLI probe must no longer require a `cloud` subcommand: relayfile stopped +// asking the CLI for its cloud session, so a CLI without `cloud` is fine. +func TestAgentRelayCLIProbeDoesNotRequireCloudSubcommand(t *testing.T) { + clearRelayfileEnv(t) + dir := t.TempDir() + path := filepath.Join(dir, "agent-relay") + script := `#!/bin/sh +if [ "$*" = "--version" ]; then + echo "8.7.0" + exit 0 +fi +if [ "$*" = "workspace active --help" ] || [ "$*" = "workspace switch --help" ]; then + exit 0 +fi +echo "error: unrecognized subcommand '$1'" >&2 +exit 2 +` + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write stub failed: %v", err) + } + t.Setenv(agentRelayCLIOverrideEnv, path) + + if err := ensureAgentRelayCLICompatible(); err != nil { + t.Fatalf("a CLI without `cloud` must still pass the probe: %v", err) + } +} + +func TestEnsureAgentRelayCloudSessionReportsItsSource(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + writeAgentRelayCloudAuthForTest(t, "https://cloud.test", "cld_at_file") + + _, source, err := ensureAgentRelayCloudSession(context.Background()) + if err != nil { + t.Fatalf("ensureAgentRelayCloudSession failed: %v", err) + } + if source != agentRelayCloudSessionFromFile { + t.Fatalf("unexpected source: %q", source) + } +} + +// MUST FIRE — the lost-update defect three reviewers converged on. +// +// The double-check after acquiring the lock exists because another process may +// have refreshed while we waited. Before the fix, the re-read value was used +// only to return early; the refresh itself still presented the PRE-LOCK copy's +// refresh token. Since Cloud rotates the refresh token on every refresh, that +// token is already dead by then, so the second refresher would fail — and if it +// somehow succeeded, it would overwrite the first refresher's rotation. +// +// This drives the real ordering: the fixture makes the file rotate underneath +// the caller while it waits for the lock, and the refresh endpoint rejects any +// refresh token that is not the newest one on disk — exactly as Cloud does. +func TestCloudSessionRefreshAdoptsARotationThatLandedWhileWaiting(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + var mu sync.Mutex + // The only refresh token Cloud still accepts. Rotating invalidates the old. + liveRefreshToken := "cld_rt_test_refresh" + var rejected []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + RefreshToken string `json:"refreshToken"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode refresh body: %v", err) + } + mu.Lock() + defer mu.Unlock() + if body.RefreshToken != liveRefreshToken { + // Presenting a rotated-away refresh token is exactly what Cloud + // refuses, and what the pre-lock copy would have presented. + rejected = append(rejected, body.RefreshToken) + w.WriteHeader(http.StatusUnauthorized) + return + } + liveRefreshToken = "cld_rt_second_rotation" + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "accessToken": "cld_at_second_rotation", + "refreshToken": liveRefreshToken, + "accessTokenExpiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + "refreshTokenExpiresAt": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339), + }) + })) + defer server.Close() + + // The expired session this process will read. + path := writeAgentRelayCloudAuthExpiringForTest(t, server.URL, "cld_at_expired", time.Now().Add(-time.Minute)) + + // Ordering is the whole test, so it is spelled out rather than slept + // through loosely: + // + // 1. take the lock, standing in for the other process that is refreshing; + // 2. start the caller — it reads the STALE session and then blocks on the + // lock, which is the only window in which a lost update can occur; + // 3. rotate the file, so the caller's pre-lock copy is now invalid; + // 4. release, so the caller re-reads and must choose which copy to use. + // + // Rotating before step 2 is what made an earlier version of this test pass + // against the unfixed code: the caller's first read already saw the rotated + // file, so it never held a stale copy at all. + release, err := acquireAgentRelayAuthLock(context.Background()) + if err != nil { + t.Fatalf("seed lock: %v", err) + } + + done := make(chan error, 1) + go func() { + _, _, err := ensureAgentRelayCloudSession(context.Background()) + done <- err + }() + + // Let the caller complete its pre-lock read and settle into the lock wait. + time.Sleep(200 * time.Millisecond) + + mu.Lock() + liveRefreshToken = "cld_rt_first_rotation" + mu.Unlock() + if err := os.WriteFile(path, mustJSON(t, agentRelayStoredAuth{ + APIURL: server.URL, + AccessToken: "cld_at_first_rotation", + RefreshToken: "cld_rt_first_rotation", + // Still inside the refresh window, so the caller must refresh rather + // than simply return this record — which is what forces it to choose + // between the stale copy and the re-read one. + AccessTokenExpiresAt: time.Now().Add(-time.Second).UTC().Format(time.RFC3339), + RefreshTokenExpiresAt: time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339), + }), 0o600); err != nil { + t.Fatalf("rotate file under the lock: %v", err) + } + + release() + + select { + case err := <-done: + if err != nil { + t.Fatalf("refresh must adopt the rotated session on disk, not the pre-lock copy: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("ensureAgentRelayCloudSession did not return") + } + + mu.Lock() + gotRejected := append([]string(nil), rejected...) + mu.Unlock() + if len(gotRejected) != 0 { + t.Fatalf("a stale refresh token was presented to Cloud: %q", gotRejected) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back credential file: %v", err) + } + var persisted agentRelayStoredAuth + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatalf("parse credential file: %v", err) + } + if persisted.AccessToken != "cld_at_second_rotation" || persisted.RefreshToken != "cld_rt_second_rotation" { + t.Fatalf("the adopted rotation was not persisted: accessToken=%q refreshToken=%q", + persisted.AccessToken, persisted.RefreshToken) + } +} + +// MUST NOT FIRE: adopting the re-read session must not defeat the early return. +// When the session on disk is already fresh, no refresh may be issued at all. +func TestCloudSessionSkipsRefreshWhenTheLockWaitProducedAFreshSession(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + refreshes := 0 + var mu sync.Mutex + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + refreshes++ + mu.Unlock() + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + path := writeAgentRelayCloudAuthExpiringForTest(t, server.URL, "cld_at_expired", time.Now().Add(-time.Minute)) + + release, err := acquireAgentRelayAuthLock(context.Background()) + if err != nil { + t.Fatalf("seed lock: %v", err) + } + if err := os.WriteFile(path, mustJSON(t, agentRelayStoredAuth{ + APIURL: server.URL, + AccessToken: "cld_at_already_fresh", + RefreshToken: "cld_rt_already_fresh", + AccessTokenExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + RefreshTokenExpiresAt: time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339), + }), 0o600); err != nil { + t.Fatalf("rotate file under the lock: %v", err) + } + + type result struct { + auth agentRelayStoredAuth + err error + } + done := make(chan result, 1) + go func() { + auth, _, err := ensureAgentRelayCloudSession(context.Background()) + done <- result{auth, err} + }() + time.Sleep(150 * time.Millisecond) + release() + + select { + case got := <-done: + if got.err != nil { + t.Fatalf("a fresh session on disk must be adopted without refreshing: %v", got.err) + } + if got.auth.AccessToken != "cld_at_already_fresh" { + t.Fatalf("unexpected access token: %q", got.auth.AccessToken) + } + case <-time.After(10 * time.Second): + t.Fatal("ensureAgentRelayCloudSession did not return") + } + + mu.Lock() + defer mu.Unlock() + if refreshes != 0 { + t.Fatalf("expected no refresh request, got %d", refreshes) + } +} + +// The stale-lock window must stay strictly longer than the refresh timeout, or +// a live holder waiting on its HTTP call looks dead and has its lock stolen. +// relay keeps a 3x margin (10s refresh vs 30s stale); this pins ours to the +// same relationship so the two implementations cannot steal from each other. +func TestAuthLockStaleWindowExceedsTheRefreshTimeout(t *testing.T) { + if agentRelayAuthLockStaleAfter <= agentRelayCloudRefreshTimeout { + t.Fatalf("stale window %v must exceed the refresh timeout %v", + agentRelayAuthLockStaleAfter, agentRelayCloudRefreshTimeout) + } +} + +// CI commonly exports an access token and nothing else. That shape is a valid +// session per packages/agents/src/connect.ts, and must not be rejected or fall +// through to an unrelated credential file. +func TestCloudCredentialsAcceptAccessTokenOnlyEnvironment(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + // A file that must NOT be consulted: the environment wins. + writeAgentRelayCloudAuthForTest(t, "https://file.test", "cld_at_from_file") + t.Setenv("CLOUD_API_ACCESS_TOKEN", "cld_at_ci_only") + + creds, err := cloudCredentialsFromAgentRelay() + if err != nil { + t.Fatalf("an access-token-only environment must be a usable session: %v", err) + } + if creds.AccessToken != "cld_at_ci_only" { + t.Fatalf("unexpected access token: %q", creds.AccessToken) + } + if creds.APIURL != defaultCloudAPIURL { + t.Fatalf("expected the default cloud API URL, got %q", creds.APIURL) + } +} + +// MUST NOT FIRE: with no refresh token there is nothing to roll, so relayfile +// must never attempt a refresh — doing so would fail with an empty credential +// and turn a working CI session into an error. +func TestAccessTokenOnlyEnvironmentNeverAttemptsARefresh(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + refreshes := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + refreshes++ + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + t.Setenv("CLOUD_API_URL", server.URL) + t.Setenv("CLOUD_API_ACCESS_TOKEN", "cld_at_ci_only") + // Already past expiry: with a refresh token this would refresh; without + // one it must be used as-is. + t.Setenv("CLOUD_API_ACCESS_TOKEN_EXPIRES_AT", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)) + + creds, err := cloudCredentialsFromAgentRelay() + if err != nil { + t.Fatalf("access-token-only session must be used as-is: %v", err) + } + if creds.AccessToken != "cld_at_ci_only" { + t.Fatalf("unexpected access token: %q", creds.AccessToken) + } + if refreshes != 0 { + t.Fatalf("expected no refresh attempt without a refresh token, got %d", refreshes) + } +} + +// A rotated refresh token must not inherit the previous token's expiry. Doing +// so re-arms the 24-hour refresh-token window immediately, so every subsequent +// command rotates again. +func TestRefreshDoesNotInheritAStaleRefreshTokenExpiry(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installBrokerShapedAgentRelayBin(t) + + refreshes := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + refreshes++ + w.Header().Set("Content-Type", "application/json") + // Server omits the optional refreshTokenExpiresAt. + _ = json.NewEncoder(w).Encode(map[string]string{ + "accessToken": "cld_at_rotated", + "refreshToken": "cld_rt_rotated", + "accessTokenExpiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + })) + defer server.Close() + + path := mustAgentRelayCloudAuthPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + // Refresh token within its 24-hour window: this is what triggers the + // refresh, and what would be inherited onto the rotated token. + if err := os.WriteFile(path, mustJSON(t, agentRelayStoredAuth{ + APIURL: server.URL, + AccessToken: "cld_at_ok", + RefreshToken: "cld_rt_near_expiry", + AccessTokenExpiresAt: time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + RefreshTokenExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + if _, err := cloudCredentialsFromAgentRelay(); err != nil { + t.Fatalf("first resolve failed: %v", err) + } + if refreshes != 1 { + t.Fatalf("expected one refresh, got %d", refreshes) + } + // The rotated session must now be stable: a second resolve must not rotate + // again just because the old token was near expiry. + if _, err := cloudCredentialsFromAgentRelay(); err != nil { + t.Fatalf("second resolve failed: %v", err) + } + if refreshes != 1 { + t.Fatalf("rotated session re-refreshed: expected 1 refresh, got %d", refreshes) + } +} + +// A relative credential path would read from, and write rotated tokens into, +// the process's working directory — usually a repository — and be invisible to +// agent-relay. Fail instead. +func TestCloudAuthPathFailsRatherThanFallingBackToARelativePath(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + + path, err := agentRelayCloudAuthPath() + if err == nil { + if filepath.IsAbs(path) { + t.Skipf("this platform resolved a home directory without HOME (%q)", path) + } + t.Fatalf("expected an error, got relative path %q", path) + } + if !strings.Contains(err.Error(), "Agent Relay cloud session") { + t.Fatalf("error should name what could not be located, got: %v", err) + } +} + +func mustJSON(t *testing.T, auth agentRelayStoredAuth) []byte { + t.Helper() + data, err := json.Marshal(auth) + if err != nil { + t.Fatalf("marshal auth: %v", err) + } + return append(data, '\n') +} diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 058cad84..d292b94c 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -91,15 +91,6 @@ type cloudCredentials struct { UpdatedAt string `json:"updatedAt,omitempty"` } -type agentRelayCloudSession struct { - APIURL string `json:"apiUrl"` - AccessToken string `json:"accessToken"` - Auth struct { - APIURL string `json:"apiUrl"` - AccessToken string `json:"accessToken"` - } `json:"auth"` -} - type agentRelayActiveWorkspace struct { ID string `json:"id"` Name string `json:"name"` @@ -1157,42 +1148,80 @@ func ensureCloudCredentials(cloudAPIURL, explicitToken string, timeout time.Dura return creds, nil } -func agentRelayBinary() string { - if value := strings.TrimSpace(os.Getenv("AGENT_RELAY_BIN")); value != "" { - return value +// agentRelayCLIOverrideEnv is relayfile's own override for the agent-relay +// Node CLI. It is deliberately not AGENT_RELAY_BIN: relay uses that variable +// for the *broker* binary (see relay packages/cli/src/cli/lib/client-factory.ts), +// and every relay-spawned agent exports it as agent-relay-broker. Reading it +// here made relayfile exec the broker, which has no `workspace` or `cloud` +// subcommand, and then report the failure as an agent-relay CLI version +// problem. Cloud session auth no longer shells out at all (see cloudauth.go); +// what remains is workspace resolution, and it resolves its binary here. +const agentRelayCLIOverrideEnv = "RELAYFILE_AGENT_RELAY_BIN" + +// agentRelayBinary returns the agent-relay CLI to exec and a human-readable +// description of how it was chosen, so failures can name their own cause. +func agentRelayBinary() (bin string, origin string) { + if value := strings.TrimSpace(os.Getenv(agentRelayCLIOverrideEnv)); value != "" { + return value, agentRelayCLIOverrideEnv } - return "agent-relay" + return "agent-relay", "PATH" } +func agentRelayCLIProbeError(bin, origin string, args []string, detail string) error { + probed := strings.Join(append([]string{"agent-relay"}, args...), " ") + hint := fmt.Sprintf("Run `npm install -g agent-relay@%s`, or point %s at the agent-relay CLI.", minAgentRelayCLIVersion, agentRelayCLIOverrideEnv) + if origin == agentRelayCLIOverrideEnv { + hint = fmt.Sprintf("%s is set to %q — point it at the agent-relay CLI, not the relay broker (agent-relay-broker), or unset it to use PATH.", agentRelayCLIOverrideEnv, bin) + } + return fmt.Errorf( + "agent-relay CLI probe failed: ran `%s` using %q (resolved from %s) and it failed with: %s. relayfile needs `%s` for workspace resolution. %s", + probed, bin, origin, detail, probed, hint, + ) +} + +// agentRelayCLIProbeTimeout bounds each compatibility probe. Every workspace +// call funnels through ensureAgentRelayCLICompatible, so an unbounded probe +// against a wedged binary would stall relayfile with no diagnostic at all. +const agentRelayCLIProbeTimeout = 15 * time.Second + func ensureAgentRelayCLICompatible() error { - bin := agentRelayBinary() - versionOutput, err := exec.Command(bin, "--version").CombinedOutput() + bin, origin := agentRelayBinary() + ctx, cancel := context.WithTimeout(context.Background(), agentRelayCLIProbeTimeout) + defer cancel() + versionOutput, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput() if err != nil { detail := strings.TrimSpace(string(versionOutput)) if detail == "" { detail = err.Error() } - return fmt.Errorf("agent-relay CLI >= %s required; run `npm install -g agent-relay@%s` or set AGENT_RELAY_BIN to a compatible binary (%s)", minAgentRelayCLIVersion, minAgentRelayCLIVersion, detail) + return agentRelayCLIProbeError(bin, origin, []string{"--version"}, detail) } version := firstSemver(string(versionOutput)) if version == "" || compareSemver(version, minAgentRelayCLIVersion) < 0 { if version == "" { version = strings.TrimSpace(string(versionOutput)) } - return fmt.Errorf("agent-relay CLI >= %s required; found %q. Run `npm install -g agent-relay@%s` or update the sandbox image", minAgentRelayCLIVersion, version, minAgentRelayCLIVersion) + return fmt.Errorf( + "agent-relay CLI >= %s required for workspace resolution; %q (resolved from %s) reported %q. Run `npm install -g agent-relay@%s` or update the sandbox image", + minAgentRelayCLIVersion, bin, origin, version, minAgentRelayCLIVersion, + ) } + // Only the workspace subcommands are probed. Cloud session auth is read + // straight from the canonical credential file (cloudauth.go), so a CLI + // without `cloud` is no longer a reason to fail. for _, args := range [][]string{ - {"cloud", "session", "--help"}, {"workspace", "active", "--help"}, {"workspace", "switch", "--help"}, } { - output, err := exec.Command(bin, args...).CombinedOutput() + probeCtx, probeCancel := context.WithTimeout(context.Background(), agentRelayCLIProbeTimeout) + output, err := exec.CommandContext(probeCtx, bin, args...).CombinedOutput() + probeCancel() if err != nil { detail := strings.TrimSpace(string(output)) if detail == "" { detail = err.Error() } - return fmt.Errorf("agent-relay CLI >= %s required with `%s`; run `npm install -g agent-relay@%s` or update the sandbox image (%s)", minAgentRelayCLIVersion, strings.Join(append([]string{"agent-relay"}, args...), " "), minAgentRelayCLIVersion, detail) + return agentRelayCLIProbeError(bin, origin, args, detail) } } return nil @@ -1258,7 +1287,8 @@ func runAgentRelayJSON(args []string, out any) error { } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, agentRelayBinary(), args...) + bin, _ := agentRelayBinary() + cmd := exec.CommandContext(ctx, bin, args...) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("agent-relay %s timed out; run 'agent-relay cloud login' and try again", strings.Join(args, " ")) @@ -1284,7 +1314,10 @@ func runAgentRelayLogin(stdin io.Reader, stdout io.Writer, noOpen bool) error { if noOpen { args = append(args, "--no-open") } - cmd := exec.Command(agentRelayBinary(), args...) + bin, _ := agentRelayBinary() + // No deadline: this is the interactive browser login, and the user may + // legitimately take minutes at the consent screen. + cmd := exec.CommandContext(context.Background(), bin, args...) cmd.Stdin = stdin cmd.Stdout = stdout cmd.Stderr = stdout @@ -1294,40 +1327,24 @@ func runAgentRelayLogin(stdin io.Reader, stdout io.Writer, noOpen bool) error { return nil } +// cloudCredentialsFromAgentRelay resolves the Agent Relay cloud session from +// the canonical credential file (or the CLOUD_API_* environment), refreshing +// the access token in place when it is inside its expiry window. It no longer +// shells out to `agent-relay cloud session`; see cloudauth.go for why. func cloudCredentialsFromAgentRelay() (cloudCredentials, error) { - var session agentRelayCloudSession - // agent-relay masks accessToken in `cloud session --json` unless - // --reveal-token is passed; CLIs predating the flag reject it as an - // unknown option. Ask for the raw token first, fall back for older CLIs. - if err := runAgentRelayJSON([]string{"cloud", "session", "--json", "--reveal-token"}, &session); err != nil { - if !strings.Contains(err.Error(), "unknown option") { - return cloudCredentials{}, err - } - if err := runAgentRelayJSON([]string{"cloud", "session", "--json"}, &session); err != nil { - return cloudCredentials{}, err - } - } - apiURL := strings.TrimRight(strings.TrimSpace(session.APIURL), "/") - accessToken := strings.TrimSpace(session.AccessToken) - if apiURL == "" { - apiURL = strings.TrimRight(strings.TrimSpace(session.Auth.APIURL), "/") - } - if accessToken == "" { - accessToken = strings.TrimSpace(session.Auth.AccessToken) + auth, _, err := ensureAgentRelayCloudSession(context.Background()) + if err != nil { + return cloudCredentials{}, err } + apiURL := strings.TrimRight(strings.TrimSpace(auth.APIURL), "/") if apiURL == "" { apiURL = defaultCloudAPIURL } - if accessToken == "" { - return cloudCredentials{}, errors.New("agent-relay cloud session --json did not include an accessToken") - } - if strings.Contains(accessToken, "…") { - return cloudCredentials{}, errors.New("agent-relay cloud session --json returned a masked accessToken; upgrade the agent-relay CLI or re-run `agent-relay cloud login`") - } return cloudCredentials{ - APIURL: apiURL, - AccessToken: accessToken, - UpdatedAt: time.Now().UTC().Format(time.RFC3339), + APIURL: apiURL, + AccessToken: strings.TrimSpace(auth.AccessToken), + AccessTokenExpiresAt: strings.TrimSpace(auth.AccessTokenExpiresAt), + UpdatedAt: time.Now().UTC().Format(time.RFC3339), }, nil } @@ -6150,7 +6167,8 @@ func runWorkspaceUse(args []string, stdout io.Writer) error { if err := ensureAgentRelayCLICompatible(); err != nil { return err } - cmd := exec.Command(agentRelayBinary(), "workspace", "switch", fs.Arg(0)) + agentRelayBin, _ := agentRelayBinary() + cmd := exec.Command(agentRelayBin, "workspace", "switch", fs.Arg(0)) cmd.Stdout = stdout cmd.Stderr = stdout if err := cmd.Run(); err != nil { @@ -9156,7 +9174,13 @@ func daemonCredentialFreshnessAuthLine(localDir string) string { if !ok { return "" } - latest, ok := latestCredentialModTime(credentialsPath(), agentRelayCloudAuthPath()) + credentialPaths := []string{credentialsPath()} + // A machine with no resolvable home has no canonical session to compare + // against; the freshness hint is advisory, so drop it rather than fail. + if agentRelayAuth, err := agentRelayCloudAuthPath(); err == nil { + credentialPaths = append(credentialPaths, agentRelayAuth) + } + latest, ok := latestCredentialModTime(credentialPaths...) if ok && latest.After(startedAt) { return "auth: daemon predates last login - restart the daemon" } @@ -10694,14 +10718,6 @@ func delegatedBundleMintScopes(bundle delegatedauth.Bundle) []string { return append([]string(nil), defaultJoinScopes...) } -func agentRelayCloudAuthPath() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".agentworkforce", "relay", "cloud-auth.json") - } - return filepath.Join(home, ".agentworkforce", "relay", "cloud-auth.json") -} - func workspacesPath() string { return filepath.Join(configDir(), "workspaces.json") } diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 48c06e25..44a9aec1 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -2252,7 +2252,7 @@ func TestStatusWarnsWhenDaemonPredatesLastLogin(t *testing.T) { RelayfileWorkspaceID: "ws_demo", AccessToken: "delegated_token", }) - agentRelayAuthPath := agentRelayCloudAuthPath() + agentRelayAuthPath := mustAgentRelayCloudAuthPath(t) if err := os.MkdirAll(filepath.Dir(agentRelayAuthPath), 0o700); err != nil { t.Fatalf("mkdir agent-relay auth dir failed: %v", err) } @@ -4154,6 +4154,92 @@ func clearRelayfileEnv(t *testing.T) { t.Setenv("RELAYCAST_BASE_URL", "") t.Setenv("RELAY_BASE_URL", "") t.Setenv("AGENT_RELAY_BIN", "") + t.Setenv("RELAYFILE_AGENT_RELAY_BIN", "") + t.Setenv("CLOUD_API_URL", "") + t.Setenv("CLOUD_API_ACCESS_TOKEN", "") + t.Setenv("CLOUD_API_REFRESH_TOKEN", "") + t.Setenv("CLOUD_API_ACCESS_TOKEN_EXPIRES_AT", "") + t.Setenv("CLOUD_API_REFRESH_TOKEN_EXPIRES_AT", "") +} + +// writeAgentRelayCloudAuthForTest writes the canonical credential file +// `agent-relay cloud login` produces. Tests set HOME to a temp dir first. +func writeAgentRelayCloudAuthForTest(t *testing.T, apiURL, accessToken string) string { + t.Helper() + return writeAgentRelayCloudAuthExpiringForTest(t, apiURL, accessToken, time.Now().Add(24*time.Hour)) +} + +// mustAgentRelayCloudAuthPath resolves the canonical credential file for tests. +// agentRelayCloudAuthPath returns an error rather than a relative fallback when +// the home directory cannot be resolved; every test sets HOME first, so a +// failure here is a broken fixture rather than a case to handle. +func mustAgentRelayCloudAuthPath(t *testing.T) string { + t.Helper() + path, err := agentRelayCloudAuthPath() + if err != nil { + t.Fatalf("resolve canonical cloud auth path: %v", err) + } + return path +} + +// agentRelayCloudLoginStub returns the shell fragment a fake agent-relay needs +// so that `cloud login` behaves like the real command: it writes the canonical +// credential file. relayfile reads the session from that file, never from the +// CLI's stdout. +// +// The payload is marshalled in Go and staged on disk, and the stub only copies +// it into place. Interpolating JSON into the script with %q would emit Go +// escape syntax that POSIX sh does not expand, so any non-ASCII byte in the URL +// or token would land in the file as literal backslash text. +func agentRelayCloudLoginStub(t *testing.T, apiURL, accessToken string) string { + t.Helper() + path := mustAgentRelayCloudAuthPath(t) + payload, err := json.Marshal(agentRelayStoredAuth{ + APIURL: apiURL, + AccessToken: accessToken, + RefreshToken: "cld_rt_test_refresh", + AccessTokenExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + RefreshTokenExpiresAt: time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("marshal login stub payload: %v", err) + } + staged := filepath.Join(t.TempDir(), "cloud-auth.staged.json") + if err := os.WriteFile(staged, append(payload, '\n'), 0o600); err != nil { + t.Fatalf("stage login stub payload: %v", err) + } + return fmt.Sprintf(` +if [ "$*" = "cloud login --no-open" ] || [ "$*" = "cloud login" ]; then + mkdir -p '%s' + cp '%s' '%s' + chmod 600 '%s' + echo "agent-relay login ok" + exit 0 +fi +`, filepath.Dir(path), staged, path, path) +} + +func writeAgentRelayCloudAuthExpiringForTest(t *testing.T, apiURL, accessToken string, accessTokenExpiresAt time.Time) string { + t.Helper() + path := mustAgentRelayCloudAuthPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir agent-relay auth dir failed: %v", err) + } + payload := agentRelayStoredAuth{ + APIURL: apiURL, + AccessToken: accessToken, + RefreshToken: "cld_rt_test_refresh", + AccessTokenExpiresAt: accessTokenExpiresAt.UTC().Format(time.RFC3339), + RefreshTokenExpiresAt: time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339), + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + t.Fatalf("marshal agent-relay cloud auth failed: %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write agent-relay cloud auth failed: %v", err) + } + return path } func setPathWithPSOnly(t *testing.T) { @@ -4202,70 +4288,28 @@ fi if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatalf("write fake agent-relay failed: %v", err) } - t.Setenv("AGENT_RELAY_BIN", path) + // relayfile resolves the CLI through its own variable, never through + // AGENT_RELAY_BIN (which relay uses for the broker binary). + t.Setenv("RELAYFILE_AGENT_RELAY_BIN", path) return path } func installFakeAgentRelaySession(t *testing.T, apiURL, accessToken, name, cloudWorkspaceID, relayfileWorkspaceID string) { t.Helper() + // The cloud session comes from the canonical credential file, not from the + // CLI; only workspace resolution still shells out. + writeAgentRelayCloudAuthForTest(t, apiURL, accessToken) body := fmt.Sprintf(` -if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then - echo '{"apiUrl":%q,"accessToken":%q}' - exit 0 -fi if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then echo '{"name":%q,"cloudWorkspaceId":%q,"relayfileWorkspaceId":%q}' exit 0 fi echo "unexpected args: $*" >&2 exit 2 -`, apiURL, accessToken, name, cloudWorkspaceID, relayfileWorkspaceID) +`, name, cloudWorkspaceID, relayfileWorkspaceID) installFakeAgentRelay(t, body) } -func TestCloudCredentialsFallBackWhenRevealTokenUnsupported(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - clearRelayfileEnv(t) - installFakeAgentRelay(t, ` -if [ "$*" = "cloud session --json --reveal-token" ]; then - echo "error: unknown option '--reveal-token'" >&2 - exit 1 -fi -if [ "$*" = "cloud session --json" ]; then - echo '{"apiUrl":"https://cloud.test","accessToken":"cld_at_raw_fallback_token"}' - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`) - - creds, err := cloudCredentialsFromAgentRelay() - if err != nil { - t.Fatalf("cloudCredentialsFromAgentRelay failed: %v", err) - } - if creds.AccessToken != "cld_at_raw_fallback_token" { - t.Fatalf("unexpected access token: %q", creds.AccessToken) - } -} - -func TestCloudCredentialsRejectMaskedAccessToken(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - clearRelayfileEnv(t) - installFakeAgentRelay(t, ` -if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then - echo '{"apiUrl":"https://cloud.test","accessToken":"cld_at_…oken"}' - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`) - - _, err := cloudCredentialsFromAgentRelay() - if err == nil || !strings.Contains(err.Error(), "masked accessToken") { - t.Fatalf("expected masked accessToken error, got %v", err) - } -} - func TestActiveWorkspaceFallsBackWhenRevealSecretsUnsupported(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) @@ -4595,15 +4639,12 @@ func TestLoginCanProvisionSeparateWorkspaceForMessagingOnlyRelaycastWorkspace(t defer cloud.Close() resolverFailure := agentRelayResolver404Error(workspaceKey, relayfileCLITestFixture(t, "cloud-workspace-not-found.json")) + writeAgentRelayCloudAuthForTest(t, cloud.URL, "cld_access") installFakeAgentRelay(t, fmt.Sprintf(` if [ "$*" = "cloud login --no-open" ]; then echo "agent-relay login ok" exit 0 fi -if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then - echo '{"apiUrl":"`+cloud.URL+`","accessToken":"cld_access"}' - exit 0 -fi if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then printf '%%s\n' %s >&2 exit 1 @@ -6954,14 +6995,7 @@ func TestLoginDelegatesToAgentRelay(t *testing.T) { defer server.Close() installFakeAgentRelay(t, ` printf '%s\n' "$*" >> "$AGENT_RELAY_LOG" -if [ "$*" = "cloud login --no-open" ]; then - echo "agent-relay login ok" - exit 0 -fi -if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then - echo '{"apiUrl":"`+server.URL+`","accessToken":"cld_new"}' - exit 0 -fi +`+agentRelayCloudLoginStub(t, server.URL, "cld_new")+` if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then echo '{"name":"demo","cloudWorkspaceId":"ws_123","relayfileWorkspaceId":"ws_123"}' exit 0 @@ -6993,11 +7027,16 @@ exit 2 t.Fatalf("read fake agent-relay log failed: %v", err) } gotLog := strings.TrimSpace(string(logBytes)) - for _, want := range []string{"cloud login --no-open", "cloud session --json", "workspace active --json"} { + for _, want := range []string{"cloud login --no-open", "workspace active --json"} { if !strings.Contains(gotLog, want) { t.Fatalf("expected agent-relay %s call, got %q", want, string(logBytes)) } } + // The cloud session is read from the canonical credential file, so the CLI + // must never be asked for it. + if strings.Contains(gotLog, "cloud session") { + t.Fatalf("relayfile must not shell out for the cloud session, got %q", string(logBytes)) + } if _, err := os.Stat(cloudCredentialsPath()); !os.IsNotExist(err) { t.Fatalf("expected stale relayfile cloud credentials removed, got err=%v", err) } @@ -7046,7 +7085,7 @@ func TestLogoutClearsAuthCredentialsOnly(t *testing.T) { }); err != nil { t.Fatalf("save cached delegated credentials failed: %v", err) } - agentRelayAuth := agentRelayCloudAuthPath() + agentRelayAuth := mustAgentRelayCloudAuthPath(t) if err := os.MkdirAll(filepath.Dir(agentRelayAuth), 0o700); err != nil { t.Fatalf("mkdir agent-relay auth dir failed: %v", err) } @@ -7390,14 +7429,8 @@ func TestRefreshDelegatedCredentialsFallsBackToCloudRemint(t *testing.T) { func TestRefreshDelegatedCredentialsSurfacesRemintFailure(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) - installFakeAgentRelay(t, ` -if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then - echo "no active cloud session" >&2 - exit 2 -fi -echo "unexpected args: $*" >&2 -exit 2 -`) + // No canonical cloud session on this machine, so the cloud re-mint + // fallback cannot run and its reason must reach the caller. expired := testJWTWithWorkspaceAgentAndExpiry("ws_refresh", "relayfile-cli", time.Now().Add(-time.Minute)) var server *httptest.Server @@ -7432,8 +7465,8 @@ exit 2 if !errors.Is(err, ErrDelegatedRelayfileCredentialsExpired) { t.Fatalf("expected delegated expiry sentinel, got %v", err) } - if !strings.Contains(err.Error(), "no active cloud session") { - t.Fatalf("expected remint failure detail, got %v", err) + if !strings.Contains(err.Error(), "no Agent Relay cloud session") || !strings.Contains(err.Error(), "cloud-auth.json") { + t.Fatalf("expected remint failure detail naming the missing cloud session, got %v", err) } } @@ -7551,14 +7584,7 @@ func TestEnsureCloudCredentialsUsesAgentRelaySession(t *testing.T) { }); err != nil { t.Fatalf("saveCloudCredentials failed: %v", err) } - installFakeAgentRelay(t, ` -if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then - echo '{"apiUrl":"https://relay-cloud.test","accessToken":"agent_cloud_token"}' - exit 0 -fi -echo "unexpected args: $*" >&2 -exit 2 -`) + writeAgentRelayCloudAuthForTest(t, "https://relay-cloud.test", "agent_cloud_token") creds, err := ensureCloudCredentials("", "", 0, false, io.Discard) if err != nil { @@ -7579,10 +7605,12 @@ exit 2 } } -func TestEnsureCloudCredentialsRejectsStaleAgentRelayBeforeSessionCommand(t *testing.T) { +// The stale-CLI gate now guards workspace resolution only — the cloud session +// no longer goes through the CLI at all, so an old CLI must not block it. +func TestActiveWorkspaceRejectsStaleAgentRelayBeforeWorkspaceCommand(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) - marker := filepath.Join(t.TempDir(), "session-called") + marker := filepath.Join(t.TempDir(), "workspace-called") path := filepath.Join(t.TempDir(), "agent-relay") script := fmt.Sprintf(`#!/bin/sh set -eu @@ -7590,9 +7618,9 @@ if [ "$*" = "--version" ]; then echo "8.3.7" exit 0 fi -if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then touch %q - echo '{"apiUrl":"https://relay-cloud.test","accessToken":"agent_cloud_token"}' + echo '{"name":"demo","cloudWorkspaceId":"ws_cloud","relayfileWorkspaceId":"rw_demo"}' exit 0 fi echo "unexpected args: $*" >&2 @@ -7601,20 +7629,43 @@ exit 2 if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatalf("write fake stale agent-relay failed: %v", err) } - t.Setenv("AGENT_RELAY_BIN", path) + t.Setenv("RELAYFILE_AGENT_RELAY_BIN", path) - _, err := ensureCloudCredentials("", "", 0, false, io.Discard) + _, err := activeWorkspaceFromAgentRelay() if err == nil { t.Fatal("expected stale agent-relay CLI to be rejected") } got := err.Error() - for _, want := range []string{"agent-relay CLI >= 8.7.0 required", "8.3.7", "npm install -g agent-relay@8.7.0", "sandbox image"} { + for _, want := range []string{"agent-relay CLI >= 8.7.0 required", "8.3.7", "npm install -g agent-relay@8.7.0", "sandbox image", path, "RELAYFILE_AGENT_RELAY_BIN"} { if !strings.Contains(got, want) { t.Fatalf("expected error to contain %q, got %q", want, got) } } if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { - t.Fatalf("expected cloud session command to be skipped, stat err=%v", statErr) + t.Fatalf("expected workspace command to be skipped, stat err=%v", statErr) + } +} + +// A cloud session must resolve from the canonical credential file even when +// the agent-relay CLI is too old for workspace resolution: the two concerns +// are independent now, and coupling them is what turned a token expiry into +// an outage. +func TestEnsureCloudCredentialsSucceedsWithStaleAgentRelayCLI(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + path := filepath.Join(t.TempDir(), "agent-relay") + if err := os.WriteFile(path, []byte("#!/bin/sh\necho \"8.3.7\"\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake stale agent-relay failed: %v", err) + } + t.Setenv("RELAYFILE_AGENT_RELAY_BIN", path) + writeAgentRelayCloudAuthForTest(t, "https://relay-cloud.test", "agent_cloud_token") + + creds, err := ensureCloudCredentials("", "", 0, false, io.Discard) + if err != nil { + t.Fatalf("ensureCloudCredentials failed: %v", err) + } + if creds.AccessToken != "agent_cloud_token" { + t.Fatalf("unexpected access token: %q", creds.AccessToken) } } diff --git a/docs/cli-design.md b/docs/cli-design.md index a3c0cfc2..0d976040 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -12,7 +12,7 @@ The `relayfile` CLI is the primary interface for humans and CI systems to intera ### Design principles - **Minimal flags, sensible defaults.** The happy path should require as few arguments as possible. -- **Canonical auth over local fallbacks.** Cloud login and active workspace selection are owned by `agent-relay login` and the `@agent-relay/cloud` session. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. +- **Canonical auth over local fallbacks.** Cloud login and active workspace selection are owned by `agent-relay cloud login` and the `@agent-relay/cloud` session. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. - **Composable with pipes and scripts.** All commands emit structured JSON when `--json` is passed; human-readable tables otherwise. - **No implicit destructive actions.** Deletes require confirmation unless `--yes` is passed. @@ -26,26 +26,52 @@ The `relayfile` CLI is the primary interface for humans and CI systems to intera |----------|--------|----------| | 1 | `--token` flag | One-off override | | 2 | `RELAYFILE_TOKEN` env var | CI/CD pipelines | -| 3 | `agent-relay cloud session --json` + `agent-relay workspace active --json` | Cloud-hosted interactive use | +| 3 | `~/.agentworkforce/relay/cloud-auth.json` (or `CLOUD_API_*`) + `agent-relay workspace active --json` | Cloud-hosted interactive use | | 4 | `~/.relayfile/credentials.json` | Self-hosted/API-key compatibility | ### Auth flow: Cloud-hosted ``` -agent-relay login +agent-relay cloud login agent-relay workspace switch my-project relayfile mount ``` -1. `agent-relay login` writes the canonical cloud session in the relay SDK store. +1. `agent-relay cloud login` writes the canonical cloud session in the relay SDK store. 2. `agent-relay workspace switch ` selects the active relay workspace. -3. `relayfile` commands call `agent-relay cloud session --json` for a short-lived access token and `agent-relay workspace active --json` for the canonical `relayfileWorkspaceId`. +3. `relayfile` commands resolve the cloud session without running any CLI, and + call `agent-relay workspace active --json` for the canonical + `relayfileWorkspaceId`. Session resolution order: + - A `CLOUD_API_ACCESS_TOKEN` in the environment wins, together with + `CLOUD_API_URL`, `CLOUD_API_REFRESH_TOKEN`, + `CLOUD_API_ACCESS_TOKEN_EXPIRES_AT` and + `CLOUD_API_REFRESH_TOKEN_EXPIRES_AT` when supplied. The access token alone + is a complete session: with no refresh token there is nothing to roll, so + Relayfile uses it as-is and never attempts a refresh. + - Otherwise the canonical credential file + `~/.agentworkforce/relay/cloud-auth.json`, which must carry `apiUrl`, + `accessToken`, `refreshToken` and an RFC3339 `accessTokenExpiresAt`. + A file that is present but incomplete is an error, not a fall-through. + - With neither, Relayfile reports that no session exists and names both + recovery paths. It never invents a session. + + Refresh: an access token within five minutes of expiry — or a refresh token + within 24 hours of its own expiry — is rolled through Cloud's + `/api/v1/auth/token/refresh`. **A session that came from the environment is + refreshed in memory only and never written to disk**, because it belongs to + whoever exported it. **Only a file-sourced session is written back**, under + the same `cloud-auth.json.lock` directory lock `agent-relay` uses, because + Cloud rotates the refresh token on every refresh and a rotation kept private + would invalidate the copy the CLI reads. 4. Relayfile runtime tokens are minted with Cloud `/join` and kept in memory; they are not persisted as a second cloud session. Relayfile requires the `agent-relay` CLI binary on `PATH` to be version -`8.7.0` or newer, because that is the first published CLI version with the -`cloud session --json` and `workspace active --json` surfaces. Operators can -override the binary with `AGENT_RELAY_BIN`. Sandboxes and Daytona/base images +`8.7.0` or newer for *workspace resolution*, because that is the first +published CLI version with the `workspace active --json` surface. Operators can +override the binary with `RELAYFILE_AGENT_RELAY_BIN`. Relayfile deliberately +does **not** read `AGENT_RELAY_BIN`: throughout Agent Relay that variable names +the *broker* binary (`agent-relay-broker`), which has no `cloud` or `workspace` +subcommand, and every relay-spawned agent exports it. Sandboxes and Daytona/base images that run relayfile must install or update `agent-relay` before exercising the Cloud-hosted path. The cloud/workforce sandbox image owner is responsible for that runtime rollout; Relayfile enforces the version at startup but does not @@ -103,8 +129,10 @@ relayfile setup [--provider github] [--workspace my-project] [--local-dir ./rela **Behavior:** -1. Ensure the user has run `agent-relay login`; `relayfile setup` reads the canonical relay session instead of starting its own login flow. -2. Use `agent-relay cloud session --json` for the Cloud access token. +1. Ensure the user has run `agent-relay cloud login`; `relayfile setup` reads the canonical relay session instead of starting its own login flow. +2. Read the Cloud access token from the canonical credential file + `~/.agentworkforce/relay/cloud-auth.json` (or the `CLOUD_API_*` + environment), refreshing it in place when it is inside its expiry window. 3. Use `agent-relay workspace active --json` for the canonical workspace descriptor and `relayfileWorkspaceId`. 4. Create/join the Cloud workspace when needed, minting Relayfile runtime credentials without persisting them as a second login. 5. Request a hosted Nango connect session for the selected integration and wait until the Cloud status endpoint reports it ready. @@ -130,14 +158,14 @@ relayfile login [--no-open] [--provision-messaging-only] | Flag | Default | Description | |------|---------|-------------| -| `--no-open` | `false` | Forwarded to `agent-relay login --no-open` | +| `--no-open` | `false` | Forwarded to `agent-relay cloud login --no-open` | | `--api-key` | `false` | Preserve the self-hosted/API-key credential path | | `--server` | `https://api.relayfile.dev` | Server base URL for `--api-key` | | `--provision-messaging-only` | `false` | When the active Agent Relay workspace exists only in Relaycast, create a separate Relayfile-backed workspace with a ` (Relayfile)` display-name suffix. | **Behavior:** -1. Default path delegates to `agent-relay login`. +1. Default path delegates to `agent-relay cloud login`. 2. Relayfile does not write `~/.relayfile/cloud-credentials.json`. 3. `--api-key` keeps the self-hosted compatibility path and writes `~/.relayfile/credentials.json` with `0600` permissions. @@ -570,7 +598,7 @@ The CLI resolves tokens in this order, first match wins: If no token is found, the CLI prints: ``` -Error: not authenticated. Run 'agent-relay login' for Cloud or set RELAYFILE_TOKEN. +Error: not authenticated. Run 'agent-relay cloud login' for Cloud or set RELAYFILE_TOKEN. ``` --- diff --git a/docs/guides/vfs-cloud-setup.md b/docs/guides/vfs-cloud-setup.md index 524bb53d..845d83ad 100644 --- a/docs/guides/vfs-cloud-setup.md +++ b/docs/guides/vfs-cloud-setup.md @@ -323,15 +323,15 @@ If the mount has not reconciled for ≥10 minutes it logs `mount stalled: /api/v1/auth/token/refresh`. +- Hold the `cloud-auth.json.lock` directory lock across the refresh, re-read + the file inside the lock, and write the rotated pair back at `0600`. Cloud + rotates the refresh token on every refresh, so keeping the new pair private + would invalidate the copy `agent-relay` reads. +- A session supplied through the environment is owned by whoever exported it: + refresh it in memory, never to disk. +- When refresh is refused, surface an actionable `agent-relay cloud login` + recovery hint. The mount process **MUST** keep running on the existing VFS + token until that token also expires. + +An earlier revision of this contract required Relayfile to obtain Cloud +credentials by invoking `agent-relay cloud session --json` and forbade it from +refreshing at all. That coupled a routine token expiry to locating and +executing a Node CLI — and, because the binary was selected by `AGENT_RELAY_BIN` +(the *broker* variable), relay-spawned agents could not auto-recover at all. +The clause is retired. ### 5.3 Relayfile VFS token rejoin @@ -430,7 +465,7 @@ Triggers: The rejoin **MUST**: -- Use the Cloud access token returned by `agent-relay cloud session --json`. +- Use the Cloud access token from the canonical session (§5.2). - Keep the minted Relayfile runtime token in memory; do not write it to `~/.relayfile/credentials.json`. - Update the in-memory token of the running syncer/HTTP client without @@ -448,7 +483,7 @@ If the refresh token also expires (default 7 days), the mount **MUST**: - Refuse local writes for affected paths and place them in `.relay/permissions-denied.log` with reason `cloud_session_expired`. - Print one stderr line per minute (capped) directing the user to run - `agent-relay login`. This is the only condition under which v1 mounts + `agent-relay cloud login`. This is the only condition under which v1 mounts enter a degraded read-only state. --- diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 3c3a8686..74fcdbd2 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Cloud session auth no longer shells out to `agent-relay cloud session`. The CLI reads the canonical credential file `agent-relay cloud login` writes (`~/.agentworkforce/relay/cloud-auth.json`), or the `CLOUD_API_*` environment, and refreshes preemptively — an access token within five minutes of expiry, or a refresh token within 24 hours of its own expiry — through Cloud's `/api/v1/auth/token/refresh` endpoint, writing the rotated pair back under the same lock `agent-relay` uses. This restores auto-recovery from a routine token expiry. A session supplied through `CLOUD_API_*` is refreshed in memory only and never written to disk; an access token supplied with no refresh token is used as-is and never refreshed. +- Relayfile no longer reads `AGENT_RELAY_BIN` to locate the `agent-relay` CLI. Agent Relay uses that variable for the *broker* binary, so every relay-spawned agent pointed Relayfile at `agent-relay-broker` — which has no `cloud` or `workspace` subcommand — and a routine expiry surfaced as `agent-relay CLI >= 8.7.0 required`. Use `RELAYFILE_AGENT_RELAY_BIN` to override the CLI path; otherwise `agent-relay` is resolved from `PATH`. +- The `agent-relay` CLI compatibility probe now names the exact argv it ran, the binary it ran it with, and how that binary was resolved. It no longer probes `cloud session`, since Relayfile does not use it. + +### Changed + +- The minimum `agent-relay` CLI version now gates workspace resolution only. A CLI without a `cloud` subcommand no longer blocks Relayfile's cloud session. + - `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.