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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ toolkit discovers every command with `rc commands --json` and
| `--json` | Machine-readable output |
| `--no-input` | Fail rather than prompt (for scripts/CI) |
| `--profile <name>` | Config profile (precedence: flag → `$RC_PROFILE` → `rc profiles use` → `"default"`) |
| `--api-key <key>` | Override profile key (`$RC_API_KEY`) |
| `--api-key <key>` | Override the profile's credential (precedence: flag → `$RC_API_KEY` → OAuth login → stored key) |
| `--yes, -y` | Skip confirmation prompts |
| `--all` | Also show experimental (unreleased) commands in help |
| `--no-color` | Disable ANSI color (also respects `$NO_COLOR`) |
Expand Down
2 changes: 1 addition & 1 deletion internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func (e *APIError) Hint() string {
func (e *APIError) credentialSourceNote() string {
switch e.CredentialSource {
case "env":
return " The active credential came from the RC_API_KEY environment variablecheck your shell env (e.g. ~/.zshrc), or run `rc login`."
return " The active credential came from the RC_API_KEY environment variable, which overrides any stored login — unset it (check your shell env, e.g. ~/.zshrc) or point it at a key with the required scope."
case "flag":
return " The active credential came from the --api-key flag; pass a key with the required scope, or run `rc login`."
case "oauth":
Expand Down
14 changes: 7 additions & 7 deletions internal/cli/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func TestRicoChat_JSONApprovesDestructiveToolWithYes(t *testing.T) {
"rico", "delete the test offering",
"--conversation", "conv1",
"--approve-tools", "--yes", "--no-input", "--json",
"--api-key", "sk_test",
"--api-key", "atk_test",
)
if err != nil {
t.Fatalf("err = %v, stderr %s", err, stderr)
Expand Down Expand Up @@ -104,7 +104,7 @@ func TestRicoChat_JSONRejectsDestructiveToolWithoutYes(t *testing.T) {
"rico", "delete the test offering",
"--conversation", "conv1",
"--approve-tools", "--no-input", "--json",
"--api-key", "sk_test",
"--api-key", "atk_test",
)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -136,7 +136,7 @@ func TestRicoChat_RemembersLastConversationForResume(t *testing.T) {
_, _, err := runCmdInConfigDir(t, configDir,
"rico", "delete the test offering",
"--conversation", "conv1",
"--approve-tools", "--yes", "--no-input", "--json", "--api-key", "sk_test",
"--approve-tools", "--yes", "--no-input", "--json", "--api-key", "atk_test",
)
if err != nil {
t.Fatal(err)
Expand All @@ -157,22 +157,22 @@ func TestRicoChat_RemembersLastConversationForResume(t *testing.T) {
}

func TestRicoChat_ResumeRequiresInteractivePicker(t *testing.T) {
_, _, err := runCmd(t, "rico", "hi", "--resume", "--no-input", "--api-key", "sk_test")
_, _, err := runCmd(t, "rico", "hi", "--resume", "--no-input", "--api-key", "atk_test")
if err == nil || !strings.Contains(err.Error(), "conversation ID is required") {
t.Fatalf("err = %v", err)
}
}

func TestRicoChat_RequiresPromptNonInteractive(t *testing.T) {
_, _, err := runCmd(t, "rico", "--no-input", "--api-key", "sk_test")
_, _, err := runCmd(t, "rico", "--no-input", "--api-key", "atk_test")
if err == nil || !strings.Contains(err.Error(), "message is required") {
t.Fatalf("err = %v", err)
}
}

// --json is non-interactive: no message must error, not open the chat UI.
func TestRico_JSONWithoutMessageRequiresMessage(t *testing.T) {
_, _, err := runCmd(t, "rico", "--json", "--api-key", "sk_test")
_, _, err := runCmd(t, "rico", "--json", "--api-key", "atk_test")
if err == nil || !strings.Contains(err.Error(), "message is required") {
t.Fatalf("err = %v", err)
}
Expand All @@ -188,7 +188,7 @@ func TestRicoConversations_ListJSON(t *testing.T) {
defer server.Close()
t.Setenv("RC_RICO_BASE_URL", server.URL)

stdout, _, err := runCmd(t, "rico", "conversations", "list", "--json", "--no-input", "--api-key", "sk_test")
stdout, _, err := runCmd(t, "rico", "conversations", "list", "--json", "--no-input", "--api-key", "atk_test")
if err != nil {
t.Fatal(err)
}
Expand Down
46 changes: 25 additions & 21 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ Two login methods are available:
as-is and never refreshed.

The API key can also be supplied via RC_API_KEY for CI use without storing
anything on disk.`,
anything on disk; while set, it overrides the stored login.`,
Example: ` # Interactive — prompts for method
rc auth login

Expand Down Expand Up @@ -339,7 +339,7 @@ func newAuthStatusCmd() *cobra.Command {
Short: "Show the current authentication state",
Long: `Displays the active profile, cached account identity when known, auth method, and project context. If a project is configured, validates that it is still accessible.

Shows which credential is in control (the OAuth login, an RC_API_KEY env var, the --api-key flag, or a stored key) and warns when more than one is present. Pass --scopes to surface the active credential's scopes before attempting writes.`,
Shows which credential is in control — precedence is the --api-key flag, then the RC_API_KEY env var, then the OAuth login, then a stored key and warns when more than one is present. Pass --scopes to surface the active credential's scopes before attempting writes.`,
Example: ` rc auth status
rc auth status --json
rc auth status --scopes --json`,
Expand Down Expand Up @@ -399,11 +399,19 @@ Shows which credential is in control (the OAuth login, an RC_API_KEY env var, th
scopes, scopesKnown = credentialScopes(token)
}

identity := rt.Config.AccountEmail
if rt.Config.AccountName != "" && identity != "" {
identity = fmt.Sprintf("%s <%s>", rt.Config.AccountName, identity)
} else if rt.Config.AccountName != "" {
identity = rt.Config.AccountName
// The cached identity belongs to the profile's stored credential;
// under a flag/env override the active key may be a different
// account entirely, so only claim an identity when the stored
// credential is the one in charge (same gate as auth_origin).
profileCredActive := credSource == config.SourceOAuth || credSource == config.SourceProfile
var identity string
if profileCredActive {
identity = rt.Config.AccountEmail
if rt.Config.AccountName != "" && identity != "" {
identity = fmt.Sprintf("%s <%s>", rt.Config.AccountName, identity)
} else if rt.Config.AccountName != "" {
identity = rt.Config.AccountName
}
}

if !authenticated {
Expand Down Expand Up @@ -440,11 +448,15 @@ Shows which credential is in control (the OAuth login, an RC_API_KEY env var, th
if !rt.Out.IsJSON() {
return nil
}
accountEmail, accountName := "", ""
if profileCredActive {
accountEmail, accountName = rt.Config.AccountEmail, rt.Config.AccountName
}
out := map[string]any{
"profile": profileName,
"authenticated": authenticated,
"account_email": rt.Config.AccountEmail,
"account_name": rt.Config.AccountName,
"account_email": accountEmail,
"account_name": accountName,
"method": method,
"credential_source": string(credSource),
"credential_description": rt.Config.CredentialDescription(),
Expand All @@ -454,8 +466,7 @@ Shows which credential is in control (the OAuth login, an RC_API_KEY env var, th
}
// Only report auth_origin when the stored credential is the active
// one; under a flag/env override it's the wrong credential's origin.
if authenticated && rt.Config.AuthSource != "" &&
(credSource == config.SourceOAuth || credSource == config.SourceProfile) {
if authenticated && rt.Config.AuthSource != "" && profileCredActive {
out["auth_origin"] = rt.Config.AuthSource
}
if credSource == config.SourceOAuth {
Expand Down Expand Up @@ -572,6 +583,7 @@ func loginWithAPIKey(ctx context.Context, rt *Runtime, key string) error {
}

func loginWithAPIKeyOrigin(ctx context.Context, rt *Runtime, key, origin string) error {
rt.client = nil
rt.Config.SetAPIKey(key)
rt.Config.AuthSource = origin
rt.Config.TokenType = ""
Expand Down Expand Up @@ -684,11 +696,7 @@ func loginWithOAuth(ctx context.Context, rt *Runtime) error {
return fmt.Errorf("token exchange: %w", err)
}

rt.Config.TokenType = "oauth"
rt.Config.AccessToken = tr.AccessToken
rt.Config.RefreshToken = tr.RefreshToken
rt.Config.TokenExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
rt.Config.APIKey = ""
rt.Config.SetOAuthTokens(tr.AccessToken, tr.RefreshToken, time.Now().Add(time.Duration(tr.ExpiresIn)*time.Second))
rt.Config.AuthSource = config.AuthOriginOAuthLogin
clearProjectBinding(rt)

Expand Down Expand Up @@ -763,11 +771,7 @@ func signupWithOAuth(ctx context.Context, rt *Runtime, email, name, password str
}
_ = svc.LogoutLoginToken(ctx, login.AuthenticationToken)

rt.Config.TokenType = "oauth"
rt.Config.AccessToken = tokens.AccessToken
rt.Config.RefreshToken = tokens.RefreshToken
rt.Config.TokenExpiresAt = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
rt.Config.APIKey = ""
rt.Config.SetOAuthTokens(tokens.AccessToken, tokens.RefreshToken, time.Now().Add(time.Duration(tokens.ExpiresIn)*time.Second))
rt.Config.AccountEmail = email
rt.Config.AccountName = name
rt.Config.AuthSource = config.AuthOriginOAuthLogin
Expand Down
16 changes: 9 additions & 7 deletions internal/cli/auth_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ func runStatus(t *testing.T, configDir string, args ...string) (string, string)
return out.String(), errb.String()
}

func TestAuthStatus_OAuthNotShadowedByEnvAPIKey(t *testing.T) {
// RC_API_KEY is a per-invocation override: it outranks the stored OAuth
// login, and the shadowing is reported via credential_conflict.
func TestAuthStatus_EnvAPIKeyShadowsOAuth(t *testing.T) {
dir := t.TempDir()
t.Setenv("RC_CONFIG_DIR", dir)
t.Setenv("RC_PROFILE", "")
Expand All @@ -50,17 +52,17 @@ func TestAuthStatus_OAuthNotShadowedByEnvAPIKey(t *testing.T) {
if err := json.Unmarshal([]byte(stdout), &st); err != nil {
t.Fatalf("status not JSON: %v\n%s", err, stdout)
}
if st.Data.Method != "oauth" {
t.Errorf("method should be oauth, got %q", st.Data.Method)
if st.Data.Method != "api_key" {
t.Errorf("method should be api_key, got %q", st.Data.Method)
}
if st.Data.CredentialSource != "oauth" {
t.Errorf("credential_source should be oauth, got %q", st.Data.CredentialSource)
if st.Data.CredentialSource != "env" {
t.Errorf("credential_source should be env, got %q", st.Data.CredentialSource)
}
if st.Data.Conflict == nil {
t.Fatal("expected a credential_conflict field when OAuth + RC_API_KEY coexist")
}
if got, _ := st.Data.Conflict["active_source"].(string); got != "oauth" {
t.Errorf("conflict active_source should be oauth, got %q", got)
if got, _ := st.Data.Conflict["active_source"].(string); got != "env" {
t.Errorf("conflict active_source should be env, got %q", got)
}
}

Expand Down
8 changes: 2 additions & 6 deletions internal/cli/auth_mcp_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,16 +142,12 @@ func loginWithMCPCredential(ctx context.Context, rt *Runtime, cred mcpCredential
if cred.durable {
return loginWithAPIKeyOrigin(ctx, rt, cred.Token, config.AuthOriginMCPImport)
}
rt.Config.APIKey = ""
rt.Config.TokenType = "oauth"
rt.Config.AccessToken = cred.Token
rt.Config.AuthSource = config.AuthOriginMCPImport
// No refresh token and no expiry on purpose: this is a borrowed access
// token. Empty RefreshToken makes Config.NeedsRefresh() return false, so
// rc never tries (and never could) to refresh it — which also means it
// can't rotation-revoke the MCP's own session.
rt.Config.RefreshToken = ""
rt.Config.TokenExpiresAt = time.Time{}
rt.Config.SetOAuthTokens(cred.Token, "", time.Time{})
rt.Config.AuthSource = config.AuthOriginMCPImport
rt.Config.AccountEmail = ""
rt.Config.AccountName = ""
rt.client = nil
Expand Down
38 changes: 29 additions & 9 deletions internal/cli/auth_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,10 @@ func TestAuthStatus_NamesMCPImportedAPIKey(t *testing.T) {
wantField(t, data, "auth_origin", "mcp_import")
}

// The conflict message must name the active credential with the same
// provenance-aware phrasing as the Credential line, not a bare source name.
func TestAuthStatus_ConflictNamesMCPImportedActive(t *testing.T) {
// The conflict message must name credentials with the same provenance-aware
// phrasing as the Credential line, not a bare source name — including the
// shadowed MCP-imported token when RC_API_KEY outranks it.
func TestAuthStatus_ConflictNamesMCPImportedIgnored(t *testing.T) {
dir := t.TempDir()
seedProfile(t, dir, &config.Config{
TokenType: "oauth",
Expand All @@ -158,7 +159,7 @@ func TestAuthStatus_ConflictNamesMCPImportedActive(t *testing.T) {
t.Setenv("RC_API_KEY", "sk_env")

data := statusData(t, dir)
wantField(t, data, "credential_source", "oauth")
wantField(t, data, "credential_source", "env")
conflict, ok := data["credential_conflict"].(map[string]any)
if !ok {
t.Fatalf("expected credential_conflict, got %v", data["credential_conflict"])
Expand All @@ -169,7 +170,26 @@ func TestAuthStatus_ConflictNamesMCPImportedActive(t *testing.T) {
}
}

// An RC_API_KEY set alongside an OAuth login must not silently take over.
// Under an env override the profile's cached identity is not the active
// account, so status must not claim it in the header or the JSON fields.
func TestAuthStatus_EnvOverrideHidesProfileIdentity(t *testing.T) {
dir := t.TempDir()
seedProfile(t, dir, &config.Config{
TokenType: "oauth",
AccessToken: "atk_live",
AccountEmail: "jane@example.com",
AccountName: "Jane",
AuthSource: config.AuthOriginOAuthLogin,
})
t.Setenv("RC_API_KEY", "sk_env")

data := statusData(t, dir)
wantField(t, data, "credential_source", "env")
wantField(t, data, "account_email", "")
wantField(t, data, "account_name", "")
}

// RC_API_KEY outranks an OAuth login, and the shadowed login must be named.
func TestAuthStatus_ConflictNamesActiveAndIgnored(t *testing.T) {
dir := t.TempDir()
seedProfile(t, dir, &config.Config{
Expand All @@ -182,20 +202,20 @@ func TestAuthStatus_ConflictNamesActiveAndIgnored(t *testing.T) {
t.Setenv("RC_API_KEY", "sk_env")

data := statusData(t, dir)
wantField(t, data, "credential_source", "oauth")
wantField(t, data, "credential_source", "env")
conflict, ok := data["credential_conflict"].(map[string]any)
if !ok {
t.Fatalf("expected credential_conflict, got %v", data["credential_conflict"])
}
wantField(t, conflict, "active_source", "oauth")
wantField(t, conflict, "active_source", "env")
ignored, _ := conflict["ignored_sources"].([]any)
found := false
for _, s := range ignored {
if s == "env" {
if s == "oauth" {
found = true
}
}
if !found {
t.Errorf("ignored_sources should name the env key, got %v", ignored)
t.Errorf("ignored_sources should name the OAuth login, got %v", ignored)
}
}
3 changes: 3 additions & 0 deletions internal/cli/paywalls_ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,9 @@ func loadPaywallAIImages(paths []string) ([]paywallai.InputAttachment, error) {

func paywallAIClient(rt *Runtime, baseURL string) (*paywallai.Client, error) {
// rt.API() refreshes the OAuth token if needed and enforces login.
// Unlike Rico, this backend also accepts sk_ secret keys, as long as they
// carry the project_configuration:offerings:read_write scope (verified
// against backend auth 2026-08-31) — so no sk_ guard here.
if _, err := rt.API(); err != nil {
return nil, err
}
Expand Down
18 changes: 15 additions & 3 deletions internal/cli/rico.go
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,15 @@ func ricoClient(rt *Runtime, baseURL string) (*rico.Client, error) {
if _, err := rt.API(); err != nil {
return nil, err
}
// Rico's backend authenticates logins only — a secret API key is rejected
// outright (verified against its auth 2026-08-31), so fail here with the
// remedy instead of letting the server return an opaque 401.
if token, source := rt.Config.Credential(); strings.HasPrefix(token, "sk_") {
if source == config.SourceEnv && rt.Config.IsOAuth() {
return nil, fmt.Errorf("your RC_API_KEY is overriding the stored login with an API key, which Rico can't accept — unset RC_API_KEY and retry")
}
return nil, fmt.Errorf("talking to Rico requires a RevenueCat login; API keys can't authenticate it — run `rc login` (browser)")
}
return rico.NewClient(rico.Options{
BaseURL: baseURL,
Token: agentAuthToken(rt),
Expand All @@ -700,9 +709,12 @@ func ricoClient(rt *Runtime, baseURL string) (*rico.Client, error) {
}), nil
}

// agentAuthToken returns the credential sent to the Rico/Paywalls AI backends —
// the CLI's own bearer token; both backends accept CLI OAuth tokens
// (verified live 2026-07-17).
// agentAuthToken returns the credential sent to the Rico / Paywall AI editor
// backends — the CLI's own bearer token. Both accept CLI OAuth tokens
// (verified live 2026-07-17). Secret keys differ (verified against backend
// auth 2026-08-31): the Paywall AI editor accepts sk_ keys carrying the
// project_configuration:offerings:read_write scope; Rico rejects sk_ keys
// entirely — ricoClient guards that before any request.
func agentAuthToken(rt *Runtime) string {
return rt.Config.BearerToken()
}
Expand Down
Loading
Loading