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
123 changes: 123 additions & 0 deletions internal/auth/codex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package auth

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"

"github.com/creack/pty"
"golang.org/x/term"
)

// codexAccountName is the storage key for Codex credentials.
const codexAccountName = "codex-oauth-creds"

// codexConfigDir is the path where Codex CLI stores its configuration.
var codexConfigDir = filepath.Join(os.Getenv("HOME"), ".codex")

// CodexProvider authenticates with OpenAI Codex CLI.
type CodexProvider struct{}

// NewCodexProvider creates a new Codex authentication provider.
func NewCodexProvider() *CodexProvider {
return &CodexProvider{}
}

// Authenticate runs `codex login` interactively and stores the auth.json contents.
func (p *CodexProvider) Authenticate(ctx context.Context, storage Storage) error {
// Check if codex CLI is available
if _, err := exec.LookPath("codex"); err != nil {
return errors.New("codex CLI not found in PATH: please install OpenAI Codex CLI first")
}

// Run codex login interactively
if err := runCodexLogin(ctx); err != nil {
return err
}

// Read and store the auth.json file
authData, err := readCodexAuth()
if err != nil {
return err
}

if err := storage.Set(codexAccountName, string(authData)); err != nil {
return fmt.Errorf("store credentials: %w", err)
}

return nil
}

// Get retrieves the stored Codex credentials.
func (p *CodexProvider) Get(storage Storage) (string, error) {
return storage.Get(codexAccountName)
}

// runCodexLogin executes the codex login command interactively.
func runCodexLogin(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "codex", "login")

// Start the command with a PTY for interactive login
ptmx, err := pty.Start(cmd)
if err != nil {
return fmt.Errorf("start pty: %w", err)
}
defer ptmx.Close()

// Handle PTY size changes
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGWINCH)
go func() {
for range ch {
_ = pty.InheritSize(os.Stdin, ptmx) //nolint:errcheck // Best-effort resize
}
}()
ch <- syscall.SIGWINCH // Initial resize
defer func() { signal.Stop(ch); close(ch) }()

// Set stdin in raw mode so we pass through all keystrokes
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("set raw mode: %w", err)
}
defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }()

// Copy stdin to the pty (user input)
go func() {
_, _ = io.Copy(ptmx, os.Stdin) //nolint:errcheck // Best-effort stdin forwarding
}()

// Copy pty output to stdout (display)
_, _ = io.Copy(os.Stdout, ptmx) //nolint:errcheck // EOF expected

// Wait for command to complete
if err := cmd.Wait(); err != nil {
return fmt.Errorf("codex login exited with error: %w", err)
}

return nil
}

// readCodexAuth reads the auth.json file from the Codex config directory.
func readCodexAuth() ([]byte, error) {
authPath := filepath.Join(codexConfigDir, "auth.json")
data, err := os.ReadFile(authPath) //nolint:gosec // Path is constructed from HOME env var
if err != nil {
if os.IsNotExist(err) {
return nil, errors.New("codex auth.json not found: login may have failed")
}
return nil, fmt.Errorf("read auth.json: %w", err)
}

if len(data) == 0 {
return nil, errors.New("codex auth.json is empty: login may have failed")
}

return data, nil
}
52 changes: 52 additions & 0 deletions internal/auth/codex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package auth

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewCodexProvider(t *testing.T) {
p := NewCodexProvider()
assert.NotNil(t, p)
}

func TestReadCodexAuth(t *testing.T) {
// Save and restore original path
originalDir := codexConfigDir
t.Cleanup(func() { codexConfigDir = originalDir })

t.Run("valid auth.json", func(t *testing.T) {
tmpDir := t.TempDir()
codexConfigDir = tmpDir

authData := `{"access_token":"test-token","refresh_token":"test-refresh"}`
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "auth.json"), []byte(authData), 0o600))

got, err := readCodexAuth()
require.NoError(t, err)
assert.JSONEq(t, authData, string(got))
})

t.Run("auth.json not found", func(t *testing.T) {
codexConfigDir = "/nonexistent/path"

got, err := readCodexAuth()
assert.Nil(t, got)
assert.ErrorContains(t, err, "codex auth.json not found")
})

t.Run("empty auth.json", func(t *testing.T) {
tmpDir := t.TempDir()
codexConfigDir = tmpDir

require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "auth.json"), []byte(""), 0o600))

got, err := readCodexAuth()
assert.Nil(t, got)
assert.ErrorContains(t, err, "auth.json is empty")
})
}
34 changes: 34 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,28 @@ The stored credentials use your Google AI Pro/Ultra subscription rather than API
RunE: runAuthGemini,
}

var authCodexCmd = &cobra.Command{
Use: "codex",
Short: "Configure OpenAI Codex CLI authentication",
Long: `Configure OpenAI Codex CLI authentication for use in Headjack containers.

This command runs the Codex login flow which:
1. Opens a browser to localhost:1455 for ChatGPT OAuth
2. Prompts you to log in with your ChatGPT account
3. Creates auth.json at ~/.codex/auth.json
4. Stores the auth.json contents securely in macOS Keychain

The stored credentials use your ChatGPT Plus/Pro/Team/Enterprise subscription rather than API billing.`,
Example: ` # Set up Codex CLI authentication
headjack auth codex`,
RunE: runAuthCodex,
}

func init() {
rootCmd.AddCommand(authCmd)
authCmd.AddCommand(authClaudeCmd)
authCmd.AddCommand(authGeminiCmd)
authCmd.AddCommand(authCodexCmd)
}

func runAuthClaude(cmd *cobra.Command, _ []string) error {
Expand Down Expand Up @@ -87,3 +105,19 @@ func runAuthGemini(cmd *cobra.Command, _ []string) error {
fmt.Println("Credentials stored in macOS Keychain.")
return nil
}

func runAuthCodex(cmd *cobra.Command, _ []string) error {
fmt.Println("Starting Codex authentication flow...")
fmt.Println()

provider := auth.NewCodexProvider()
storage := keychain.New()

if err := provider.Authenticate(cmd.Context(), storage); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}

fmt.Println()
fmt.Println("Authentication successful! Credentials stored in macOS Keychain.")
return nil
}
11 changes: 11 additions & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ func injectAuthToken(agent string, cfg *instance.CreateSessionConfig) error {
return fmt.Errorf("get gemini credentials: %w", err)
}
cfg.Env = append(cfg.Env, "GEMINI_OAUTH_CREDS="+creds)

case "codex":
provider := auth.NewCodexProvider()
creds, err := provider.Get(storage)
if err != nil {
if errors.Is(err, keychain.ErrNotFound) {
return errors.New("codex auth not configured: run 'headjack auth codex' first")
}
return fmt.Errorf("get codex credentials: %w", err)
}
cfg.Env = append(cfg.Env, "CODEX_AUTH_JSON="+creds)
}

return nil
Expand Down
9 changes: 9 additions & 0 deletions internal/instance/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,15 @@ echo '{"security":{"auth":{"selectedType":"oauth-personal"}}}' > ~/.gemini/setti
Env: env,
})

case catalog.SessionTypeCodex:
// Write Codex auth.json from env var.
// CODEX_AUTH_JSON contains the contents of ~/.codex/auth.json.
setupCmd := `mkdir -p ~/.codex && echo "$CODEX_AUTH_JSON" > ~/.codex/auth.json`
return m.runtime.Exec(ctx, containerID, container.ExecConfig{
Command: []string{"sh", "-c", setupCmd},
Env: env,
})

default:
return nil
}
Expand Down