From e0f22b5c6cdf86a606beafd9050983b95971f22e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 2 Jan 2026 20:07:03 -0800 Subject: [PATCH] refactor: cross-platform keyring and improved auth UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace macOS-only keychain with cross-platform keyring (99designs/keyring) - Supports macOS Keychain, Windows Credential Manager, Secret Service, Linux kernel keyring (keyctl), and encrypted file fallback - Add HEADJACK_KEYRING_BACKEND and HEADJACK_KEYRING_PASSWORD env vars - Add dual authentication modes for all agents - Subscription: OAuth tokens from CLI tools (existing subscriptions) - API Key: Direct API keys for pay-per-use billing - Credentials stored as JSON with type field - Improve auth command UX with charmbracelet/huh - Interactive selection picker (arrow keys) instead of numbered menu - Masked password input (shows dots) for tokens/keys - Add --status flag to check configured auth per provider - Update documentation for new auth system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../adr-007-cross-platform-keyring.md | 78 +++++ docs/docs/explanation/authentication.md | 275 +++++++++++------- docs/docs/how-to/authenticate.md | 89 ++++-- docs/docs/how-to/troubleshoot-auth.md | 173 +++++++---- docs/docs/reference/cli/auth.md | 105 +++++-- docs/docs/reference/environment.md | 49 +++- docs/docs/tutorials/getting-started.md | 38 ++- go.mod | 32 +- go.sum | 77 ++++- internal/auth/auth.go | 119 +++++++- internal/auth/claude.go | 171 ++++++----- internal/auth/codex.go | 126 ++++---- internal/auth/codex_test.go | 2 +- internal/auth/gemini.go | 99 +++++-- internal/auth/gemini_test.go | 2 +- internal/auth/mocks/prompter.go | 170 +++++++++++ internal/auth/mocks/provider.go | 259 +++++++++++++---- internal/auth/prompter.go | 72 +++++ internal/cmd/auth.go | 203 +++++++++---- internal/cmd/run.go | 87 +++--- internal/instance/instance.go | 10 +- internal/instance/manager.go | 23 +- internal/keychain/keychain.go | 54 +++- internal/keychain/keychain_darwin.go | 71 ----- internal/keychain/keychain_other.go | 22 -- internal/keychain/keyring.go | 191 ++++++++++++ internal/keychain/keyring_test.go | 236 +++++++++++++++ 27 files changed, 2173 insertions(+), 660 deletions(-) create mode 100644 docs/docs/decisions/adr-007-cross-platform-keyring.md create mode 100644 internal/auth/mocks/prompter.go create mode 100644 internal/auth/prompter.go delete mode 100644 internal/keychain/keychain_darwin.go delete mode 100644 internal/keychain/keychain_other.go create mode 100644 internal/keychain/keyring.go create mode 100644 internal/keychain/keyring_test.go diff --git a/docs/docs/decisions/adr-007-cross-platform-keyring.md b/docs/docs/decisions/adr-007-cross-platform-keyring.md new file mode 100644 index 0000000..e054b4c --- /dev/null +++ b/docs/docs/decisions/adr-007-cross-platform-keyring.md @@ -0,0 +1,78 @@ +--- +sidebar_position: 7 +title: "ADR-007: Cross-Platform Keyring" +description: Decision to use 99designs/keyring for cross-platform credential storage +--- + +# ADR 007: Cross-Platform Keyring + +## Status + +Accepted + +## Context + +Headjack stores agent credentials (Claude, Gemini, Codex OAuth tokens) securely and injects them into containers at session start. The original implementation used `github.com/keybase/go-keychain`, which only supports macOS Keychain. + +As the project considers Linux support (via Podman), credential storage becomes a blocker. Linux has multiple options for secret storage: + +1. **Secret Service API** (D-Bus): Used by GNOME Keyring and KDE Wallet. Works well on desktop Linux but requires a running D-Bus session. + +2. **Linux kernel keyring** (`keyctl`): Built into the kernel, works on headless servers without D-Bus. Session-scoped by default. + +3. **Encrypted file**: Universal fallback that works everywhere but requires password management. + +4. **`pass`** (password-store): Uses GPG encryption. Requires GPG key setup. + +We need a solution that: +- Maintains seamless macOS Keychain support +- Works on desktop Linux (GNOME/KDE) +- Works on headless Linux servers +- Minimizes code complexity + +## Decision + +Adopt `github.com/99designs/keyring` as the unified keyring library. This library: + +- Supports 7 backends: macOS Keychain, Windows Credential Manager, Secret Service (D-Bus), KWallet, keyctl, pass, and encrypted file +- Is battle-tested (powers AWS Vault, 1.3k GitHub stars) +- Provides automatic backend selection per platform +- Includes encrypted file fallback for environments without native keyring support + +### Backend Selection + +| Platform | Priority Order | +|----------|----------------| +| macOS | `keychain` (native) | +| Linux | `secret-service` → `keyctl` → `file` | +| Windows | `wincred` | + +Users can override via `HEADJACK_KEYRING_BACKEND` environment variable. + +### Password Handling + +For the encrypted file backend: +1. Check `HEADJACK_KEYRING_PASSWORD` environment variable +2. Fall back to interactive terminal prompt if available +3. Return clear error if no password source available + +## Consequences + +### Positive + +- Enables Linux support without custom implementations for each keyring system +- Single library handles all platform complexity +- `keyctl` backend enables headless Linux server support +- Encrypted file fallback works in any environment (CI, containers, etc.) +- Windows support comes "for free" if ever needed + +### Negative + +- Larger dependency footprint (the library includes multiple backend implementations) +- File backend requires password management (env var or interactive prompt) +- `keyctl` credentials are session-scoped by default; may not persist across reboots + +### Neutral + +- API change: `New()` now returns `(Keychain, error)` instead of just `Keychain` +- Existing macOS users experience no change in behavior diff --git a/docs/docs/explanation/authentication.md b/docs/docs/explanation/authentication.md index d4e4706..aa0b736 100644 --- a/docs/docs/explanation/authentication.md +++ b/docs/docs/explanation/authentication.md @@ -1,59 +1,78 @@ --- sidebar_position: 6 title: Authentication -description: How Keychain storage and token injection work +description: How credential storage and token injection work --- # Authentication -Running CLI agents inside containers creates an authentication challenge: how do credentials get from your host machine into the isolated container environment? Headjack solves this through a combination of secure credential storage in macOS Keychain and just-in-time injection when sessions start. +Running CLI agents inside containers creates an authentication challenge: how do credentials get from your host machine into the isolated container environment? Headjack solves this through a combination of secure credential storage in the system keychain and just-in-time injection when sessions start. ## The Authentication Challenge -Each CLI agent has its own authentication mechanism: +Each CLI agent supports multiple authentication methods: -| Agent | Auth Type | Credential | -|-------|-----------|------------| -| Claude Code | OAuth 2.0 | OIDC token (`sk-ant-*`) | -| Gemini CLI | Google OAuth | OAuth credentials + account info | -| Codex | OpenAI OAuth | Auth JSON file | +| Agent | Subscription Auth | API Key Auth | +|-------|------------------|--------------| +| Claude Code | OAuth token (`sk-ant-*`) | Anthropic API key (`sk-ant-api*`) | +| Gemini CLI | Google OAuth credentials | Google AI API key (`AIza*`) | +| Codex | OpenAI OAuth (ChatGPT Plus/Pro) | OpenAI API key (`sk-*`) | -These credentials are typically stored in config files in the user's home directory: +**Subscription authentication** uses your existing CLI subscription (Claude Pro/Max, ChatGPT Plus/Pro, Gemini subscription) via OAuth tokens. -``` -~/.claude.json # Claude Code -~/.gemini/oauth_creds.json # Gemini CLI -~/.codex/auth.json # Codex -``` - -Simply mounting these files into containers would work but creates problems: - -- **Persistence**: Containers are ephemeral; credentials would be lost on container recreation -- **Security**: Credentials on disk are readable by any process; keychain provides better protection -- **Isolation**: Multiple containers might need different credentials (future multi-account support) +**API key authentication** uses pay-per-use API keys that you purchase separately from the subscription. ## The Keychain Solution -Headjack stores agent credentials in macOS Keychain, the system's secure credential store: +Headjack stores agent credentials in the system's secure credential store as JSON: ``` +-----------------------------------------------------------+ -| macOS Keychain | +| System Keychain | | +-----------------------------------------------------+ | -| | Service: com.headjack | | +| | Service: com.headjack.cli | | | +-----------------------------------------------------+ | -| | claude-oidc-token | sk-ant-oat01-xxxx... | | -| | gemini-oauth-creds | {"oauth_creds":...} | | -| | codex-oauth-creds | {"api_key":...} | | +| | claude-credential | {"type":"subscription", | | +| | | "value":"sk-ant-oat01-..."} | | +| | gemini-credential | {"type":"apikey", | | +| | | "value":"AIza..."} | | +| | codex-credential | {"type":"subscription", | | +| | | "value":"{...auth.json...}"} | | | +-----------------------------------------------------+ | +-----------------------------------------------------------+ ``` -Keychain provides: +### Platform-Specific Backends + +Headjack automatically selects the best available backend for your platform: + +| Platform | Backend | Description | +|----------|---------|-------------| +| macOS | `keychain` | Native macOS Keychain | +| Linux (desktop) | `secret-service` | GNOME Keyring or KDE Wallet via D-Bus | +| Linux (headless) | `keyctl` | Linux kernel keyring | +| Linux (fallback) | `file` | Encrypted file storage | +| Windows | `wincred` | Windows Credential Manager | + +You can override the backend with the `HEADJACK_KEYRING_BACKEND` environment variable: + +```bash +export HEADJACK_KEYRING_BACKEND=file # Force encrypted file backend +``` + +For the encrypted file backend, provide a password via environment variable or interactive prompt: + +```bash +export HEADJACK_KEYRING_PASSWORD=your-password +``` + +### Security Properties + +The keychain provides: - **Encryption at rest**: Credentials are encrypted on disk - **Access control**: Only Headjack can read its credentials -- **OS integration**: Locked when screen locks, protected by system security +- **OS integration**: Protected by system security mechanisms - **No plaintext files**: Credentials never written to disk in readable form ## Authentication Flow @@ -65,59 +84,103 @@ The authentication flow has two phases: capture and injection. Before using an agent, you must authenticate: ```bash -hjk auth claude # Capture Claude credentials -hjk auth gemini # Capture Gemini credentials -hjk auth codex # Capture Codex credentials +hjk auth claude # Configure Claude credentials +hjk auth gemini # Configure Gemini credentials +hjk auth codex # Configure Codex credentials +``` + +Each command presents a choice between subscription and API key authentication: + +``` +$ hjk auth claude + +Configure claude authentication + +Authentication method: + 1. Subscription (uses CLAUDE_CODE_OAUTH_TOKEN) + 2. API Key (uses ANTHROPIC_API_KEY) +Enter choice (1-2): ``` -Each agent has a unique capture process: +The capture process differs by agent and authentication type: -**Claude Code** +**Claude Code (Subscription)** -Claude uses `claude setup-token` which runs an interactive OAuth flow: +Claude requires you to manually obtain an OAuth token: -```go -// From claude.go -cmd := exec.CommandContext(ctx, "claude", "setup-token") -// Run with PTY for interactive OAuth -ptmx, err := pty.Start(cmd) -// Extract token from output -token := extractToken(outputBuf.String()) -// Store in keychain -storage.Set(claudeAccountName, token) ``` +$ hjk auth claude +... +Enter choice (1-2): 1 -You authenticate in your browser, then paste the token into the terminal. +Claude subscription credentials must be entered manually. -**Gemini CLI** +To get your OAuth token: + 1. Run: claude setup-token + 2. Complete the browser login flow + 3. Copy the token (starts with sk-ant-) -Gemini credentials are captured from existing config files: +Paste your credential: +``` + +You run `claude setup-token` in a separate terminal, complete the OAuth flow, then paste the token. + +**Claude Code (API Key)** + +For API key authentication, you enter your Anthropic API key directly: -```go -// From gemini.go -oauthData, err := os.ReadFile("~/.gemini/oauth_creds.json") -accountsData, err := os.ReadFile("~/.gemini/google_accounts.json") -// Combine and store -config := &GeminiConfig{OAuthCreds: oauthData, GoogleAccounts: accountsData} -storage.Set(geminiAccountName, string(configJSON)) ``` +$ hjk auth claude +... +Enter choice (1-2): 2 -You must have already run `gemini` on your host and completed OAuth. +Enter your claude API key. -**Codex** +API key: +``` + +**Gemini CLI (Subscription)** -Codex uses `codex login` interactively: +Gemini credentials are auto-detected from existing config files: -```go -// From codex.go -cmd := exec.CommandContext(ctx, "codex", "login") -// Run with PTY for interactive login -ptmx, err := pty.Start(cmd) -// After completion, read the auth file -authData, err := os.ReadFile("~/.codex/auth.json") -// Store in keychain -storage.Set(codexAccountName, string(authData)) ``` +$ hjk auth gemini +... +Enter choice (1-2): 1 + +Found existing subscription credentials. + +Credentials stored securely. +``` + +If not found, you'll see instructions: + +``` +Gemini credentials not found. + +To authenticate with your Gemini subscription: + 1. Run: gemini + 2. Complete the Google OAuth login + 3. Run: hjk auth gemini + +Paste your credential: +``` + +**Codex (Subscription)** + +Codex credentials are similarly auto-detected from `~/.codex/auth.json`: + +``` +$ hjk auth codex +... +Enter choice (1-2): 1 + +Found existing subscription credentials. + +Credentials stored securely. +``` + +If not found, you must run `codex login` first in a separate terminal. ### Phase 2: Credential Injection @@ -129,17 +192,21 @@ When a session starts, Headjack injects credentials into the container: v +-------------------------+ | Read from Keychain | + | (type + value) | +-------------------------+ | v +-------------------------+ - | Pass as env variable | + | Set env variable | + | based on credential | + | type | +-------------------------+ | v +-------------------------+ | Container setup writes | - | to expected locations | + | files (subscription | + | only, if needed) | +-------------------------+ | v @@ -149,39 +216,25 @@ When a session starts, Headjack injects credentials into the container: +-------------------------+ ``` -The implementation differs by agent: - -**Claude Code** - -Claude is passed the token via environment variable: - -```go -// Environment variable set at session start -env = append(env, "CLAUDE_CODE_OAUTH_TOKEN=" + token) -``` - -Claude Code reads this variable directly. +The environment variable used depends on credential type: -**Gemini CLI** +| Agent | Subscription Env Var | API Key Env Var | +|-------|---------------------|-----------------| +| Claude | `CLAUDE_CODE_OAUTH_TOKEN` | `ANTHROPIC_API_KEY` | +| Gemini | `GEMINI_OAUTH_CREDS` | `GEMINI_API_KEY` | +| Codex | `CODEX_AUTH_JSON` | `OPENAI_API_KEY` | -Gemini needs config files, so Headjack writes them at instance start: +**API Key Mode** -```go -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` -``` - -The combined credentials are passed as `GEMINI_OAUTH_CREDS`, then split into the expected files. +When using API key authentication, the credential is passed directly via environment variable. No file setup is needed inside the container. -**Codex** +**Subscription Mode** -Codex similarly needs a config file: +Subscription authentication may require additional file setup in the container: -```go -setupCmd := `mkdir -p ~/.codex && echo "$CODEX_AUTH_JSON" > ~/.codex/auth.json` -``` +- **Claude**: Creates `~/.claude.json` to skip onboarding prompts +- **Gemini**: Splits the combined JSON into `~/.gemini/oauth_creds.json` and `~/.gemini/google_accounts.json` +- **Codex**: Writes `~/.codex/auth.json` ## Security Properties @@ -201,12 +254,12 @@ Credentials are written to the container filesystem only when a session starts. ### Keychain Protection -macOS Keychain provides: +Platform keychains provide: - Encryption with user-specific keys -- Access control lists (ACL) -- Integration with Touch ID / system authentication -- Automatic locking when system sleeps +- Access control (only Headjack can access its credentials) +- Integration with system authentication +- Protection by OS security mechanisms ### No Cross-Instance Leakage @@ -216,7 +269,7 @@ Each session gets its own credential injection. Sessions in different instances ### Per-Machine Authentication -Credentials are stored in the local machine's Keychain. If you use Headjack on multiple machines, you must `hjk auth` on each one. +Credentials are stored in the local machine's keychain. If you use Headjack on multiple machines, you must run `hjk auth` on each one. ### Single Account per Agent @@ -232,12 +285,12 @@ OAuth tokens expire. When they do, you must re-run `hjk auth` to capture fresh t ## Troubleshooting Auth Issues -### "Token not found" +### "Token not found" or "auth not configured" The credential hasn't been captured: ```bash -hjk auth claude # Capture Claude credentials +hjk auth claude # Configure Claude credentials ``` ### "Authentication failed" inside container @@ -245,20 +298,22 @@ hjk auth claude # Capture Claude credentials The token may have expired: ```bash -hjk auth claude # Re-capture fresh token +hjk auth claude # Re-capture fresh credential hjk recreate # Recreate container with new credentials ``` ### Claude onboarding prompt -Claude Code shows onboarding prompts if it doesn't find expected config: +Claude Code shows onboarding prompts if it doesn't find expected config. Headjack creates `~/.claude.json` automatically when using subscription authentication. If you see onboarding prompts, the setup command may have failed. Check container logs. -```go -// Headjack creates this to skip onboarding -setupCmd := `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json` -``` +### Switching between subscription and API key -If you see onboarding prompts, the setup command may have failed. Check container logs. +To switch authentication methods, simply run `hjk auth` again and select the other option: + +```bash +hjk auth claude # Select option 2 for API key +hjk recreate # Recreate to use new credential type +``` ## Why Not SSH Agent Forwarding? @@ -266,9 +321,9 @@ SSH agent forwarding is a common solution for credential access in containers. H 1. **Different credential types**: Agent CLIs don't use SSH keys 2. **OAuth complexity**: OAuth tokens aren't compatible with SSH agent protocol -3. **VM boundary**: SSH agent sockets don't cross the hypervisor boundary easily +3. **VM boundary**: SSH agent sockets don't cross hypervisor/container boundaries easily -The environment variable + file-writing approach works reliably across the VM boundary that Apple Containerization creates. +The environment variable + file-writing approach works reliably across container boundaries. ## Related diff --git a/docs/docs/how-to/authenticate.md b/docs/docs/how-to/authenticate.md index 28e515c..f56020f 100644 --- a/docs/docs/how-to/authenticate.md +++ b/docs/docs/how-to/authenticate.md @@ -6,15 +6,22 @@ description: How to set up authentication for Claude, Gemini, and Codex agents # How to Authenticate Agents -Set up authentication so Headjack containers can use your AI subscriptions. All credentials are stored securely in macOS Keychain and automatically injected into containers at runtime. +Set up authentication so Headjack containers can use your AI subscriptions or API keys. All credentials are stored securely in the system keychain and automatically injected into containers at runtime. ## Prerequisites - Headjack installed -- The CLI for your chosen agent installed: - - Claude Code (`claude` command) with Claude Pro or Max subscription - - Gemini CLI (`gemini` command) with Google AI Pro or Ultra subscription - - OpenAI Codex CLI (`codex` command) with ChatGPT Plus, Pro, Team, or Enterprise subscription +- For **subscription** authentication: The CLI for your chosen agent installed and a valid subscription +- For **API key** authentication: An API key from the provider + +## Authentication Methods + +Each agent supports two authentication methods: + +| Method | Billing | Best For | +|--------|---------|----------| +| **Subscription** | Uses your existing subscription (Claude Pro/Max, ChatGPT Plus/Pro, Gemini) | Users with active subscriptions | +| **API Key** | Pay-per-use API billing | Users without subscriptions or who prefer usage-based billing | ## Claude @@ -24,28 +31,46 @@ Run the authentication command: hjk auth claude ``` -A URL will be displayed. Open it in your browser, log in with your Anthropic account, and enter the displayed code back in the terminal when prompted. +Choose your authentication method when prompted: -## Gemini +### Subscription (Claude Pro/Max) -Gemini requires authenticating with the Gemini CLI first: +1. Select option 1 +2. In a **separate terminal**, run `claude setup-token` +3. Complete the browser login flow +4. Copy the token (starts with `sk-ant-`) +5. Paste it when prompted -```bash -gemini -``` +### API Key -Complete the Google OAuth login in your browser. Verify the credentials were created: +1. Select option 2 +2. Enter your Anthropic API key (starts with `sk-ant-api`) -```bash -ls ~/.gemini/oauth_creds.json ~/.gemini/google_accounts.json -``` +## Gemini -Then store the credentials in Headjack: +Run the authentication command: ```bash hjk auth gemini ``` +Choose your authentication method when prompted: + +### Subscription (Google AI) + +If you have existing Gemini CLI credentials (`~/.gemini/`), they are automatically detected. + +If not found: + +1. Run `gemini` in a separate terminal +2. Complete the Google OAuth login +3. Run `hjk auth gemini` again + +### API Key + +1. Select option 2 +2. Enter your Google AI API key (starts with `AIza`) + ## Codex Run the authentication command: @@ -54,7 +79,22 @@ Run the authentication command: hjk auth codex ``` -A browser window opens to `localhost:1455` for ChatGPT OAuth. Log in and complete the flow, then return to the terminal. +Choose your authentication method when prompted: + +### Subscription (ChatGPT Plus/Pro/Team) + +If you have existing Codex CLI credentials (`~/.codex/auth.json`), they are automatically detected. + +If not found: + +1. Run `codex login` in a separate terminal +2. Complete the OAuth flow in your browser +3. Run `hjk auth codex` again + +### API Key + +1. Select option 2 +2. Enter your OpenAI API key (starts with `sk-`) ## Verification @@ -66,12 +106,21 @@ hjk run my-feature --agent claude # or gemini, codex The agent should authenticate without prompting for login. +## Switching Authentication Methods + +To switch between subscription and API key: + +```bash +hjk auth claude # Select the other option +hjk recreate my-feature # Recreate container with new credentials +``` + ## Notes -- Credentials use your subscription, not API billing -- Tokens are stored under the service `com.headjack.cli` in macOS Keychain +- Subscription credentials use your subscription, not API billing +- API key credentials are billed per-use through the provider's API +- Credentials are stored in the system keychain (macOS Keychain, GNOME Keyring, etc.) - Re-run the auth command if authentication fails or tokens expire -- For Gemini, re-run both `gemini` and `hjk auth gemini` to refresh credentials ## Related diff --git a/docs/docs/how-to/troubleshoot-auth.md b/docs/docs/how-to/troubleshoot-auth.md index cd9ec18..d736ed0 100644 --- a/docs/docs/how-to/troubleshoot-auth.md +++ b/docs/docs/how-to/troubleshoot-auth.md @@ -8,94 +8,90 @@ description: How to troubleshoot authentication issues with Claude, Gemini, and Diagnose and resolve common authentication problems for Claude, Gemini, and Codex agents. -## "CLI not found" Errors +## "auth not configured" Error ### Symptom ``` -claude CLI not found in PATH: please install Claude Code first -codex CLI not found in PATH: please install OpenAI Codex CLI first +claude auth not configured: run 'hjk auth claude' first +gemini auth not configured: run 'hjk auth gemini' first +codex auth not configured: run 'hjk auth codex' first ``` ### Solution -Install the missing CLI tool: - -- **Claude**: Install Claude Code from [claude.ai/code](https://claude.ai/code) -- **Gemini**: Install Gemini CLI with `npm install -g @anthropic-ai/gemini-cli` -- **Codex**: Install OpenAI Codex CLI with `npm install -g @openai/codex` - -Verify the CLI is in your PATH: +Run the authentication command for the agent: ```bash -which claude -which gemini -which codex +hjk auth claude # or gemini, codex ``` -## "Credentials not found" Errors +Choose either subscription or API key authentication when prompted. + +## "Credentials not found" Errors (Subscription) ### Symptom (Gemini) ``` -gemini credentials not found: please run 'gemini' and complete the OAuth login first -google_accounts.json not found: please run 'gemini' and complete the OAuth login first +Gemini credentials not found. + +To authenticate with your Gemini subscription: + 1. Run: gemini + 2. Complete the Google OAuth login + 3. Run: hjk auth gemini ``` ### Solution -Gemini requires you to authenticate with the CLI first: +Gemini subscription authentication requires existing CLI credentials: ```bash -gemini -``` - -Complete the Google OAuth flow in the browser, then re-run: - -```bash -hjk auth gemini +gemini # Complete the Google OAuth flow +hjk auth gemini # Select option 1 ``` ### Symptom (Codex) ``` -codex auth.json not found: login may have failed -codex auth.json is empty: login may have failed +Codex credentials not found. + +To authenticate with your ChatGPT subscription: + 1. Run: codex login + 2. Complete the OAuth flow in your browser + 3. Run: hjk auth codex ``` ### Solution -The Codex login flow did not complete successfully. Run authentication again: +Codex subscription authentication requires existing CLI credentials: ```bash -hjk auth codex +codex login # Complete the OAuth flow +hjk auth codex # Select option 1 ``` -Ensure you complete the OAuth flow in the browser before closing it. - -## "No token received" Error +## Invalid Token/Key Format -### Symptom (Claude) +### Symptom ``` -no token received from claude setup-token +invalid Claude OAuth token: must start with 'sk-ant-' +invalid Anthropic API key: must start with 'sk-ant-api' +invalid Google AI API key: must start with 'AIza' +invalid OpenAI API key: must start with 'sk-' ``` ### Solution -The Claude authentication flow did not complete successfully. This can happen if: - -- You cancelled the login flow -- The browser login timed out -- Network issues interrupted the flow - -Run authentication again: +Ensure you're entering the correct credential type: -```bash -hjk auth claude -``` +| Agent | Subscription Token | API Key | +|-------|-------------------|---------| +| Claude | Starts with `sk-ant-` | Starts with `sk-ant-api` | +| Gemini | JSON from `~/.gemini/` | Starts with `AIza` | +| Codex | JSON from `~/.codex/auth.json` | Starts with `sk-` | -Complete all steps in the browser and enter the code when prompted. +If you selected the wrong option, run `hjk auth` again and choose the other option. ## Token Expired or Invalid @@ -105,34 +101,42 @@ The agent fails to authenticate when running, even though you previously ran `hj ### Solution -Re-authenticate to get a fresh token: +Re-authenticate to get fresh credentials: ```bash -# For Claude +# For Claude (subscription) +# Run 'claude setup-token' first, then: hjk auth claude -# For Gemini +# For Gemini (subscription) gemini # Complete OAuth flow first hjk auth gemini -# For Codex +# For Codex (subscription) +codex login # Complete OAuth flow first hjk auth codex + +# For any agent (API key) +hjk auth # Select option 2 and enter your API key +``` + +After re-authenticating, recreate your instance: + +```bash +hjk recreate my-feature ``` -## Keychain Access Denied +## Keychain Access Issues ### Symptom Authentication fails with a keychain or permission error. -### Solution +### Solution (macOS) 1. Open **Keychain Access** (in Applications > Utilities) - 2. Search for `com.headjack.cli` - 3. Delete any existing Headjack entries - 4. Re-run authentication: ```bash @@ -141,31 +145,74 @@ Authentication fails with a keychain or permission error. 5. When prompted by macOS, allow Headjack to access the keychain +### Solution (Linux) + +If using the encrypted file backend, ensure the password is set: + +```bash +export HEADJACK_KEYRING_PASSWORD=your-password +hjk auth claude +``` + +Or switch to a different backend: + +```bash +export HEADJACK_KEYRING_BACKEND=secret-service # For GNOME Keyring +hjk auth claude +``` + ## Viewing Stored Credentials -To check if credentials are stored in the keychain: +### macOS 1. Open **Keychain Access** - 2. Search for `com.headjack.cli` - 3. Look for entries labeled: - - `Headjack - claude-oidc-token` (Claude) - - `Headjack - gemini-oauth-creds` (Gemini) - - `Headjack - codex-oauth-creds` (Codex) + - `claude-credential` + - `gemini-credential` + - `codex-credential` + +### Linux + +Credentials are stored in your configured keyring backend. The location depends on the backend: + +- **secret-service**: GNOME Keyring or KDE Wallet +- **keyctl**: Linux kernel keyring +- **file**: `~/.config/headjack/keyring/` ## Clearing All Authentication To remove all stored credentials and start fresh: -1. Open **Keychain Access** +### macOS +1. Open **Keychain Access** 2. Search for `com.headjack.cli` - 3. Delete all matching entries - 4. Re-authenticate each agent as needed +### Linux + +```bash +# If using file backend +rm -rf ~/.config/headjack/keyring/ + +# Then re-authenticate +hjk auth claude +hjk auth gemini +hjk auth codex +``` + +## Switching Between Subscription and API Key + +To switch authentication methods: + +```bash +hjk auth claude # Select the other option when prompted +hjk recreate my-feature # Apply new credentials to existing instance +``` + ## Related -- [Authenticate Agents](./authenticate) - set up authentication for Claude, Gemini, or Codex +- [Authenticate Agents](./authenticate) - Set up authentication for Claude, Gemini, or Codex +- [Authentication Explanation](../explanation/authentication) - How credential storage works diff --git a/docs/docs/reference/cli/auth.md b/docs/docs/reference/cli/auth.md index a76e4b2..bb36c07 100644 --- a/docs/docs/reference/cli/auth.md +++ b/docs/docs/reference/cli/auth.md @@ -16,7 +16,14 @@ hjk auth ## Description -Runs the agent-specific authentication flow and stores credentials securely in the macOS Keychain. These credentials are automatically injected into containers when running agents with `hjk run --agent`. +Configures agent authentication and stores credentials securely in the system keychain. These credentials are automatically injected into containers when running agents with `hjk run --agent`. + +Each agent supports two authentication methods: + +| Method | Description | Billing | +|--------|-------------|---------| +| **Subscription** | OAuth tokens from CLI tools | Uses your existing subscription (Claude Pro/Max, ChatGPT Plus/Pro, Gemini subscription) | +| **API Key** | Direct API keys | Pay-per-use API billing | ## Subcommands @@ -28,14 +35,23 @@ Configure Claude Code authentication for use in Headjack containers. hjk auth claude ``` -This command runs the Claude setup-token flow which: +Prompts you to choose between: + +1. **Subscription**: Uses your Claude Pro/Max subscription via OAuth token +2. **API Key**: Uses an Anthropic API key for pay-per-use billing + +**Subscription flow**: + +You must manually obtain the OAuth token: -1. Displays a URL to open in your browser -2. Prompts you to log in with your Anthropic account -3. Presents a code to enter back in the terminal -4. Stores the resulting OAuth token securely in macOS Keychain +1. Run `claude setup-token` in a separate terminal +2. Complete the browser login flow +3. Copy the token (starts with `sk-ant-`) +4. Paste it when prompted by `hjk auth claude` -The stored token uses your Claude Pro/Max subscription rather than API billing. +**API Key flow**: + +Enter your Anthropic API key directly (starts with `sk-ant-api`). ### hjk auth gemini @@ -45,11 +61,22 @@ Configure Gemini CLI authentication for use in Headjack containers. hjk auth gemini ``` -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. +Prompts you to choose between: + +1. **Subscription**: Uses your Google AI subscription via OAuth credentials +2. **API Key**: Uses a Google AI API key for pay-per-use billing + +**Subscription flow**: + +Automatically reads existing credentials from `~/.gemini/` if available. If not found: + +1. Run `gemini` on your host machine +2. Complete the Google OAuth login +3. Run `hjk auth gemini` again -The stored credentials use your Google AI Pro/Ultra subscription rather than API billing. +**API Key flow**: -**Prerequisites**: Run `gemini` on your host machine first to complete the OAuth flow. +Enter your Google AI API key directly (starts with `AIza`). ### hjk auth codex @@ -59,39 +86,69 @@ Configure OpenAI Codex CLI authentication for use in Headjack containers. hjk auth codex ``` -This command runs the Codex login flow which: +Prompts you to choose between: -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 +1. **Subscription**: Uses your ChatGPT Plus/Pro/Team subscription via OAuth +2. **API Key**: Uses an OpenAI API key for pay-per-use billing -The stored credentials use your ChatGPT Plus/Pro/Team/Enterprise subscription rather than API billing. +**Subscription flow**: + +Automatically reads existing credentials from `~/.codex/auth.json` if available. If not found: + +1. Run `codex login` on your host machine +2. Complete the OAuth flow in your browser +3. Run `hjk auth codex` again + +**API Key flow**: + +Enter your OpenAI API key directly (starts with `sk-`). ## Examples ```bash -# Set up Claude Code authentication +# Set up Claude Code with subscription +hjk auth claude +# Select option 1, then paste your OAuth token + +# Set up Claude Code with API key hjk auth claude +# Select option 2, then enter your Anthropic API key -# Set up Gemini CLI authentication (after running 'gemini' first) +# Set up Gemini CLI (after running 'gemini' first) hjk auth gemini -# Set up Codex CLI authentication +# Set up Codex CLI (after running 'codex login' first) hjk auth codex ``` ## Security -All credentials are stored in the macOS Keychain, which provides: +All credentials are stored in the system's secure credential store: + +| Platform | Backend | +|----------|---------| +| macOS | Keychain | +| Linux (desktop) | GNOME Keyring / KDE Wallet | +| Linux (headless) | Kernel keyring or encrypted file | +| Windows | Credential Manager | + +Security properties: - Encryption at rest -- Access control via macOS security policies -- Integration with Touch ID and Apple Watch unlock -- Automatic locking when the system sleeps +- Access control via OS security policies +- Credentials never written to disk in plaintext + +## Environment Variables + +When injected into containers, credentials are set via environment variables: -Credentials are never written to disk in plaintext. +| Agent | Subscription Env Var | API Key Env Var | +|-------|---------------------|-----------------| +| Claude | `CLAUDE_CODE_OAUTH_TOKEN` | `ANTHROPIC_API_KEY` | +| Gemini | `GEMINI_OAUTH_CREDS` | `GEMINI_API_KEY` | +| Codex | `CODEX_AUTH_JSON` | `OPENAI_API_KEY` | ## See Also - [hjk run](run.md) - Use authenticated agents with `--agent` flag +- [Authentication](../../explanation/authentication.md) - How credential storage works diff --git a/docs/docs/reference/environment.md b/docs/docs/reference/environment.md index 57606cf..1ab05d4 100644 --- a/docs/docs/reference/environment.md +++ b/docs/docs/reference/environment.md @@ -78,12 +78,59 @@ agents: OPENAI_API_KEY: "your-api-key" ``` +## Credential Environment Variables + +When running agent sessions, Headjack injects credential environment variables based on your authentication configuration. The variable used depends on the credential type (subscription or API key). + +### Claude + +| Variable | Credential Type | Description | +|----------|-----------------|-------------| +| `CLAUDE_CODE_OAUTH_TOKEN` | Subscription | OAuth token from Claude Pro/Max subscription | +| `ANTHROPIC_API_KEY` | API Key | Anthropic API key for pay-per-use billing | + +### Gemini + +| Variable | Credential Type | Description | +|----------|-----------------|-------------| +| `GEMINI_OAUTH_CREDS` | Subscription | Combined OAuth credentials JSON from `~/.gemini/` | +| `GEMINI_API_KEY` | API Key | Google AI API key for pay-per-use billing | + +### Codex + +| Variable | Credential Type | Description | +|----------|-----------------|-------------| +| `CODEX_AUTH_JSON` | Subscription | OAuth credentials JSON from `~/.codex/auth.json` | +| `OPENAI_API_KEY` | API Key | OpenAI API key for pay-per-use billing | + +These variables are set automatically when you run `hjk run --agent `. You configure which credential type to use via `hjk auth `. + +## Keyring Environment Variables + +These environment variables configure the cross-platform keyring backend used for credential storage. + +| Variable | Type | Description | +|----------|------|-------------| +| `HEADJACK_KEYRING_BACKEND` | string | Override the keyring backend. Options: `keychain` (macOS), `secret-service` (Linux desktop), `keyctl` (Linux kernel), `file` (encrypted file) | +| `HEADJACK_KEYRING_PASSWORD` | string | Password for the encrypted file backend. Required when using `file` backend without interactive prompt. | + +### Example Usage + +```bash +# Force encrypted file backend on Linux +export HEADJACK_KEYRING_BACKEND=file +export HEADJACK_KEYRING_PASSWORD=my-secure-password + +# Use GNOME Keyring on Linux +export HEADJACK_KEYRING_BACKEND=secret-service +``` + ## Container Environment When Headjack starts a container, it sets up the environment to include: 1. Agent-specific environment variables from configuration -2. Any credentials configured via `hjk auth` commands +2. Credential environment variables based on authentication type (see above) 3. Standard container environment variables The exact environment passed to containers depends on the agent type and authentication configuration. diff --git a/docs/docs/tutorials/getting-started.md b/docs/docs/tutorials/getting-started.md index 5729d49..0ee75a8 100644 --- a/docs/docs/tutorials/getting-started.md +++ b/docs/docs/tutorials/getting-started.md @@ -36,7 +36,7 @@ Before starting, ensure you have: - **macOS with Podman installed** - Headjack uses Podman for containerization by default - **Git installed** - Verify with `git --version` -- **A Claude Pro or Max subscription** - For Claude Code authentication +- **A Claude Pro/Max subscription OR an Anthropic API key** - For Claude Code authentication - **A git repository to work in** - Any project repository will work ## Step 1: Install Headjack @@ -62,7 +62,7 @@ headjack version 0.1.0 (abc1234) ## Step 2: Authenticate Claude Code -Before spawning a Claude agent, we need to configure authentication. Headjack stores credentials securely in the macOS Keychain and injects them into containers automatically. +Before spawning a Claude agent, we need to configure authentication. Headjack stores credentials securely in the system keychain and injects them into containers automatically. Run the authentication command: @@ -70,19 +70,41 @@ Run the authentication command: hjk auth claude ``` -This starts an interactive flow: +You will be prompted to choose an authentication method: -1. A URL is displayed. Open it in your browser. -2. Log in with your Anthropic account. -3. A code appears in your browser. Enter it in the terminal when prompted. +``` +Configure claude authentication + +Authentication method: + 1. Subscription (uses CLAUDE_CODE_OAUTH_TOKEN) + 2. API Key (uses ANTHROPIC_API_KEY) +Enter choice (1-2): +``` + +### Option 1: Subscription (Claude Pro/Max) + +If you have a Claude Pro or Max subscription: + +1. Select option 1 +2. In a **separate terminal**, run `claude setup-token` +3. Complete the browser login flow +4. Copy the token that appears (starts with `sk-ant-`) +5. Paste it when prompted by `hjk auth claude` + +### Option 2: API Key + +If you prefer pay-per-use API billing: + +1. Select option 2 +2. Enter your Anthropic API key (starts with `sk-ant-api`) When successful, you will see: ``` -Authentication successful! Token stored in macOS Keychain. +Credentials stored securely. ``` -The stored token uses your Claude Pro/Max subscription rather than API billing. It persists across sessions and only needs to be configured once. +The credential persists across sessions and only needs to be configured once. ## Step 3: Create Your First Instance and Session diff --git a/go.mod b/go.mod index 7d3d495..26e4d71 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,12 @@ module github.com/jmgilman/headjack go 1.25.4 require ( + github.com/99designs/keyring v1.2.2 github.com/creack/pty v1.1.24 github.com/docker/docker v28.5.2+incompatible github.com/go-playground/validator/v10 v10.30.1 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/go-containerregistry v0.20.7 - github.com/keybase/go-keychain v0.0.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -17,23 +17,52 @@ require ( ) require ( + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v0.8.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/cli v29.0.3+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mtibben/percent v0.2.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -42,6 +71,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/vbatts/tar-split v0.12.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index c14d4b9..7e18c49 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,38 @@ +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,6 +44,12 @@ github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaft github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -30,24 +66,48 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= -github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -58,6 +118,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -79,6 +142,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -86,6 +151,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= @@ -94,7 +161,9 @@ golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= @@ -104,8 +173,8 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 80a9bec..823be6f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,7 +1,63 @@ // Package auth provides authentication for agent CLIs. package auth -import "context" +import ( + "encoding/json" + "fmt" +) + +// CredentialType distinguishes between subscription-based and API key authentication. +type CredentialType string + +const ( + // CredentialTypeSubscription represents OAuth/subscription-based authentication. + CredentialTypeSubscription CredentialType = "subscription" + + // CredentialTypeAPIKey represents direct API key authentication. + CredentialTypeAPIKey CredentialType = "apikey" +) + +// Credential holds a provider's authentication credential with its type. +type Credential struct { + Type CredentialType `json:"type"` + Value string `json:"value"` +} + +// MarshalJSON implements json.Marshaler. +func (c Credential) MarshalJSON() ([]byte, error) { + type credentialAlias Credential + return json.Marshal(credentialAlias(c)) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (c *Credential) UnmarshalJSON(data []byte) error { + type credentialAlias Credential + var alias credentialAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *c = Credential(alias) + return nil +} + +// ProviderInfo describes a provider's authentication options and configuration. +type ProviderInfo struct { + // Name is the provider identifier (e.g., "claude", "gemini", "codex"). + Name string + + // SubscriptionEnvVar is the environment variable for subscription credentials. + SubscriptionEnvVar string + + // APIKeyEnvVar is the environment variable for API key credentials. + APIKeyEnvVar string + + // KeychainAccount is the keychain account name for storing credentials. + KeychainAccount string + + // RequiresContainerSetup indicates whether subscription credentials need + // file setup in the container (e.g., writing config files). + RequiresContainerSetup bool +} // Storage abstracts credential storage backends. // @@ -21,10 +77,61 @@ type Storage interface { // //go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/provider.go . Provider type Provider interface { - // Authenticate runs the provider's authentication flow and stores the credential. - // This typically involves interactive user input (browser login, code entry, etc.). - Authenticate(ctx context.Context, storage Storage) error + // Info returns metadata about this provider. + Info() ProviderInfo + + // CheckSubscription checks if subscription credentials exist and returns them. + // For providers that auto-detect credentials (Gemini, Codex), this reads from + // the expected file locations. For providers requiring manual entry (Claude), + // this returns an error with instructions. + CheckSubscription() (string, error) + + // ValidateSubscription validates a subscription credential value. + ValidateSubscription(value string) error + + // ValidateAPIKey validates an API key credential value. + ValidateAPIKey(value string) error + + // Store saves a credential to storage. + Store(storage Storage, cred Credential) error + + // Load retrieves the stored credential for this provider. + Load(storage Storage) (*Credential, error) +} + +// Prompter abstracts user interaction for credential collection. +// +//go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/prompter.go . Prompter +type Prompter interface { + // Print outputs text to the user. + Print(message string) + + // PromptSecret prompts for secret input (no echo). + PromptSecret(prompt string) (string, error) + + // PromptChoice prompts user to select from options, returns 0-based index. + PromptChoice(prompt string, options []string) (int, error) +} + +// StoreCredential is a helper function to store a credential in JSON format. +func StoreCredential(storage Storage, account string, cred Credential) error { + data, err := json.Marshal(cred) + if err != nil { + return fmt.Errorf("marshal credential: %w", err) + } + return storage.Set(account, string(data)) +} + +// LoadCredential is a helper function to load a credential from JSON format. +func LoadCredential(storage Storage, account string) (*Credential, error) { + data, err := storage.Get(account) + if err != nil { + return nil, err + } - // Get retrieves the stored credential for this provider. - Get(storage Storage) (string, error) + var cred Credential + if err := json.Unmarshal([]byte(data), &cred); err != nil { + return nil, fmt.Errorf("unmarshal credential: %w", err) + } + return &cred, nil } diff --git a/internal/auth/claude.go b/internal/auth/claude.go index 11e07c4..8aa5a19 100644 --- a/internal/auth/claude.go +++ b/internal/auth/claude.go @@ -1,27 +1,18 @@ package auth import ( - "bytes" - "context" "errors" - "fmt" - "io" - "os" - "os/exec" - "os/signal" - "regexp" "strings" - "syscall" - - "github.com/creack/pty" - "golang.org/x/term" ) -// claudeAccountName is the storage key for Claude credentials. -const claudeAccountName = "claude-oidc-token" - -// ansiEscape matches ANSI escape sequences. -var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]`) +// Claude provider configuration. +var claudeInfo = ProviderInfo{ + Name: "claude", + SubscriptionEnvVar: "CLAUDE_CODE_OAUTH_TOKEN", + APIKeyEnvVar: "ANTHROPIC_API_KEY", + KeychainAccount: "claude-credential", + RequiresContainerSetup: true, +} // ClaudeProvider authenticates with Claude Code CLI. type ClaudeProvider struct{} @@ -31,96 +22,100 @@ func NewClaudeProvider() *ClaudeProvider { return &ClaudeProvider{} } -// Authenticate runs `claude setup-token` interactively and stores the OAuth token. -func (p *ClaudeProvider) Authenticate(ctx context.Context, storage Storage) error { - // Check if claude CLI is available - if _, err := exec.LookPath("claude"); err != nil { - return errors.New("claude CLI not found in PATH: please install Claude Code first") - } +// Info returns metadata about the Claude provider. +func (p *ClaudeProvider) Info() ProviderInfo { + return claudeInfo +} + +// CheckSubscription returns instructions for obtaining a Claude OAuth token. +// Unlike Gemini/Codex, Claude requires manual token retrieval via `claude setup-token`. +func (p *ClaudeProvider) CheckSubscription() (string, error) { + //nolint:staticcheck // ST1005: Intentionally capitalized - user-facing instructions + return "", errors.New(`Claude subscription credentials must be entered manually. - cmd := exec.CommandContext(ctx, "claude", "setup-token") +To get your OAuth token: + 1. Run: claude setup-token + 2. Complete the browser login flow + 3. Copy the token (starts with sk-ant-)`) +} - // Start the command with a PTY so Ink gets the TTY it needs - ptmx, err := pty.Start(cmd) - if err != nil { - return fmt.Errorf("start pty: %w", err) +// ValidateSubscription validates a Claude OAuth token. +func (p *ClaudeProvider) ValidateSubscription(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("token cannot be empty") } - 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) + if !strings.HasPrefix(value, "sk-ant-") { + return errors.New("invalid Claude OAuth token: must start with 'sk-ant-'") } - defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() - - // Buffer to capture output for token extraction - var outputBuf bytes.Buffer - - // Copy stdin to the pty (user input) - go func() { - _, _ = io.Copy(ptmx, os.Stdin) //nolint:errcheck // Best-effort stdin forwarding - }() - - // Copy pty output to both stdout (display) and our buffer (capture) - _, _ = io.Copy(io.MultiWriter(os.Stdout, &outputBuf), ptmx) //nolint:errcheck // EOF expected + return nil +} - // Wait for command to complete - if err := cmd.Wait(); err != nil { - return fmt.Errorf("claude exited with error: %w", err) +// ValidateAPIKey validates an Anthropic API key. +func (p *ClaudeProvider) ValidateAPIKey(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("API key cannot be empty") } - - // Extract token from captured output - token := extractToken(outputBuf.String()) - if token == "" { - return errors.New("no token received from claude setup-token") + // Anthropic API keys start with sk-ant-api + if !strings.HasPrefix(value, "sk-ant-api") { + return errors.New("invalid Anthropic API key: must start with 'sk-ant-api'") } + return nil +} - // Store the token - if err := storage.Set(claudeAccountName, token); err != nil { - return fmt.Errorf("store token: %w", err) - } +// Store saves a credential to storage. +func (p *ClaudeProvider) Store(storage Storage, cred Credential) error { + return StoreCredential(storage, claudeInfo.KeychainAccount, cred) +} - return nil +// Load retrieves the stored credential for Claude. +func (p *ClaudeProvider) Load(storage Storage) (*Credential, error) { + return LoadCredential(storage, claudeInfo.KeychainAccount) } -// Get retrieves the stored Claude OAuth token. -func (p *ClaudeProvider) Get(storage Storage) (string, error) { - return storage.Get(claudeAccountName) +// isClaudeToken checks if a string looks like a Claude OAuth token. +// Claude tokens have the format: sk-ant-oat01-... +func isClaudeToken(s string) bool { + return strings.HasPrefix(s, "sk-ant-") } -// extractToken searches the output for a Claude OAuth token. +// extractToken extracts a Claude OAuth token from command output. +// It scans each line, strips ANSI codes, and returns the first token found. func extractToken(output string) string { - // Strip ANSI escape codes - clean := ansiEscape.ReplaceAllString(output, "") - - // Normalize line endings (PTY may use \r\n or just \r) - clean = strings.ReplaceAll(clean, "\r\n", "\n") - clean = strings.ReplaceAll(clean, "\r", "\n") + // Split on any newline type + lines := strings.FieldsFunc(output, func(r rune) bool { + return r == '\n' || r == '\r' + }) - lines := strings.Split(clean, "\n") for _, line := range lines { - trimmed := strings.TrimSpace(line) - if isClaudeToken(trimmed) { - return trimmed + line = strings.TrimSpace(line) + line = stripANSI(line) + if isClaudeToken(line) { + return line } } return "" } -// isClaudeToken checks if a string looks like a Claude OAuth token. -// Claude tokens have the format: sk-ant-oat01-... -func isClaudeToken(s string) bool { - return strings.HasPrefix(s, "sk-ant-") +// stripANSI removes ANSI escape codes from a string. +func stripANSI(s string) string { + var result strings.Builder + inEscape := false + + for i := range len(s) { + if s[i] == '\x1b' { + inEscape = true + continue + } + if inEscape { + // ANSI sequences end with a letter + if (s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') { + inEscape = false + } + continue + } + result.WriteByte(s[i]) + } + return result.String() } diff --git a/internal/auth/codex.go b/internal/auth/codex.go index b665ed0..88b808e 100644 --- a/internal/auth/codex.go +++ b/internal/auth/codex.go @@ -1,26 +1,25 @@ package auth import ( - "context" "errors" "fmt" - "io" "os" - "os/exec" - "os/signal" "path/filepath" - "syscall" - - "github.com/creack/pty" - "golang.org/x/term" + "strings" ) -// 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") +// Codex provider configuration. +var codexInfo = ProviderInfo{ + Name: "codex", + SubscriptionEnvVar: "CODEX_AUTH_JSON", + APIKeyEnvVar: "OPENAI_API_KEY", + KeychainAccount: "codex-credential", + RequiresContainerSetup: true, +} + // CodexProvider authenticates with OpenAI Codex CLI. type CodexProvider struct{} @@ -29,79 +28,56 @@ 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 - } +// Info returns metadata about the Codex provider. +func (p *CodexProvider) Info() ProviderInfo { + return codexInfo +} - // Read and store the auth.json file +// CheckSubscription reads cached Codex CLI credentials from ~/.codex/auth.json. +// If credentials exist and are valid, returns them as a JSON string. +// If credentials don't exist, returns an error with instructions. +func (p *CodexProvider) CheckSubscription() (string, error) { authData, err := readCodexAuth() if err != nil { - return err + return "", err } + return string(authData), nil +} - if err := storage.Set(codexAccountName, string(authData)); err != nil { - return fmt.Errorf("store credentials: %w", err) +// ValidateSubscription validates Codex auth.json credentials. +func (p *CodexProvider) ValidateSubscription(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("credentials cannot be empty") + } + // Codex auth.json should be valid JSON + if !strings.HasPrefix(value, "{") { + return errors.New("invalid auth.json: must be a JSON object") } - 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) +// ValidateAPIKey validates an OpenAI API key. +func (p *CodexProvider) ValidateAPIKey(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("API key cannot be empty") } - 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) + // OpenAI API keys start with "sk-" + if !strings.HasPrefix(value, "sk-") { + return errors.New("invalid OpenAI API key: must start with 'sk-'") } - 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 + return nil +} - // Wait for command to complete - if err := cmd.Wait(); err != nil { - return fmt.Errorf("codex login exited with error: %w", err) - } +// Store saves a credential to storage. +func (p *CodexProvider) Store(storage Storage, cred Credential) error { + return StoreCredential(storage, codexInfo.KeychainAccount, cred) +} - return nil +// Load retrieves the stored credential for Codex. +func (p *CodexProvider) Load(storage Storage) (*Credential, error) { + return LoadCredential(storage, codexInfo.KeychainAccount) } // readCodexAuth reads the auth.json file from the Codex config directory. @@ -110,7 +86,13 @@ func readCodexAuth() ([]byte, error) { 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") + //nolint:staticcheck // ST1005: Intentionally capitalized - user-facing instructions + return nil, errors.New(`Codex credentials not found. + +To authenticate with your ChatGPT subscription: + 1. Run: codex login + 2. Complete the OAuth flow in your browser + 3. Run: hjk auth codex`) } return nil, fmt.Errorf("read auth.json: %w", err) } diff --git a/internal/auth/codex_test.go b/internal/auth/codex_test.go index ecc0f2b..baef46a 100644 --- a/internal/auth/codex_test.go +++ b/internal/auth/codex_test.go @@ -36,7 +36,7 @@ func TestReadCodexAuth(t *testing.T) { got, err := readCodexAuth() assert.Nil(t, got) - assert.ErrorContains(t, err, "codex auth.json not found") + assert.ErrorContains(t, err, "Codex credentials not found") }) t.Run("empty auth.json", func(t *testing.T) { diff --git a/internal/auth/gemini.go b/internal/auth/gemini.go index 3c1038b..db8884d 100644 --- a/internal/auth/gemini.go +++ b/internal/auth/gemini.go @@ -1,20 +1,26 @@ package auth import ( - "context" "encoding/json" "errors" "fmt" "os" "path/filepath" + "strings" ) -// 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") +// Gemini provider configuration. +var geminiInfo = ProviderInfo{ + Name: "gemini", + SubscriptionEnvVar: "GEMINI_OAUTH_CREDS", + APIKeyEnvVar: "GEMINI_API_KEY", + KeychainAccount: "gemini-credential", + RequiresContainerSetup: true, +} + // GeminiConfig holds all configuration needed to authenticate Gemini CLI. type GeminiConfig struct { OAuthCreds json.RawMessage `json:"oauth_creds"` @@ -29,31 +35,84 @@ 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 +// Info returns metadata about the Gemini provider. +func (p *GeminiProvider) Info() ProviderInfo { + return geminiInfo +} + +// CheckSubscription reads cached Gemini CLI credentials from ~/.gemini/. +// If credentials exist and are valid, returns them as a JSON string. +// If credentials don't exist, returns an error with instructions. +func (p *GeminiProvider) CheckSubscription() (string, error) { config, err := readGeminiConfig() if err != nil { - return err + return "", err } - // Store the config as JSON + // Marshal the config to JSON for storage configJSON, err := json.Marshal(config) if err != nil { - return fmt.Errorf("marshal config: %w", err) + return "", fmt.Errorf("marshal config: %w", err) + } + + return string(configJSON), nil +} + +// ValidateSubscription validates Gemini OAuth credentials. +func (p *GeminiProvider) ValidateSubscription(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("credentials cannot be empty") + } + + // Try to parse as GeminiConfig JSON + var config GeminiConfig + if err := json.Unmarshal([]byte(value), &config); err != nil { + return fmt.Errorf("invalid JSON format: %w", err) + } + + if len(config.OAuthCreds) == 0 { + return errors.New("missing oauth_creds in credentials") + } + if len(config.GoogleAccounts) == 0 { + return errors.New("missing google_accounts in credentials") } - if err := storage.Set(geminiAccountName, string(configJSON)); err != nil { - return fmt.Errorf("store config: %w", err) + // Validate OAuth creds have a refresh token + var oauthCreds struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.Unmarshal(config.OAuthCreds, &oauthCreds); err != nil { + return fmt.Errorf("parse oauth_creds: %w", err) } + if oauthCreds.RefreshToken == "" { + return errors.New("missing refresh_token in oauth_creds") + } + + return nil +} +// ValidateAPIKey validates a Google AI API key. +func (p *GeminiProvider) ValidateAPIKey(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("API key cannot be empty") + } + // Google AI API keys typically start with "AIza" + if !strings.HasPrefix(value, "AIza") { + return errors.New("invalid Google AI API key: must start with 'AIza'") + } return nil } -// Get retrieves the stored Gemini config. -func (p *GeminiProvider) Get(storage Storage) (string, error) { - return storage.Get(geminiAccountName) +// Store saves a credential to storage. +func (p *GeminiProvider) Store(storage Storage, cred Credential) error { + return StoreCredential(storage, geminiInfo.KeychainAccount, cred) +} + +// Load retrieves the stored credential for Gemini. +func (p *GeminiProvider) Load(storage Storage) (*Credential, error) { + return LoadCredential(storage, geminiInfo.KeychainAccount) } // readGeminiConfig reads OAuth credentials and account info from Gemini CLI's cache. @@ -63,7 +122,13 @@ func readGeminiConfig() (*GeminiConfig, error) { 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") + //nolint:staticcheck // ST1005: Intentionally capitalized - user-facing instructions + return nil, errors.New(`Gemini credentials not found. + +To authenticate with your Gemini subscription: + 1. Run: gemini + 2. Complete the Google OAuth login + 3. Run: hjk auth gemini`) } return nil, fmt.Errorf("read oauth_creds.json: %w", err) } diff --git a/internal/auth/gemini_test.go b/internal/auth/gemini_test.go index 55b497f..24ff0d4 100644 --- a/internal/auth/gemini_test.go +++ b/internal/auth/gemini_test.go @@ -52,7 +52,7 @@ func TestReadGeminiConfig(t *testing.T) { got, err := readGeminiConfig() assert.Nil(t, got) - assert.ErrorContains(t, err, "gemini credentials not found") + assert.ErrorContains(t, err, "Gemini credentials not found") }) t.Run("invalid oauth json", func(t *testing.T) { diff --git a/internal/auth/mocks/prompter.go b/internal/auth/mocks/prompter.go new file mode 100644 index 0000000..bebe4ad --- /dev/null +++ b/internal/auth/mocks/prompter.go @@ -0,0 +1,170 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package mocks + +import ( + "sync" + + "github.com/jmgilman/headjack/internal/auth" +) + +// Ensure, that PrompterMock does implement auth.Prompter. +// If this is not the case, regenerate this file with moq. +var _ auth.Prompter = &PrompterMock{} + +// PrompterMock is a mock implementation of auth.Prompter. +// +// func TestSomethingThatUsesPrompter(t *testing.T) { +// +// // make and configure a mocked auth.Prompter +// mockedPrompter := &PrompterMock{ +// PrintFunc: func(message string) { +// panic("mock out the Print method") +// }, +// PromptChoiceFunc: func(prompt string, options []string) (int, error) { +// panic("mock out the PromptChoice method") +// }, +// PromptSecretFunc: func(prompt string) (string, error) { +// panic("mock out the PromptSecret method") +// }, +// } +// +// // use mockedPrompter in code that requires auth.Prompter +// // and then make assertions. +// +// } +type PrompterMock struct { + // PrintFunc mocks the Print method. + PrintFunc func(message string) + + // PromptChoiceFunc mocks the PromptChoice method. + PromptChoiceFunc func(prompt string, options []string) (int, error) + + // PromptSecretFunc mocks the PromptSecret method. + PromptSecretFunc func(prompt string) (string, error) + + // calls tracks calls to the methods. + calls struct { + // Print holds details about calls to the Print method. + Print []struct { + // Message is the message argument value. + Message string + } + // PromptChoice holds details about calls to the PromptChoice method. + PromptChoice []struct { + // Prompt is the prompt argument value. + Prompt string + // Options is the options argument value. + Options []string + } + // PromptSecret holds details about calls to the PromptSecret method. + PromptSecret []struct { + // Prompt is the prompt argument value. + Prompt string + } + } + lockPrint sync.RWMutex + lockPromptChoice sync.RWMutex + lockPromptSecret sync.RWMutex +} + +// Print calls PrintFunc. +func (mock *PrompterMock) Print(message string) { + if mock.PrintFunc == nil { + panic("PrompterMock.PrintFunc: method is nil but Prompter.Print was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockPrint.Lock() + mock.calls.Print = append(mock.calls.Print, callInfo) + mock.lockPrint.Unlock() + mock.PrintFunc(message) +} + +// PrintCalls gets all the calls that were made to Print. +// Check the length with: +// +// len(mockedPrompter.PrintCalls()) +func (mock *PrompterMock) PrintCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockPrint.RLock() + calls = mock.calls.Print + mock.lockPrint.RUnlock() + return calls +} + +// PromptChoice calls PromptChoiceFunc. +func (mock *PrompterMock) PromptChoice(prompt string, options []string) (int, error) { + if mock.PromptChoiceFunc == nil { + panic("PrompterMock.PromptChoiceFunc: method is nil but Prompter.PromptChoice was just called") + } + callInfo := struct { + Prompt string + Options []string + }{ + Prompt: prompt, + Options: options, + } + mock.lockPromptChoice.Lock() + mock.calls.PromptChoice = append(mock.calls.PromptChoice, callInfo) + mock.lockPromptChoice.Unlock() + return mock.PromptChoiceFunc(prompt, options) +} + +// PromptChoiceCalls gets all the calls that were made to PromptChoice. +// Check the length with: +// +// len(mockedPrompter.PromptChoiceCalls()) +func (mock *PrompterMock) PromptChoiceCalls() []struct { + Prompt string + Options []string +} { + var calls []struct { + Prompt string + Options []string + } + mock.lockPromptChoice.RLock() + calls = mock.calls.PromptChoice + mock.lockPromptChoice.RUnlock() + return calls +} + +// PromptSecret calls PromptSecretFunc. +func (mock *PrompterMock) PromptSecret(prompt string) (string, error) { + if mock.PromptSecretFunc == nil { + panic("PrompterMock.PromptSecretFunc: method is nil but Prompter.PromptSecret was just called") + } + callInfo := struct { + Prompt string + }{ + Prompt: prompt, + } + mock.lockPromptSecret.Lock() + mock.calls.PromptSecret = append(mock.calls.PromptSecret, callInfo) + mock.lockPromptSecret.Unlock() + return mock.PromptSecretFunc(prompt) +} + +// PromptSecretCalls gets all the calls that were made to PromptSecret. +// Check the length with: +// +// len(mockedPrompter.PromptSecretCalls()) +func (mock *PrompterMock) PromptSecretCalls() []struct { + Prompt string +} { + var calls []struct { + Prompt string + } + mock.lockPromptSecret.RLock() + calls = mock.calls.PromptSecret + mock.lockPromptSecret.RUnlock() + return calls +} diff --git a/internal/auth/mocks/provider.go b/internal/auth/mocks/provider.go index 266c320..6ec9cd6 100644 --- a/internal/auth/mocks/provider.go +++ b/internal/auth/mocks/provider.go @@ -4,7 +4,6 @@ package mocks import ( - "context" "sync" "github.com/jmgilman/headjack/internal/auth" @@ -20,11 +19,23 @@ var _ auth.Provider = &ProviderMock{} // // // make and configure a mocked auth.Provider // mockedProvider := &ProviderMock{ -// AuthenticateFunc: func(ctx context.Context, storage auth.Storage) error { -// panic("mock out the Authenticate method") +// CheckSubscriptionFunc: func() (string, error) { +// panic("mock out the CheckSubscription method") // }, -// GetFunc: func(storage auth.Storage) (string, error) { -// panic("mock out the Get method") +// InfoFunc: func() auth.ProviderInfo { +// panic("mock out the Info method") +// }, +// LoadFunc: func(storage auth.Storage) (*auth.Credential, error) { +// panic("mock out the Load method") +// }, +// StoreFunc: func(storage auth.Storage, cred auth.Credential) error { +// panic("mock out the Store method") +// }, +// ValidateAPIKeyFunc: func(value string) error { +// panic("mock out the ValidateAPIKey method") +// }, +// ValidateSubscriptionFunc: func(value string) error { +// panic("mock out the ValidateSubscription method") // }, // } // @@ -33,95 +44,245 @@ var _ auth.Provider = &ProviderMock{} // // } type ProviderMock struct { - // AuthenticateFunc mocks the Authenticate method. - AuthenticateFunc func(ctx context.Context, storage auth.Storage) error + // CheckSubscriptionFunc mocks the CheckSubscription method. + CheckSubscriptionFunc func() (string, error) + + // InfoFunc mocks the Info method. + InfoFunc func() auth.ProviderInfo + + // LoadFunc mocks the Load method. + LoadFunc func(storage auth.Storage) (*auth.Credential, error) + + // StoreFunc mocks the Store method. + StoreFunc func(storage auth.Storage, cred auth.Credential) error + + // ValidateAPIKeyFunc mocks the ValidateAPIKey method. + ValidateAPIKeyFunc func(value string) error - // GetFunc mocks the Get method. - GetFunc func(storage auth.Storage) (string, error) + // ValidateSubscriptionFunc mocks the ValidateSubscription method. + ValidateSubscriptionFunc func(value string) error // calls tracks calls to the methods. calls struct { - // Authenticate holds details about calls to the Authenticate method. - Authenticate []struct { - // Ctx is the ctx argument value. - Ctx context.Context + // CheckSubscription holds details about calls to the CheckSubscription method. + CheckSubscription []struct { + } + // Info holds details about calls to the Info method. + Info []struct { + } + // Load holds details about calls to the Load method. + Load []struct { // Storage is the storage argument value. Storage auth.Storage } - // Get holds details about calls to the Get method. - Get []struct { + // Store holds details about calls to the Store method. + Store []struct { // Storage is the storage argument value. Storage auth.Storage + // Cred is the cred argument value. + Cred auth.Credential + } + // ValidateAPIKey holds details about calls to the ValidateAPIKey method. + ValidateAPIKey []struct { + // Value is the value argument value. + Value string + } + // ValidateSubscription holds details about calls to the ValidateSubscription method. + ValidateSubscription []struct { + // Value is the value argument value. + Value string } } - lockAuthenticate sync.RWMutex - lockGet sync.RWMutex + lockCheckSubscription sync.RWMutex + lockInfo sync.RWMutex + lockLoad sync.RWMutex + lockStore sync.RWMutex + lockValidateAPIKey sync.RWMutex + lockValidateSubscription sync.RWMutex +} + +// CheckSubscription calls CheckSubscriptionFunc. +func (mock *ProviderMock) CheckSubscription() (string, error) { + if mock.CheckSubscriptionFunc == nil { + panic("ProviderMock.CheckSubscriptionFunc: method is nil but Provider.CheckSubscription was just called") + } + callInfo := struct { + }{} + mock.lockCheckSubscription.Lock() + mock.calls.CheckSubscription = append(mock.calls.CheckSubscription, callInfo) + mock.lockCheckSubscription.Unlock() + return mock.CheckSubscriptionFunc() +} + +// CheckSubscriptionCalls gets all the calls that were made to CheckSubscription. +// Check the length with: +// +// len(mockedProvider.CheckSubscriptionCalls()) +func (mock *ProviderMock) CheckSubscriptionCalls() []struct { +} { + var calls []struct { + } + mock.lockCheckSubscription.RLock() + calls = mock.calls.CheckSubscription + mock.lockCheckSubscription.RUnlock() + return calls +} + +// Info calls InfoFunc. +func (mock *ProviderMock) Info() auth.ProviderInfo { + if mock.InfoFunc == nil { + panic("ProviderMock.InfoFunc: method is nil but Provider.Info was just called") + } + callInfo := struct { + }{} + mock.lockInfo.Lock() + mock.calls.Info = append(mock.calls.Info, callInfo) + mock.lockInfo.Unlock() + return mock.InfoFunc() +} + +// InfoCalls gets all the calls that were made to Info. +// Check the length with: +// +// len(mockedProvider.InfoCalls()) +func (mock *ProviderMock) InfoCalls() []struct { +} { + var calls []struct { + } + mock.lockInfo.RLock() + calls = mock.calls.Info + mock.lockInfo.RUnlock() + return calls } -// Authenticate calls AuthenticateFunc. -func (mock *ProviderMock) Authenticate(ctx context.Context, storage auth.Storage) error { - if mock.AuthenticateFunc == nil { - panic("ProviderMock.AuthenticateFunc: method is nil but Provider.Authenticate was just called") +// Load calls LoadFunc. +func (mock *ProviderMock) Load(storage auth.Storage) (*auth.Credential, error) { + if mock.LoadFunc == nil { + panic("ProviderMock.LoadFunc: method is nil but Provider.Load was just called") } callInfo := struct { - Ctx context.Context Storage auth.Storage }{ - Ctx: ctx, Storage: storage, } - mock.lockAuthenticate.Lock() - mock.calls.Authenticate = append(mock.calls.Authenticate, callInfo) - mock.lockAuthenticate.Unlock() - return mock.AuthenticateFunc(ctx, storage) + mock.lockLoad.Lock() + mock.calls.Load = append(mock.calls.Load, callInfo) + mock.lockLoad.Unlock() + return mock.LoadFunc(storage) } -// AuthenticateCalls gets all the calls that were made to Authenticate. +// LoadCalls gets all the calls that were made to Load. // Check the length with: // -// len(mockedProvider.AuthenticateCalls()) -func (mock *ProviderMock) AuthenticateCalls() []struct { - Ctx context.Context +// len(mockedProvider.LoadCalls()) +func (mock *ProviderMock) LoadCalls() []struct { Storage auth.Storage } { var calls []struct { - Ctx context.Context Storage auth.Storage } - mock.lockAuthenticate.RLock() - calls = mock.calls.Authenticate - mock.lockAuthenticate.RUnlock() + mock.lockLoad.RLock() + calls = mock.calls.Load + mock.lockLoad.RUnlock() return calls } -// Get calls GetFunc. -func (mock *ProviderMock) Get(storage auth.Storage) (string, error) { - if mock.GetFunc == nil { - panic("ProviderMock.GetFunc: method is nil but Provider.Get was just called") +// Store calls StoreFunc. +func (mock *ProviderMock) Store(storage auth.Storage, cred auth.Credential) error { + if mock.StoreFunc == nil { + panic("ProviderMock.StoreFunc: method is nil but Provider.Store was just called") } callInfo := struct { Storage auth.Storage + Cred auth.Credential }{ Storage: storage, + Cred: cred, } - mock.lockGet.Lock() - mock.calls.Get = append(mock.calls.Get, callInfo) - mock.lockGet.Unlock() - return mock.GetFunc(storage) + mock.lockStore.Lock() + mock.calls.Store = append(mock.calls.Store, callInfo) + mock.lockStore.Unlock() + return mock.StoreFunc(storage, cred) } -// GetCalls gets all the calls that were made to Get. +// StoreCalls gets all the calls that were made to Store. // Check the length with: // -// len(mockedProvider.GetCalls()) -func (mock *ProviderMock) GetCalls() []struct { +// len(mockedProvider.StoreCalls()) +func (mock *ProviderMock) StoreCalls() []struct { Storage auth.Storage + Cred auth.Credential } { var calls []struct { Storage auth.Storage + Cred auth.Credential + } + mock.lockStore.RLock() + calls = mock.calls.Store + mock.lockStore.RUnlock() + return calls +} + +// ValidateAPIKey calls ValidateAPIKeyFunc. +func (mock *ProviderMock) ValidateAPIKey(value string) error { + if mock.ValidateAPIKeyFunc == nil { + panic("ProviderMock.ValidateAPIKeyFunc: method is nil but Provider.ValidateAPIKey was just called") + } + callInfo := struct { + Value string + }{ + Value: value, + } + mock.lockValidateAPIKey.Lock() + mock.calls.ValidateAPIKey = append(mock.calls.ValidateAPIKey, callInfo) + mock.lockValidateAPIKey.Unlock() + return mock.ValidateAPIKeyFunc(value) +} + +// ValidateAPIKeyCalls gets all the calls that were made to ValidateAPIKey. +// Check the length with: +// +// len(mockedProvider.ValidateAPIKeyCalls()) +func (mock *ProviderMock) ValidateAPIKeyCalls() []struct { + Value string +} { + var calls []struct { + Value string + } + mock.lockValidateAPIKey.RLock() + calls = mock.calls.ValidateAPIKey + mock.lockValidateAPIKey.RUnlock() + return calls +} + +// ValidateSubscription calls ValidateSubscriptionFunc. +func (mock *ProviderMock) ValidateSubscription(value string) error { + if mock.ValidateSubscriptionFunc == nil { + panic("ProviderMock.ValidateSubscriptionFunc: method is nil but Provider.ValidateSubscription was just called") + } + callInfo := struct { + Value string + }{ + Value: value, + } + mock.lockValidateSubscription.Lock() + mock.calls.ValidateSubscription = append(mock.calls.ValidateSubscription, callInfo) + mock.lockValidateSubscription.Unlock() + return mock.ValidateSubscriptionFunc(value) +} + +// ValidateSubscriptionCalls gets all the calls that were made to ValidateSubscription. +// Check the length with: +// +// len(mockedProvider.ValidateSubscriptionCalls()) +func (mock *ProviderMock) ValidateSubscriptionCalls() []struct { + Value string +} { + var calls []struct { + Value string } - mock.lockGet.RLock() - calls = mock.calls.Get - mock.lockGet.RUnlock() + mock.lockValidateSubscription.RLock() + calls = mock.calls.ValidateSubscription + mock.lockValidateSubscription.RUnlock() return calls } diff --git a/internal/auth/prompter.go b/internal/auth/prompter.go new file mode 100644 index 0000000..5006c54 --- /dev/null +++ b/internal/auth/prompter.go @@ -0,0 +1,72 @@ +package auth + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/huh" +) + +// HuhPrompter implements Prompter using charmbracelet/huh for interactive forms. +type HuhPrompter struct{} + +// NewTerminalPrompter creates a new HuhPrompter for interactive terminal prompts. +func NewTerminalPrompter() *HuhPrompter { + return &HuhPrompter{} +} + +// Print outputs text to the user. +func (p *HuhPrompter) Print(message string) { + fmt.Println(message) +} + +// PromptSecret prompts for secret input with masked display. +func (p *HuhPrompter) PromptSecret(prompt string) (string, error) { + var value string + + err := huh.NewInput(). + Title(prompt). + EchoMode(huh.EchoModePassword). + Value(&value). + Run() + + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", errors.New("canceled by user") + } + return "", fmt.Errorf("prompt input: %w", err) + } + + return strings.TrimSpace(value), nil +} + +// PromptChoice prompts user to select from options and returns the 0-based index. +func (p *HuhPrompter) PromptChoice(prompt string, options []string) (int, error) { + if len(options) == 0 { + return 0, errors.New("no options provided") + } + + // Build huh options with display labels and index values + huhOptions := make([]huh.Option[int], len(options)) + for i, opt := range options { + huhOptions[i] = huh.NewOption(opt, i) + } + + var selected int + + err := huh.NewSelect[int](). + Title(prompt). + Options(huhOptions...). + Value(&selected). + Run() + + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return 0, errors.New("canceled by user") + } + return 0, fmt.Errorf("prompt choice: %w", err) + } + + return selected, nil +} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 4bba50d..ab12c77 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "github.com/spf13/cobra" @@ -14,8 +15,8 @@ var authCmd = &cobra.Command{ Short: "Configure authentication for agent CLIs", Long: `Configure authentication for supported agent CLIs. -Runs the agent-specific authentication flow and stores credentials -securely in the macOS Keychain.`, +Prompts for authentication method (subscription or API key) and stores +credentials securely in the system keychain.`, } var authClaudeCmd = &cobra.Command{ @@ -23,13 +24,9 @@ var authClaudeCmd = &cobra.Command{ Short: "Configure Claude Code authentication", Long: `Configure Claude Code authentication for use in Headjack containers. -This command runs the Claude setup-token flow which: -1. Displays a URL to open in your browser -2. Prompts you to log in with your Anthropic account -3. Presents a code to enter back in the terminal -4. Stores the resulting OAuth token securely in macOS Keychain - -The stored token uses your Claude Pro/Max subscription rather than API billing.`, +Choose between: + 1. Subscription: Uses your Claude Pro/Max subscription via OAuth token + 2. API Key: Uses an Anthropic API key for pay-per-use billing`, Example: ` # Set up Claude Code authentication headjack auth claude`, RunE: runAuthClaude, @@ -40,14 +37,10 @@ var authGeminiCmd = &cobra.Command{ 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 +Choose between: + 1. Subscription: Uses your Google AI Pro/Ultra subscription via OAuth + 2. API Key: Uses a Google AI API key for pay-per-use billing`, + Example: ` # Set up Gemini CLI authentication headjack auth gemini`, RunE: runAuthGemini, } @@ -57,67 +50,177 @@ var authCodexCmd = &cobra.Command{ 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.`, +Choose between: + 1. Subscription: Uses your ChatGPT Plus/Pro/Team subscription via OAuth + 2. API Key: Uses an OpenAI API key for pay-per-use billing`, Example: ` # Set up Codex CLI authentication headjack auth codex`, RunE: runAuthCodex, } +var authStatusFlag bool + func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(authClaudeCmd) authCmd.AddCommand(authGeminiCmd) authCmd.AddCommand(authCodexCmd) + + // Add --status flag to all auth subcommands + for _, cmd := range []*cobra.Command{authClaudeCmd, authGeminiCmd, authCodexCmd} { + cmd.Flags().BoolVar(&authStatusFlag, "status", false, "Show current authentication status") + } +} + +func runAuthClaude(_ *cobra.Command, _ []string) error { + return runAuth(auth.NewClaudeProvider()) +} + +func runAuthGemini(_ *cobra.Command, _ []string) error { + return runAuth(auth.NewGeminiProvider()) } -func runAuthClaude(cmd *cobra.Command, _ []string) error { - fmt.Println("Starting Claude authentication flow...") - fmt.Println() +func runAuthCodex(_ *cobra.Command, _ []string) error { + return runAuth(auth.NewCodexProvider()) +} + +// runAuth handles both --status checks and interactive auth flows. +func runAuth(provider auth.Provider) error { + if authStatusFlag { + return showAuthStatus(provider) + } + return runAuthFlow(provider) +} + +// showAuthStatus displays the current authentication status for a provider. +func showAuthStatus(provider auth.Provider) error { + storage, err := keychain.New() + if err != nil { + return fmt.Errorf("initialize credential storage: %w", err) + } - provider := auth.NewClaudeProvider() - storage := keychain.New() + info := provider.Info() + cred, err := provider.Load(storage) + if errors.Is(err, keychain.ErrNotFound) { + fmt.Printf("%s: not configured\n", info.Name) + return nil + } + if err != nil { + return fmt.Errorf("load credential: %w", err) + } - if err := provider.Authenticate(cmd.Context(), storage); err != nil { - return fmt.Errorf("authentication failed: %w", err) + switch cred.Type { + case auth.CredentialTypeSubscription: + fmt.Printf("%s: subscription\n", info.Name) + case auth.CredentialTypeAPIKey: + fmt.Printf("%s: api key\n", info.Name) + default: + fmt.Printf("%s: configured (unknown type)\n", info.Name) } - fmt.Println() - fmt.Println("Authentication successful! Token stored in macOS Keychain.") return nil } -func runAuthGemini(cmd *cobra.Command, _ []string) error { - fmt.Println("Reading Gemini CLI credentials...") +// runAuthFlow runs the interactive authentication flow for a provider. +func runAuthFlow(provider auth.Provider) error { + storage, err := keychain.New() + if err != nil { + return fmt.Errorf("initialize credential storage: %w", err) + } + + prompter := auth.NewTerminalPrompter() + info := provider.Info() - provider := auth.NewGeminiProvider() - storage := keychain.New() + prompter.Print(fmt.Sprintf("Configure %s authentication", info.Name)) + prompter.Print("") - if err := provider.Authenticate(cmd.Context(), storage); err != nil { - return fmt.Errorf("failed to store credentials: %w", err) + choice, err := prompter.PromptChoice("Authentication method:", []string{ + "Subscription", + "API Key", + }) + if err != nil { + return fmt.Errorf("select auth method: %w", err) } - fmt.Println("Credentials stored in macOS Keychain.") + prompter.Print("") + + var cred auth.Credential + + switch choice { + case 0: // Subscription + cred, err = handleSubscriptionAuth(provider, prompter) + case 1: // API Key + cred, err = handleAPIKeyAuth(provider, prompter) + } + + if err != nil { + return err + } + + if err := provider.Store(storage, cred); err != nil { + return fmt.Errorf("store credential: %w", err) + } + + prompter.Print("") + prompter.Print("Credentials stored securely.") return nil } -func runAuthCodex(cmd *cobra.Command, _ []string) error { - fmt.Println("Starting Codex authentication flow...") - fmt.Println() +// handleSubscriptionAuth handles subscription-based authentication. +// For Claude, prompts for manual token entry. +// For Gemini/Codex, attempts to read existing credentials from config files. +func handleSubscriptionAuth(provider auth.Provider, prompter auth.Prompter) (auth.Credential, error) { + // Try to auto-detect existing credentials + value, err := provider.CheckSubscription() + if err == nil { + // Found existing credentials + prompter.Print("Found existing subscription credentials.") + if validateErr := provider.ValidateSubscription(value); validateErr != nil { + return auth.Credential{}, fmt.Errorf("invalid credentials: %w", validateErr) + } + return auth.Credential{ + Type: auth.CredentialTypeSubscription, + Value: value, + }, nil + } - provider := auth.NewCodexProvider() - storage := keychain.New() + // No existing credentials - show instructions and prompt for manual entry + prompter.Print(err.Error()) + prompter.Print("") - if err := provider.Authenticate(cmd.Context(), storage); err != nil { - return fmt.Errorf("authentication failed: %w", err) + value, err = prompter.PromptSecret("Paste your credential: ") + if err != nil { + return auth.Credential{}, fmt.Errorf("read credential: %w", err) } - fmt.Println() - fmt.Println("Authentication successful! Credentials stored in macOS Keychain.") - return nil + if err := provider.ValidateSubscription(value); err != nil { + return auth.Credential{}, fmt.Errorf("invalid credential: %w", err) + } + + return auth.Credential{ + Type: auth.CredentialTypeSubscription, + Value: value, + }, nil +} + +// handleAPIKeyAuth handles API key authentication. +func handleAPIKeyAuth(provider auth.Provider, prompter auth.Prompter) (auth.Credential, error) { + info := provider.Info() + + prompter.Print(fmt.Sprintf("Enter your %s API key.", info.Name)) + prompter.Print("") + + value, err := prompter.PromptSecret("API key: ") + if err != nil { + return auth.Credential{}, fmt.Errorf("read API key: %w", err) + } + + if err := provider.ValidateAPIKey(value); err != nil { + return auth.Credential{}, fmt.Errorf("invalid API key: %w", err) + } + + return auth.Credential{ + Type: auth.CredentialTypeAPIKey, + Value: value, + }, nil } diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 0f79a2d..3e17bd3 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -115,60 +115,73 @@ func buildSessionConfig(cmd *cobra.Command, flags *runFlags, args []string) (*in } } - // Inject authentication tokens from keychain - if err := injectAuthToken(agent, cfg); err != nil { + // Inject authentication credentials from keychain + if err := injectAuthCredential(agent, cfg); err != nil { return nil, err } return cfg, nil } -// injectAuthToken retrieves the auth token for the agent and adds it to the session config. -func injectAuthToken(agent string, cfg *instance.CreateSessionConfig) error { +// agentAuthSpec maps agent names to their providers. +type agentAuthSpec struct { + provider func() auth.Provider + notConfiguredMsg string +} + +var agentAuthSpecs = map[string]agentAuthSpec{ + "claude": { + provider: func() auth.Provider { return auth.NewClaudeProvider() }, + notConfiguredMsg: "claude auth not configured: run 'hjk auth claude' first", + }, + "gemini": { + provider: func() auth.Provider { return auth.NewGeminiProvider() }, + notConfiguredMsg: "gemini auth not configured: run 'hjk auth gemini' first", + }, + "codex": { + provider: func() auth.Provider { return auth.NewCodexProvider() }, + notConfiguredMsg: "codex auth not configured: run 'hjk auth codex' first", + }, +} + +// injectAuthCredential retrieves the credential for the agent and configures the session. +func injectAuthCredential(agent string, cfg *instance.CreateSessionConfig) error { spec, ok := agentAuthSpecs[agent] if !ok { return nil } - storage := keychain.New() - credential, err := spec.provider().Get(storage) + storage, err := keychain.New() + if err != nil { + return fmt.Errorf("initialize credential storage: %w", err) + } + + provider := spec.provider() + cred, err := provider.Load(storage) if err != nil { if errors.Is(err, keychain.ErrNotFound) { - return errors.New(spec.notConfigured) + return errors.New(spec.notConfiguredMsg) } - return fmt.Errorf("%s: %w", spec.errPrefix, err) + return fmt.Errorf("load %s credential: %w", agent, err) } - cfg.Env = append(cfg.Env, spec.envVar+"="+credential) - return nil -} - -type agentAuthSpec struct { - provider func() auth.Provider - envVar string - notConfigured string - errPrefix string -} + info := provider.Info() + + // Set environment variable based on credential type + switch cred.Type { + case auth.CredentialTypeSubscription: + cfg.Env = append(cfg.Env, info.SubscriptionEnvVar+"="+cred.Value) + cfg.CredentialType = string(auth.CredentialTypeSubscription) + cfg.RequiresAgentSetup = info.RequiresContainerSetup + case auth.CredentialTypeAPIKey: + cfg.Env = append(cfg.Env, info.APIKeyEnvVar+"="+cred.Value) + cfg.CredentialType = string(auth.CredentialTypeAPIKey) + cfg.RequiresAgentSetup = false // API keys don't need file setup + default: + return fmt.Errorf("unknown credential type: %s", cred.Type) + } -var agentAuthSpecs = map[string]agentAuthSpec{ - "claude": { - provider: func() auth.Provider { return auth.NewClaudeProvider() }, - envVar: "CLAUDE_CODE_OAUTH_TOKEN", - notConfigured: "claude auth not configured: run 'headjack auth claude' first", - errPrefix: "get claude token", - }, - "gemini": { - provider: func() auth.Provider { return auth.NewGeminiProvider() }, - envVar: "GEMINI_OAUTH_CREDS", - notConfigured: "gemini auth not configured: run 'headjack auth gemini' first", - errPrefix: "get gemini credentials", - }, - "codex": { - provider: func() auth.Provider { return auth.NewCodexProvider() }, - envVar: "CODEX_AUTH_JSON", - notConfigured: "codex auth not configured: run 'headjack auth codex' first", - errPrefix: "get codex credentials", - }, + return nil } func runRunCmd(cmd *cobra.Command, args []string) error { diff --git a/internal/instance/instance.go b/internal/instance/instance.go index 562e745..5c1e7bc 100644 --- a/internal/instance/instance.go +++ b/internal/instance/instance.go @@ -74,8 +74,10 @@ type Session struct { // CreateSessionConfig configures session creation. type CreateSessionConfig struct { - Type string // Session type (shell, claude, gemini, codex) - Name string // Optional session name (auto-generated if empty) - Command []string // Initial command to run (optional, defaults to shell) - Env []string // Additional environment variables + Type string // Session type (shell, claude, gemini, codex) + Name string // Optional session name (auto-generated if empty) + Command []string // Initial command to run (optional, defaults to shell) + Env []string // Additional environment variables + CredentialType string // Credential type: "subscription" or "apikey" (empty for shell) + RequiresAgentSetup bool // Whether agent needs file setup in container } diff --git a/internal/instance/manager.go b/internal/instance/manager.go index 9edb318..27f1500 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -810,7 +810,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, cfg.Env); setupErr != nil { + if setupErr := m.runAgentSetup(ctx, entry.ContainerID, sessionType, cfg.Env, cfg.RequiresAgentSetup); setupErr != nil { return nil, fmt.Errorf("agent setup: %w", setupErr) } @@ -874,12 +874,13 @@ 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. -// 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 { +// For Gemini/Codex with subscription auth, this writes OAuth credentials to file locations. +// API key auth skips file setup since credentials are passed via environment variables. +func (m *Manager) runAgentSetup(ctx context.Context, containerID string, sessionType catalog.SessionType, env []string, requiresSetup bool) 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. + // Always create ~/.claude.json with hasCompletedOnboarding to skip interactive setup. + // This is required for both OAuth token and API key authentication 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{ @@ -887,7 +888,11 @@ func (m *Manager) runAgentSetup(ctx context.Context, containerID string, session }) case catalog.SessionTypeGemini: - // Write Gemini config files from env vars. + // Skip file setup for API key auth - credentials are in GEMINI_API_KEY env var + if !requiresSetup { + return nil + } + // Write Gemini config files from env vars for subscription auth. // 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 && \ @@ -900,7 +905,11 @@ echo '{"security":{"auth":{"selectedType":"oauth-personal"}}}' > ~/.gemini/setti }) case catalog.SessionTypeCodex: - // Write Codex auth.json from env var. + // Skip file setup for API key auth - credentials are in OPENAI_API_KEY env var + if !requiresSetup { + return nil + } + // Write Codex auth.json from env var for subscription auth. // 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{ diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 44a18c2..6a3c3f9 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -1,4 +1,12 @@ -// Package keychain provides access to macOS Keychain for secure credential storage. +// Package keychain provides secure credential storage using platform-native keychains. +// +// On macOS, credentials are stored in the macOS Keychain. On Linux, the package +// attempts to use the Secret Service D-Bus API (GNOME Keyring, KWallet), falls back +// to the Linux kernel keyring (keyctl), and finally to an encrypted file. On Windows, +// credentials are stored in Windows Credential Manager. +// +// The backend can be overridden using the HEADJACK_KEYRING_BACKEND environment variable. +// For the encrypted file backend, the password can be provided via HEADJACK_KEYRING_PASSWORD. package keychain import "errors" @@ -6,10 +14,48 @@ import "errors" // ErrNotFound is returned when a credential is not found in the keychain. var ErrNotFound = errors.New("credential not found in keychain") -// ErrUnsupportedPlatform is returned when keychain operations are attempted on non-macOS platforms. -var ErrUnsupportedPlatform = errors.New("keychain is only supported on macOS") +// ErrNoPassword is returned when the file backend needs a password but none is available. +var ErrNoPassword = errors.New("keyring password required: set HEADJACK_KEYRING_PASSWORD or run in interactive terminal") + +// Backend represents a keyring backend type. +type Backend string + +// Supported keyring backends. +const ( + // BackendAuto automatically selects the best available backend for the platform. + BackendAuto Backend = "" + + // BackendKeychain uses macOS Keychain (darwin only). + BackendKeychain Backend = "keychain" + + // BackendSecretService uses the Secret Service D-Bus API (Linux with GNOME Keyring or KWallet). + BackendSecretService Backend = "secret-service" + + // BackendKeyctl uses the Linux kernel keyring (headless Linux). + BackendKeyctl Backend = "keyctl" + + // BackendWinCred uses Windows Credential Manager (Windows only). + BackendWinCred Backend = "wincred" + + // BackendFile uses an encrypted file (universal fallback). + BackendFile Backend = "file" +) + +// Config holds configuration for the keyring. +type Config struct { + // Backend specifies the backend to use. Empty string means auto-detect. + Backend Backend + + // FileDir is the directory for encrypted file backend. + // Defaults to ~/.config/headjack/ + FileDir string + + // PasswordFunc provides a password for the encrypted file backend. + // If nil, HEADJACK_KEYRING_PASSWORD env var is checked, then interactive prompt. + PasswordFunc func(string) (string, error) +} -// Keychain provides secure credential storage using macOS Keychain. +// Keychain provides secure credential storage. // //go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/keychain.go . Keychain type Keychain interface { diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go deleted file mode 100644 index 091c9d4..0000000 --- a/internal/keychain/keychain_darwin.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build darwin - -package keychain - -import ( - "errors" - - gokeychain "github.com/keybase/go-keychain" -) - -// serviceName is the service identifier used for all headjack credentials. -const serviceName = "com.headjack.cli" - -type keychain struct{} - -// New creates a new Keychain backed by macOS Keychain. -func New() Keychain { - return &keychain{} -} - -func (k *keychain) Set(account, secret string) error { - // First try to delete any existing entry to avoid duplicates. - // Ignore errors since the item may not exist. - _ = k.Delete(account) //nolint:errcheck // Intentionally ignoring - item may not exist - - item := gokeychain.NewItem() - item.SetSecClass(gokeychain.SecClassGenericPassword) - item.SetService(serviceName) - item.SetAccount(account) - item.SetLabel("Headjack - " + account) - item.SetData([]byte(secret)) - item.SetSynchronizable(gokeychain.SynchronizableNo) - item.SetAccessible(gokeychain.AccessibleWhenUnlocked) - - return gokeychain.AddItem(item) -} - -func (k *keychain) Get(account string) (string, error) { - query := gokeychain.NewItem() - query.SetSecClass(gokeychain.SecClassGenericPassword) - query.SetService(serviceName) - query.SetAccount(account) - query.SetMatchLimit(gokeychain.MatchLimitOne) - query.SetReturnData(true) - - results, err := gokeychain.QueryItem(query) - if errors.Is(err, gokeychain.ErrorItemNotFound) { - return "", ErrNotFound - } - if err != nil { - return "", err - } - if len(results) == 0 { - return "", ErrNotFound - } - - return string(results[0].Data), nil -} - -func (k *keychain) Delete(account string) error { - item := gokeychain.NewItem() - item.SetSecClass(gokeychain.SecClassGenericPassword) - item.SetService(serviceName) - item.SetAccount(account) - - err := gokeychain.DeleteItem(item) - if errors.Is(err, gokeychain.ErrorItemNotFound) { - return nil - } - return err -} diff --git a/internal/keychain/keychain_other.go b/internal/keychain/keychain_other.go deleted file mode 100644 index 8b461a6..0000000 --- a/internal/keychain/keychain_other.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build !darwin - -package keychain - -type keychain struct{} - -// New creates a new Keychain. On non-macOS platforms, all operations return ErrUnsupportedPlatform. -func New() Keychain { - return &keychain{} -} - -func (k *keychain) Set(_, _ string) error { - return ErrUnsupportedPlatform -} - -func (k *keychain) Get(_ string) (string, error) { - return "", ErrUnsupportedPlatform -} - -func (k *keychain) Delete(_ string) error { - return ErrUnsupportedPlatform -} diff --git a/internal/keychain/keyring.go b/internal/keychain/keyring.go new file mode 100644 index 0000000..5edd2c8 --- /dev/null +++ b/internal/keychain/keyring.go @@ -0,0 +1,191 @@ +package keychain + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/99designs/keyring" + "golang.org/x/term" +) + +const serviceName = "com.headjack.cli" + +// Environment variable names for keyring configuration. +const ( + EnvKeyringBackend = "HEADJACK_KEYRING_BACKEND" + EnvKeyringPassword = "HEADJACK_KEYRING_PASSWORD" +) + +type keyringStore struct { + ring keyring.Keyring +} + +// New creates a new Keychain with default configuration. +// Uses auto-detection to select the appropriate backend for the platform. +func New() (Keychain, error) { + return NewWithConfig(Config{}) +} + +// NewWithConfig creates a new Keychain with the specified configuration. +func NewWithConfig(cfg Config) (Keychain, error) { + backend := cfg.Backend + if backend == BackendAuto { + backend = detectBackend() + } + + // Check for environment variable override + if envBackend := os.Getenv(EnvKeyringBackend); envBackend != "" { + backend = Backend(envBackend) + } + + ring, err := openKeyring(backend, cfg) + if err != nil { + return nil, fmt.Errorf("open keyring (%s): %w", backend, err) + } + + return &keyringStore{ring: ring}, nil +} + +// detectBackend returns the best available backend for the current platform. +func detectBackend() Backend { + switch runtime.GOOS { + case "darwin": + return BackendKeychain + case "windows": + return BackendWinCred + case "linux": + // Try secret-service first (works with GNOME Keyring, KWallet via D-Bus) + if isSecretServiceAvailable() { + return BackendSecretService + } + // Fall back to keyctl (Linux kernel keyring, works headless) + if isKeyctlAvailable() { + return BackendKeyctl + } + // Last resort: encrypted file + return BackendFile + default: + return BackendFile + } +} + +// isSecretServiceAvailable checks if the Secret Service D-Bus API is available. +func isSecretServiceAvailable() bool { + // Check if D-Bus session is available by looking for the socket + if dbusAddr := os.Getenv("DBUS_SESSION_BUS_ADDRESS"); dbusAddr != "" { + return true + } + // Also check for the default socket path + if xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR"); xdgRuntimeDir != "" { + socketPath := filepath.Join(xdgRuntimeDir, "bus") + if _, err := os.Stat(socketPath); err == nil { + return true + } + } + return false +} + +// isKeyctlAvailable checks if the Linux kernel keyring is available. +func isKeyctlAvailable() bool { + // keyctl is available on all modern Linux kernels + return runtime.GOOS == "linux" +} + +// openKeyring opens a keyring with the specified backend and configuration. +func openKeyring(backend Backend, cfg Config) (keyring.Keyring, error) { + fileDir := cfg.FileDir + if fileDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("get home directory: %w", err) + } + fileDir = filepath.Join(home, ".config", "headjack") + } + + passwordFunc := cfg.PasswordFunc + if passwordFunc == nil { + passwordFunc = defaultPasswordFunc + } + + config := keyring.Config{ + ServiceName: serviceName, + + // macOS Keychain options + KeychainName: "login", + KeychainTrustApplication: true, + KeychainSynchronizable: false, + KeychainAccessibleWhenUnlocked: true, + + // Encrypted file backend options + FileDir: fileDir, + FilePasswordFunc: passwordFunc, + + // Restrict to specified backend + AllowedBackends: []keyring.BackendType{keyring.BackendType(backend)}, + } + + return keyring.Open(config) +} + +// defaultPasswordFunc provides a password for the encrypted file backend. +// It first checks the environment variable, then falls back to interactive prompt. +func defaultPasswordFunc(prompt string) (string, error) { + // Check environment variable first (for CI/headless use) + if pw := os.Getenv(EnvKeyringPassword); pw != "" { + return pw, nil + } + + // Try interactive prompt if running in a terminal + if term.IsTerminal(int(os.Stdin.Fd())) { + return terminalPrompt(prompt) + } + + return "", ErrNoPassword +} + +// terminalPrompt reads a password from the terminal without echoing. +func terminalPrompt(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) // Print newline after password entry + if err != nil { + return "", fmt.Errorf("read password: %w", err) + } + return string(password), nil +} + +func (k *keyringStore) Set(account, secret string) error { + return k.ring.Set(keyring.Item{ + Key: account, + Data: []byte(secret), + Label: "Headjack - " + account, + Description: "Headjack CLI credential", + }) +} + +func (k *keyringStore) Get(account string) (string, error) { + item, err := k.ring.Get(account) + if errors.Is(err, keyring.ErrKeyNotFound) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + return string(item.Data), nil +} + +func (k *keyringStore) Delete(account string) error { + err := k.ring.Remove(account) + if err == nil { + return nil + } + // Handle both keyring.ErrKeyNotFound and filesystem "no such file" errors + // for idempotent delete behavior + if errors.Is(err, keyring.ErrKeyNotFound) || os.IsNotExist(err) { + return nil + } + return err +} diff --git a/internal/keychain/keyring_test.go b/internal/keychain/keyring_test.go new file mode 100644 index 0000000..0fd6bb0 --- /dev/null +++ b/internal/keychain/keyring_test.go @@ -0,0 +1,236 @@ +package keychain + +import ( + "os" + "runtime" + "testing" +) + +const testPassword = "test-password" + +func testPasswordFunc(string) (string, error) { + return testPassword, nil +} + +func TestNew_DefaultBackend(t *testing.T) { + // Use file backend for testing to avoid needing actual system keyrings + t.Setenv(EnvKeyringBackend, string(BackendFile)) + t.Setenv(EnvKeyringPassword, testPassword) + + store, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if store == nil { + t.Fatal("New() returned nil store") + } +} + +func TestNewWithConfig_FileBackend(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewWithConfig(Config{ + Backend: BackendFile, + FileDir: tmpDir, + PasswordFunc: testPasswordFunc, + }) + if err != nil { + t.Fatalf("NewWithConfig() failed: %v", err) + } + if store == nil { + t.Fatal("NewWithConfig() returned nil store") + } +} + +func TestKeyringStore_SetGet(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewWithConfig(Config{ + Backend: BackendFile, + FileDir: tmpDir, + PasswordFunc: testPasswordFunc, + }) + if err != nil { + t.Fatalf("NewWithConfig() failed: %v", err) + } + + // Set a credential + err = store.Set("test-account", "test-secret") + if err != nil { + t.Fatalf("Set() failed: %v", err) + } + + // Get the credential + secret, err := store.Get("test-account") + if err != nil { + t.Fatalf("Get() failed: %v", err) + } + if secret != "test-secret" { + t.Errorf("Get() = %q, want %q", secret, "test-secret") + } +} + +func TestKeyringStore_GetNotFound(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewWithConfig(Config{ + Backend: BackendFile, + FileDir: tmpDir, + PasswordFunc: testPasswordFunc, + }) + if err != nil { + t.Fatalf("NewWithConfig() failed: %v", err) + } + + _, err = store.Get("nonexistent") + if err != ErrNotFound { + t.Errorf("Get() error = %v, want %v", err, ErrNotFound) + } +} + +func TestKeyringStore_Delete(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewWithConfig(Config{ + Backend: BackendFile, + FileDir: tmpDir, + PasswordFunc: testPasswordFunc, + }) + if err != nil { + t.Fatalf("NewWithConfig() failed: %v", err) + } + + // Set then delete + err = store.Set("delete-test", "secret") + if err != nil { + t.Fatalf("Set() failed: %v", err) + } + + err = store.Delete("delete-test") + if err != nil { + t.Fatalf("Delete() failed: %v", err) + } + + // Verify deleted + _, err = store.Get("delete-test") + if err != ErrNotFound { + t.Errorf("Get() after Delete() error = %v, want %v", err, ErrNotFound) + } +} + +func TestKeyringStore_DeleteIdempotent(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewWithConfig(Config{ + Backend: BackendFile, + FileDir: tmpDir, + PasswordFunc: testPasswordFunc, + }) + if err != nil { + t.Fatalf("NewWithConfig() failed: %v", err) + } + + // Delete nonexistent should succeed (idempotent) + err = store.Delete("nonexistent") + if err != nil { + t.Errorf("Delete() of nonexistent key failed: %v", err) + } +} + +func TestKeyringStore_Overwrite(t *testing.T) { + tmpDir := t.TempDir() + + store, err := NewWithConfig(Config{ + Backend: BackendFile, + FileDir: tmpDir, + PasswordFunc: testPasswordFunc, + }) + if err != nil { + t.Fatalf("NewWithConfig() failed: %v", err) + } + + // Set initial value + err = store.Set("overwrite-test", "initial") + if err != nil { + t.Fatalf("Set() initial failed: %v", err) + } + + // Overwrite with new value + err = store.Set("overwrite-test", "updated") + if err != nil { + t.Fatalf("Set() overwrite failed: %v", err) + } + + // Verify updated value + secret, err := store.Get("overwrite-test") + if err != nil { + t.Fatalf("Get() failed: %v", err) + } + if secret != "updated" { + t.Errorf("Get() = %q, want %q", secret, "updated") + } +} + +func TestDetectBackend(t *testing.T) { + backend := detectBackend() + + switch runtime.GOOS { + case "darwin": + if backend != BackendKeychain { + t.Errorf("detectBackend() on darwin = %v, want %v", backend, BackendKeychain) + } + case "windows": + if backend != BackendWinCred { + t.Errorf("detectBackend() on windows = %v, want %v", backend, BackendWinCred) + } + default: + // Linux and other platforms + validBackends := map[Backend]bool{ + BackendSecretService: true, + BackendKeyctl: true, + BackendFile: true, + } + if !validBackends[backend] { + t.Errorf("detectBackend() on %s = %v, want one of secret-service, keyctl, or file", runtime.GOOS, backend) + } + } +} + +func TestEnvBackendOverride(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv(EnvKeyringBackend, string(BackendFile)) + t.Setenv(EnvKeyringPassword, testPassword) + + store, err := NewWithConfig(Config{ + FileDir: tmpDir, + }) + if err != nil { + t.Fatalf("NewWithConfig() with env override failed: %v", err) + } + if store == nil { + t.Fatal("NewWithConfig() returned nil store") + } +} + +func TestDefaultPasswordFunc_EnvVar(t *testing.T) { + t.Setenv(EnvKeyringPassword, "env-password") + + password, err := defaultPasswordFunc("Enter password: ") + if err != nil { + t.Fatalf("defaultPasswordFunc() failed: %v", err) + } + if password != "env-password" { + t.Errorf("defaultPasswordFunc() = %q, want %q", password, "env-password") + } +} + +func TestDefaultPasswordFunc_NoPassword(t *testing.T) { + // Ensure env var is not set + os.Unsetenv(EnvKeyringPassword) + + // In non-terminal context (like tests), should return ErrNoPassword + _, err := defaultPasswordFunc("Enter password: ") + if err != ErrNoPassword { + t.Errorf("defaultPasswordFunc() error = %v, want %v", err, ErrNoPassword) + } +}