From 84d6770461f4faa9aca72319410103c4a9fb80c8 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 1 Jan 2026 18:37:02 -0800 Subject: [PATCH 1/2] feat(auth): implement `hjk auth codex` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for OpenAI Codex CLI authentication. The command runs `codex login` interactively, which opens a browser for ChatGPT OAuth, then stores the resulting auth.json contents in the macOS Keychain. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/auth/codex.go | 123 ++++++++++++++++++++++++++++++++++++ internal/auth/codex_test.go | 52 +++++++++++++++ internal/cmd/auth.go | 34 ++++++++++ 3 files changed, 209 insertions(+) create mode 100644 internal/auth/codex.go create mode 100644 internal/auth/codex_test.go diff --git a/internal/auth/codex.go b/internal/auth/codex.go new file mode 100644 index 0000000..b665ed0 --- /dev/null +++ b/internal/auth/codex.go @@ -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 +} diff --git a/internal/auth/codex_test.go b/internal/auth/codex_test.go new file mode 100644 index 0000000..ecc0f2b --- /dev/null +++ b/internal/auth/codex_test.go @@ -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") + }) +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 6613f60..4bba50d 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -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 { @@ -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 +} From 1a3df8b18c92b491d4d9bf6b4523a13187f95463 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 1 Jan 2026 18:40:54 -0800 Subject: [PATCH 2/2] fix(auth): inject codex credentials into container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing credential injection for Codex sessions: - Add codex case to injectAuthToken to pass CODEX_AUTH_JSON env var - Add codex case to runAgentSetup to write ~/.codex/auth.json This ensures the Codex CLI finds its auth.json when running inside containers, matching the pattern used for Claude and Gemini. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/run.go | 11 +++++++++++ internal/instance/manager.go | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index f9e0fbe..70b8fb3 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -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 diff --git a/internal/instance/manager.go b/internal/instance/manager.go index 3da1ec0..fc21b0c 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -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 }