diff --git a/internal/auth/gemini.go b/internal/auth/gemini.go new file mode 100644 index 0000000..3c1038b --- /dev/null +++ b/internal/auth/gemini.go @@ -0,0 +1,96 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// geminiAccountName is the storage key for Gemini credentials. +const geminiAccountName = "gemini-oauth-creds" + +// geminiConfigDir is the path where Gemini CLI stores its configuration. +var geminiConfigDir = filepath.Join(os.Getenv("HOME"), ".gemini") + +// GeminiConfig holds all configuration needed to authenticate Gemini CLI. +type GeminiConfig struct { + OAuthCreds json.RawMessage `json:"oauth_creds"` + GoogleAccounts json.RawMessage `json:"google_accounts"` +} + +// GeminiProvider authenticates with Gemini CLI. +type GeminiProvider struct{} + +// NewGeminiProvider creates a new Gemini authentication provider. +func NewGeminiProvider() *GeminiProvider { + return &GeminiProvider{} +} + +// Authenticate reads cached Gemini CLI config files and stores them in the keychain. +// If credentials don't exist, it returns an error instructing the user to run gemini first. +func (p *GeminiProvider) Authenticate(_ context.Context, storage Storage) error { + // Read the cached config from Gemini CLI + config, err := readGeminiConfig() + if err != nil { + return err + } + + // Store the config as JSON + configJSON, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + if err := storage.Set(geminiAccountName, string(configJSON)); err != nil { + return fmt.Errorf("store config: %w", err) + } + + return nil +} + +// Get retrieves the stored Gemini config. +func (p *GeminiProvider) Get(storage Storage) (string, error) { + return storage.Get(geminiAccountName) +} + +// readGeminiConfig reads OAuth credentials and account info from Gemini CLI's cache. +func readGeminiConfig() (*GeminiConfig, error) { + // Read oauth_creds.json (required) + oauthPath := filepath.Join(geminiConfigDir, "oauth_creds.json") + oauthData, err := os.ReadFile(oauthPath) //nolint:gosec // Path is constructed from HOME env var + if err != nil { + if os.IsNotExist(err) { + return nil, errors.New("gemini credentials not found: please run 'gemini' and complete the OAuth login first") + } + return nil, fmt.Errorf("read oauth_creds.json: %w", err) + } + + // Validate OAuth creds have a refresh token + var oauthCreds struct { + RefreshToken string `json:"refresh_token"` + } + if unmarshalErr := json.Unmarshal(oauthData, &oauthCreds); unmarshalErr != nil { + return nil, fmt.Errorf("parse oauth_creds.json: %w", unmarshalErr) + } + if oauthCreds.RefreshToken == "" { + return nil, errors.New("gemini credentials missing refresh token: please run 'gemini' and complete the OAuth login") + } + + // Read google_accounts.json (required for OAuth) + accountsPath := filepath.Join(geminiConfigDir, "google_accounts.json") + accountsData, err := os.ReadFile(accountsPath) //nolint:gosec // Path is constructed from HOME env var + if err != nil { + if os.IsNotExist(err) { + return nil, errors.New("google_accounts.json not found: please run 'gemini' and complete the OAuth login first") + } + return nil, fmt.Errorf("read google_accounts.json: %w", err) + } + + return &GeminiConfig{ + OAuthCreds: oauthData, + GoogleAccounts: accountsData, + }, nil +} diff --git a/internal/auth/gemini_test.go b/internal/auth/gemini_test.go new file mode 100644 index 0000000..55b497f --- /dev/null +++ b/internal/auth/gemini_test.go @@ -0,0 +1,97 @@ +package auth + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGeminiConfigJSON(t *testing.T) { + config := GeminiConfig{ + OAuthCreds: json.RawMessage(`{"access_token":"test","refresh_token":"1//test"}`), + GoogleAccounts: json.RawMessage(`{"active":"test@example.com"}`), + } + + data, err := json.Marshal(config) + require.NoError(t, err) + + var parsed GeminiConfig + require.NoError(t, json.Unmarshal(data, &parsed)) + + assert.Equal(t, config.OAuthCreds, parsed.OAuthCreds) + assert.Equal(t, config.GoogleAccounts, parsed.GoogleAccounts) +} + +func TestReadGeminiConfig(t *testing.T) { + // Save and restore original path + originalDir := geminiConfigDir + t.Cleanup(func() { geminiConfigDir = originalDir }) + + t.Run("valid config files", func(t *testing.T) { + tmpDir := t.TempDir() + geminiConfigDir = tmpDir + + oauthCreds := `{"access_token":"ya29.test","refresh_token":"1//refresh","scope":"openid"}` + googleAccounts := `{"active":"test@example.com"}` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "oauth_creds.json"), []byte(oauthCreds), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "google_accounts.json"), []byte(googleAccounts), 0o600)) + + got, err := readGeminiConfig() + require.NoError(t, err) + assert.JSONEq(t, oauthCreds, string(got.OAuthCreds)) + assert.JSONEq(t, googleAccounts, string(got.GoogleAccounts)) + }) + + t.Run("oauth_creds.json not found", func(t *testing.T) { + geminiConfigDir = "/nonexistent/path" + + got, err := readGeminiConfig() + assert.Nil(t, got) + assert.ErrorContains(t, err, "gemini credentials not found") + }) + + t.Run("invalid oauth json", func(t *testing.T) { + tmpDir := t.TempDir() + geminiConfigDir = tmpDir + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "oauth_creds.json"), []byte("{invalid}"), 0o600)) + + got, err := readGeminiConfig() + assert.Nil(t, got) + assert.ErrorContains(t, err, "parse oauth_creds.json") + }) + + t.Run("missing refresh token", func(t *testing.T) { + tmpDir := t.TempDir() + geminiConfigDir = tmpDir + + oauthCreds := `{"access_token":"ya29.test"}` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "oauth_creds.json"), []byte(oauthCreds), 0o600)) + + got, err := readGeminiConfig() + assert.Nil(t, got) + assert.ErrorContains(t, err, "missing refresh token") + }) + + t.Run("google_accounts.json not found", func(t *testing.T) { + tmpDir := t.TempDir() + geminiConfigDir = tmpDir + + oauthCreds := `{"access_token":"ya29.test","refresh_token":"1//refresh"}` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "oauth_creds.json"), []byte(oauthCreds), 0o600)) + + got, err := readGeminiConfig() + assert.Nil(t, got) + assert.ErrorContains(t, err, "google_accounts.json not found") + }) +} + +func TestNewGeminiProvider(t *testing.T) { + p := NewGeminiProvider() + assert.NotNil(t, p) +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 9c4f482..6613f60 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -35,9 +35,27 @@ The stored token uses your Claude Pro/Max subscription rather than API billing.` RunE: runAuthClaude, } +var authGeminiCmd = &cobra.Command{ + Use: "gemini", + Short: "Configure Gemini CLI authentication", + Long: `Configure Gemini CLI authentication for use in Headjack containers. + +This command reads existing Gemini CLI credentials and stores them securely +in the macOS Keychain. You must first authenticate with Gemini CLI by running +'gemini' and completing the Google OAuth login flow. + +The stored credentials use your Google AI Pro/Ultra subscription rather than API billing.`, + Example: ` # First, authenticate with Gemini CLI (if not already done) + gemini + # Then, store credentials in Headjack + headjack auth gemini`, + RunE: runAuthGemini, +} + func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(authClaudeCmd) + authCmd.AddCommand(authGeminiCmd) } func runAuthClaude(cmd *cobra.Command, _ []string) error { @@ -55,3 +73,17 @@ func runAuthClaude(cmd *cobra.Command, _ []string) error { fmt.Println("Authentication successful! Token stored in macOS Keychain.") return nil } + +func runAuthGemini(cmd *cobra.Command, _ []string) error { + fmt.Println("Reading Gemini CLI credentials...") + + provider := auth.NewGeminiProvider() + storage := keychain.New() + + if err := provider.Authenticate(cmd.Context(), storage); err != nil { + return fmt.Errorf("failed to store credentials: %w", err) + } + + fmt.Println("Credentials stored in macOS Keychain.") + return nil +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 5a3efe1..f9e0fbe 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -130,22 +130,32 @@ func buildSessionConfig(cmd *cobra.Command, flags *runFlags, args []string) (*in // injectAuthToken retrieves the auth token for the agent and adds it to the session config. func injectAuthToken(agent string, cfg *instance.CreateSessionConfig) error { - if agent != "claude" { - return nil - } - storage := keychain.New() - provider := auth.NewClaudeProvider() - token, err := provider.Get(storage) - if err != nil { - if errors.Is(err, keychain.ErrNotFound) { - return errors.New("claude auth not configured: run 'headjack auth claude' first") + switch agent { + case "claude": + provider := auth.NewClaudeProvider() + token, err := provider.Get(storage) + if err != nil { + if errors.Is(err, keychain.ErrNotFound) { + return errors.New("claude auth not configured: run 'headjack auth claude' first") + } + return fmt.Errorf("get claude token: %w", err) + } + cfg.Env = append(cfg.Env, "CLAUDE_CODE_OAUTH_TOKEN="+token) + + case "gemini": + provider := auth.NewGeminiProvider() + creds, err := provider.Get(storage) + if err != nil { + if errors.Is(err, keychain.ErrNotFound) { + return errors.New("gemini auth not configured: run 'headjack auth gemini' first") + } + return fmt.Errorf("get gemini credentials: %w", err) } - return fmt.Errorf("get claude token: %w", err) + cfg.Env = append(cfg.Env, "GEMINI_OAUTH_CREDS="+creds) } - cfg.Env = append(cfg.Env, "CLAUDE_CODE_OAUTH_TOKEN="+token) return nil } diff --git a/internal/instance/manager.go b/internal/instance/manager.go index 17f6350..3da1ec0 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -807,7 +807,7 @@ func (m *Manager) CreateSession(ctx context.Context, instanceID string, cfg *Cre } // Run agent-specific setup before starting the session - if setupErr := m.runAgentSetup(ctx, entry.ContainerID, sessionType); setupErr != nil { + if setupErr := m.runAgentSetup(ctx, entry.ContainerID, sessionType, cfg.Env); setupErr != nil { return nil, fmt.Errorf("agent setup: %w", setupErr) } @@ -871,19 +871,34 @@ func (m *Manager) CreateSession(ctx context.Context, instanceID string, cfg *Cre // runAgentSetup performs agent-specific setup before starting a session. // For Claude, this creates the config file needed to skip onboarding. -func (m *Manager) runAgentSetup(ctx context.Context, containerID string, sessionType catalog.SessionType) error { - if sessionType != catalog.SessionTypeClaude { +// For Gemini, this writes OAuth credentials to the expected file location. +func (m *Manager) runAgentSetup(ctx context.Context, containerID string, sessionType catalog.SessionType, env []string) error { + switch sessionType { + case catalog.SessionTypeClaude: + // Create ~/.claude.json with hasCompletedOnboarding to skip interactive setup. + // This is required for OAuth token authentication to work in headless environments. + // See: https://github.com/anthropics/claude-code/issues/8938 + setupCmd := `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json` + return m.runtime.Exec(ctx, containerID, container.ExecConfig{ + Command: []string{"sh", "-c", setupCmd}, + }) + + case catalog.SessionTypeGemini: + // Write Gemini config files from env vars. + // GEMINI_OAUTH_CREDS contains JSON with oauth_creds and google_accounts. + // We also write a minimal settings.json to set the auth type. + setupCmd := `mkdir -p ~/.gemini && \ +echo "$GEMINI_OAUTH_CREDS" | jq -r '.oauth_creds' > ~/.gemini/oauth_creds.json && \ +echo "$GEMINI_OAUTH_CREDS" | jq -r '.google_accounts' > ~/.gemini/google_accounts.json && \ +echo '{"security":{"auth":{"selectedType":"oauth-personal"}}}' > ~/.gemini/settings.json` + return m.runtime.Exec(ctx, containerID, container.ExecConfig{ + Command: []string{"sh", "-c", setupCmd}, + Env: env, + }) + + default: return nil } - - // Create ~/.claude.json with hasCompletedOnboarding to skip interactive setup. - // This is required for OAuth token authentication to work in headless environments. - // See: https://github.com/anthropics/claude-code/issues/8938 - setupCmd := `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json` - - return m.runtime.Exec(ctx, containerID, container.ExecConfig{ - Command: []string{"sh", "-c", setupCmd}, - }) } // getRunningInstance retrieves an instance and verifies its container is running.