Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.
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
96 changes: 96 additions & 0 deletions internal/auth/gemini.go
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions internal/auth/gemini_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
32 changes: 32 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
32 changes: 21 additions & 11 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
39 changes: 27 additions & 12 deletions internal/instance/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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.
Expand Down