diff --git a/README.md b/README.md index 450c7c4..0689a9b 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Harnesses read their configuration at startup. Restart `claude`, `codex`, `openc | Harness | Detected by | Files written | What Requesty sets | | --- | --- | --- | --- | | [Claude Code](https://docs.requesty.ai/integrations/claude-code) | `claude` on `PATH` | `~/.claude/settings.json` | `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_MODEL` in the `env` block | -| [Codex](https://docs.requesty.ai/integrations/openai-codex) | `codex` on `PATH` | `~/.codex/config.toml`, `~/.codex/auth.json` | A `requesty` model provider on `.../v1`, the selected model, and API key auth | +| [Codex](https://docs.requesty.ai/integrations/openai-codex) | `codex` on `PATH` | `~/.codex/config.toml` | A `requesty` model provider on `.../v1`, the selected model, and command-backed auth through `requesty auth token` | | [OpenCode](https://docs.requesty.ai/integrations/opencode) | `opencode` on `PATH` | `~/.config/opencode/opencode.json` | A `requesty` provider on `.../v1` plus the model as `requesty/` | | [Pi](https://docs.requesty.ai/integrations/pi) | `pi` on `PATH` | `~/.pi/agent/models.json` | A `requesty` provider using the native Anthropic Messages API | | [Hermes](https://docs.requesty.ai/integrations/hermes) | `hermes` on `PATH` | `~/.hermes/config.yaml` | A `requesty` entry in `custom_providers` and `model.default` | @@ -154,9 +154,10 @@ mv ~/.claude/settings.json.requesty.bak ~/.claude/settings.json ## Keys -Your Requesty API key is written into `~/.requesty/config.json` and into each harness config the -CLI configures, because that is how those harnesses authenticate. All of these files are written -so that only your user can read them. +Your Requesty API key is written into `~/.requesty/config.json`. Codex retrieves it when needed +through `requesty auth token`; other harnesses may also store it in their own config because that +is how they authenticate. All files containing the key are written so that only your user can +read them. Treat those files as secrets and do not commit them. Keys can be rotated or revoked at any time on the [API keys page](https://app.requesty.ai/api-keys). diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..a19a480 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newAuthCommand(env environment) *cobra.Command { + auth := &cobra.Command{ + Use: "auth", + Short: "Credential helpers for integrations", + Args: cobra.NoArgs, + Hidden: true, + } + + auth.AddCommand(&cobra.Command{ + Use: "token", + Short: "Print the configured Requesty API key", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if env.config.APIKey == "" { + return fmt.Errorf("no Requesty API key configured") + } + + if _, err := fmt.Fprintln(cmd.OutOrStdout(), env.config.APIKey); err != nil { + return fmt.Errorf("failed to print API key: %w", err) + } + return nil + }, + }) + + return auth +} diff --git a/cmd/requesty.go b/cmd/requesty.go index d078473..d61deee 100644 --- a/cmd/requesty.go +++ b/cmd/requesty.go @@ -59,6 +59,7 @@ func newRootCommand(env environment) *cobra.Command { } root.AddCommand( + newAuthCommand(env), newAPIKeysCommand(env), newGroupsCommand(env), ) diff --git a/cmd/requesty_test.go b/cmd/requesty_test.go new file mode 100644 index 0000000..6803a00 --- /dev/null +++ b/cmd/requesty_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/requestyai/cli/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthTokenPrintsAPIKey(t *testing.T) { + var output bytes.Buffer + command := newRootCommand(environment{ + config: config.Config{APIKey: "my-api-key"}, + }) + command.SetOut(&output) + command.SetArgs([]string{"auth", "token"}) + + require.NoError(t, command.Execute()) + assert.Equal(t, "my-api-key\n", output.String()) +} + +func TestAuthTokenRejectsMissingAPIKey(t *testing.T) { + command := newRootCommand(environment{}) + command.SetArgs([]string{"auth", "token"}) + + err := command.Execute() + + require.EqualError(t, err, "no Requesty API key configured") +} + +func TestAuthCommandIsHidden(t *testing.T) { + command := newRootCommand(environment{}) + + auth, _, err := command.Find([]string{"auth"}) + + require.NoError(t, err) + assert.True(t, auth.Hidden) +} diff --git a/internal/harnesses/codex.go b/internal/harnesses/codex.go index 40b652c..06fb148 100644 --- a/internal/harnesses/codex.go +++ b/internal/harnesses/codex.go @@ -30,11 +30,12 @@ type codexProvider struct { Name string `toml:"name"` BaseURL string `toml:"base_url"` HTTPHeaders map[string]string `toml:"http_headers"` + Auth codexProviderAuth `toml:"auth"` } -type codexAuth struct { - AuthMode string `json:"auth_mode"` - OpenAIAPIKey string `json:"OPENAI_API_KEY"` +type codexProviderAuth struct { + Command string `toml:"command"` + Args []string `toml:"args"` } type CodexHarness struct { @@ -60,8 +61,8 @@ func (c *CodexHarness) Name() string { func (c *CodexHarness) Description() []string { return []string{ - "takes a backup of current config.toml and auth.json", - "writes a config.toml and auth.json to route through Requesty", + "takes a backup of current config.toml", + "writes a config.toml to route through Requesty", } } @@ -75,20 +76,14 @@ func (c *CodexHarness) Status() (Status, error) { } configPath := c.configPath() - authPath := c.authPath() - status.Files = append(status.Files, configPath, authPath) + status.Files = append(status.Files, configPath) configExists, err := pathExists(configPath) if err != nil { return status, fmt.Errorf("failed to check file exists: %w", err) } - authExists, err := pathExists(authPath) - if err != nil { - return status, fmt.Errorf("failed to check file exists: %w", err) - } - - if !configExists || !authExists { + if !configExists { status.Configured = false return status, nil } @@ -105,7 +100,8 @@ func (c *CodexHarness) Status() (Status, error) { return status, fmt.Errorf("failed to unmarshal: %w", err) } - if config.ModelProvider == codexModelProvider { + provider, providerExists := config.ModelProviders[codexModelProvider] + if config.ModelProvider == codexModelProvider && providerExists && provider.Auth.Command != "" { status.Configured = true } else { status.Configured = false @@ -139,6 +135,10 @@ func (c *CodexHarness) configureMerge(opts ConfigureOptions) error { "http_headers": map[string]any{ "X-Title": "OpenAI Codex", }, + "auth": map[string]any{ + "command": "requesty", + "args": []string{"auth", "token"}, + }, }, }, }) @@ -146,22 +146,9 @@ func (c *CodexHarness) configureMerge(opts ConfigureOptions) error { return fmt.Errorf("failed to merge config file: %w", err) } - authPath := c.authPath() - - auth, err := mergeOrCreateJSONConfigFile(authPath, map[string]any{ - "auth_mode": "apikey", - "OPENAI_API_KEY": c.config.APIKey, - }) - if err != nil { - return fmt.Errorf("failed to merge auth file: %w", err) - } - if err := backupAndWriteConfigFileAsTOML(configPath, &config); err != nil { return fmt.Errorf("failed to write config file: %w", err) } - if err := backupAndWriteConfigFileAsJSON(authPath, &auth); err != nil { - return fmt.Errorf("failed to write auth file: %w", err) - } return nil } @@ -177,6 +164,10 @@ func (c *CodexHarness) configureOverwrite(opts ConfigureOptions) error { HTTPHeaders: map[string]string{ "X-Title": "OpenAI Codex", }, + Auth: codexProviderAuth{ + Command: "requesty", + Args: []string{"auth", "token"}, + }, }, }, ModelReasoningEffort: "high", @@ -189,22 +180,9 @@ func (c *CodexHarness) configureOverwrite(opts ConfigureOptions) error { return fmt.Errorf("failed to write config file: %w", err) } - auth := codexAuth{ - AuthMode: "apikey", - OpenAIAPIKey: c.config.APIKey, - } - - if err := backupAndWriteConfigFileAsJSON(c.authPath(), &auth); err != nil { - return fmt.Errorf("failed to write auth file: %w", err) - } - return nil } func (c *CodexHarness) configPath() string { return filepath.Join(c.configDir, "config.toml") } - -func (c *CodexHarness) authPath() string { - return filepath.Join(c.configDir, "auth.json") -} diff --git a/internal/harnesses/codex_test.go b/internal/harnesses/codex_test.go index 00e66b3..45f7036 100644 --- a/internal/harnesses/codex_test.go +++ b/internal/harnesses/codex_test.go @@ -20,16 +20,13 @@ func TestCodexIntegrationRoundTrip(t *testing.T) { // Simulate a machine with Codex installed but not integrated. configDir := t.TempDir() configPath := filepath.Join(configDir, "config.toml") - authPath := filepath.Join(configDir, "auth.json") require.NoError(t, os.WriteFile(configPath, []byte("model = \"gpt-5.5\"\n"), 0o600)) - require.NoError(t, os.WriteFile(authPath, []byte(`{"auth_mode": "chatgpt"}`), 0o600)) harness := NewCodexHarness(config, configDir) status, err := harness.Status() require.NoError(t, err) assert.Contains(t, status.Files, configPath) - assert.Contains(t, status.Files, authPath) assert.Equal(t, false, status.Configured) // Configure the machine. @@ -51,7 +48,6 @@ func TestCodexHarnessConfigureCreatesMissingConfig(t *testing.T) { } configDir := t.TempDir() configPath := filepath.Join(configDir, "config.toml") - authPath := filepath.Join(configDir, "auth.json") harness := NewCodexHarness(config, configDir) require.NoError(t, harness.Configure(ConfigureOptions{ @@ -65,13 +61,37 @@ func TestCodexHarnessConfigureCreatesMissingConfig(t *testing.T) { assert.Equal(t, "openai-responses/gpt-5.5", parsedConfig.Model) assert.Equal(t, codexModelProvider, parsedConfig.ModelProvider) assert.Equal(t, "https://router.requesty.ai/v1", parsedConfig.ModelProviders[codexModelProvider].BaseURL) + assert.Equal(t, codexProviderAuth{ + Command: "requesty", + Args: []string{"auth", "token"}, + }, parsedConfig.ModelProviders[codexModelProvider].Auth) + _, err = os.Stat(filepath.Join(configDir, "auth.json")) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestCodexHarnessConfigureOverwriteUsesProviderAuth(t *testing.T) { + cfg := config.Config{ + RouterBaseURL: "https://router.requesty.ai", + APIKey: "my-api-key", + } + configDir := t.TempDir() + harness := NewCodexHarness(cfg, configDir) - auth, err := os.ReadFile(authPath) + require.NoError(t, harness.Configure(ConfigureOptions{ + Model: "openai-responses/gpt-5.5", + Overwrite: true, + })) + + configBytes, err := os.ReadFile(filepath.Join(configDir, "config.toml")) require.NoError(t, err) - assert.JSONEq(t, `{ - "auth_mode": "apikey", - "OPENAI_API_KEY": "my-api-key" - }`, string(auth)) + var parsedConfig codexConfig + require.NoError(t, toml.Unmarshal(configBytes, &parsedConfig)) + assert.Equal(t, codexProviderAuth{ + Command: "requesty", + Args: []string{"auth", "token"}, + }, parsedConfig.ModelProviders[codexModelProvider].Auth) + _, err = os.Stat(filepath.Join(configDir, "auth.json")) + assert.ErrorIs(t, err, os.ErrNotExist) } func TestCodexHarnessDefaultConfigDir(t *testing.T) { diff --git a/internal/harnesses/merge_test.go b/internal/harnesses/merge_test.go index dbd3c38..d9bcc12 100644 --- a/internal/harnesses/merge_test.go +++ b/internal/harnesses/merge_test.go @@ -61,6 +61,9 @@ custom_provider_setting = "keep-me" [model_providers.requesty.http_headers] Existing = "keep-me" +[model_providers.requesty.auth] +custom_auth_setting = "keep-me" + [projects."/existing/project"] trust_level = "untrusted" @@ -78,6 +81,10 @@ custom_project_setting = "keep-me" "http_headers": map[string]any{ "X-Title": "OpenAI Codex", }, + "auth": map[string]any{ + "command": "requesty", + "args": []string{"auth", "token"}, + }, }, }, }) @@ -98,6 +105,11 @@ custom_project_setting = "keep-me" "Existing": "keep-me", "X-Title": "OpenAI Codex", }, + "auth": map[string]any{ + "custom_auth_setting": "keep-me", + "command": "requesty", + "args": []string{"auth", "token"}, + }, }, }, "projects": map[string]any{