Skip to main content

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.

The Authentication Challenge

Each CLI agent has its own authentication mechanism:

AgentAuth TypeCredential
Claude CodeOAuth 2.0OIDC token (sk-ant-*)
Gemini CLIGoogle OAuthOAuth credentials + account info
CodexOpenAI OAuthAuth JSON file

These credentials are typically stored in config files in the user's home directory:

~/.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)

The Keychain Solution

Headjack stores agent credentials in macOS Keychain, the system's secure credential store:

+-----------------------------------------------------------+
| macOS Keychain |
| +-----------------------------------------------------+ |
| | Service: com.headjack | |
| +-----------------------------------------------------+ |
| | claude-oidc-token | sk-ant-oat01-xxxx... | |
| | gemini-oauth-creds | {"oauth_creds":...} | |
| | codex-oauth-creds | {"api_key":...} | |
| +-----------------------------------------------------+ |
+-----------------------------------------------------------+

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
  • No plaintext files: Credentials never written to disk in readable form

Authentication Flow

The authentication flow has two phases: capture and injection.

Phase 1: Credential Capture

Before using an agent, you must authenticate:

hjk auth claude   # Capture Claude credentials
hjk auth gemini # Capture Gemini credentials
hjk auth codex # Capture Codex credentials

Each agent has a unique capture process:

Claude Code

Claude uses claude setup-token which runs an interactive OAuth flow:

// 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)

You authenticate in your browser, then paste the token into the terminal.

Gemini CLI

Gemini credentials are captured from existing config files:

// 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))

You must have already run gemini on your host and completed OAuth.

Codex

Codex uses codex login interactively:

// 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))

Phase 2: Credential Injection

When a session starts, Headjack injects credentials into the container:

                Session Start
|
v
+-------------------------+
| Read from Keychain |
+-------------------------+
|
v
+-------------------------+
| Pass as env variable |
+-------------------------+
|
v
+-------------------------+
| Container setup writes |
| to expected locations |
+-------------------------+
|
v
+-------------------------+
| Agent CLI reads config |
| and authenticates |
+-------------------------+

The implementation differs by agent:

Claude Code

Claude is passed the token via environment variable:

// Environment variable set at session start
env = append(env, "CLAUDE_CODE_OAUTH_TOKEN=" + token)

Claude Code reads this variable directly.

Gemini CLI

Gemini needs config files, so Headjack writes them at instance start:

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.

Codex

Codex similarly needs a config file:

setupCmd := `mkdir -p ~/.codex && echo "$CODEX_AUTH_JSON" > ~/.codex/auth.json`

Security Properties

This design provides several security properties:

Credentials Never in Images

Container images never contain credentials. They're injected at runtime. This means:

  • Images can be shared without credential exposure
  • Pushing images to registries is safe
  • Container filesystem snapshots don't contain credentials

Minimal Exposure Window

Credentials are written to the container filesystem only when a session starts. They exist in memory during the session but aren't persisted in the container image.

Keychain Protection

macOS Keychain provides:

  • Encryption with user-specific keys
  • Access control lists (ACL)
  • Integration with Touch ID / system authentication
  • Automatic locking when system sleeps

No Cross-Instance Leakage

Each session gets its own credential injection. Sessions in different instances don't share credential storage (inside the container).

Limitations

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.

Single Account per Agent

Currently, Headjack stores one set of credentials per agent type. Multi-account support (using different credentials for different instances) is not yet implemented.

Container Filesystem Persistence

Once credentials are written inside a container, they persist until the container is recreated. A hjk recreate is needed to rotate credentials if they change on the host.

OAuth Token Expiry

OAuth tokens expire. When they do, you must re-run hjk auth to capture fresh tokens. The agents themselves may handle refresh automatically, but if the initial token is too old, authentication fails.

Troubleshooting Auth Issues

"Token not found"

The credential hasn't been captured:

hjk auth claude  # Capture Claude credentials

"Authentication failed" inside container

The token may have expired:

hjk auth claude  # Re-capture fresh token
hjk recreate <instance> # Recreate container with new credentials

Claude onboarding prompt

Claude Code shows onboarding prompts if it doesn't find expected config:

// Headjack creates this to skip onboarding
setupCmd := `mkdir -p ~/.claude && echo '{"hasCompletedOnboarding":true}' > ~/.claude.json`

If you see onboarding prompts, the setup command may have failed. Check container logs.

Why Not SSH Agent Forwarding?

SSH agent forwarding is a common solution for credential access in containers. Headjack doesn't use it because:

  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

The environment variable + file-writing approach works reliably across the VM boundary that Apple Containerization creates.