feat: add Codex CLI provider for subprocess integration (#80)
* feat: add Codex CLI provider for OpenAI subprocess integration Add CodexCliProvider that wraps `codex exec --json` as a subprocess, analogous to the existing ClaudeCliProvider pattern. This enables using OpenAI's Codex CLI tool as a local LLM backend. - CodexCliProvider: subprocess wrapper parsing JSONL event stream - Credential reader for ~/.codex/auth.json with token expiry detection - Factory integration: provider "codex-cli" and auth_method "codex-cli" - Fix tilde expansion in workspace path for CLI providers - 37 unit tests covering parsing, prompt building, credentials, and mocks * fix: add tool call extraction to Codex CLI provider - Extract shared tool call parsing into tool_call_extract.go (extractToolCallsFromText, stripToolCallsFromText, findMatchingBrace) - Both ClaudeCliProvider and CodexCliProvider now share the same tool call extraction logic for PicoClaw-specific tools - Fix cache token accounting: include cached_input_tokens in total - Add 2 new tests for tool call extraction from JSONL events - Update existing tests for corrected token calculations * fix(docker): update Go version to match go.mod requirement Dockerfile used golang:1.24-alpine but go.mod requires go >= 1.25.7. This caused Docker builds to fail on all branches with: "go: go.mod requires go >= 1.25.7 (running go 1.24.13)" Update to golang:1.25-alpine to match the project requirement. * fix: handle codex CLI stderr noise without losing valid stdout Codex writes diagnostic messages to stderr (e.g. rollout errors) which cause non-zero exit codes even when valid JSONL output exists on stdout. Parse stdout first before checking exit code to avoid false errors. * style: fix gofmt formatting and update web search API in tests - Remove trailing whitespace in web.go and base_test.go - Update config_test.go and web_test.go for WebSearchToolOptions API
This commit is contained in:
181
pkg/providers/codex_cli_credentials_test.go
Normal file
181
pkg/providers/codex_cli_credentials_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadCodexCliCredentials_Valid(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{
|
||||
"tokens": {
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"account_id": "org-test123"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
token, accountID, expiresAt, err := ReadCodexCliCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadCodexCliCredentials() error: %v", err)
|
||||
}
|
||||
if token != "test-access-token" {
|
||||
t.Errorf("token = %q, want %q", token, "test-access-token")
|
||||
}
|
||||
if accountID != "org-test123" {
|
||||
t.Errorf("accountID = %q, want %q", accountID, "org-test123")
|
||||
}
|
||||
// Expiry should be within ~1 hour from now (file was just written)
|
||||
if expiresAt.Before(time.Now()) {
|
||||
t.Errorf("expiresAt = %v, should be in the future", expiresAt)
|
||||
}
|
||||
if expiresAt.After(time.Now().Add(2 * time.Hour)) {
|
||||
t.Errorf("expiresAt = %v, should be within ~1 hour", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_MissingFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
_, _, _, err := ReadCodexCliCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing auth.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
_, _, _, err := ReadCodexCliCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty access_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
_, _, _, err := ReadCodexCliCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
token, accountID, _, err := ReadCodexCliCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "tok123" {
|
||||
t.Errorf("token = %q, want %q", token, "tok123")
|
||||
}
|
||||
if accountID != "" {
|
||||
t.Errorf("accountID = %q, want empty", accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
customDir := filepath.Join(tmpDir, "custom-codex")
|
||||
if err := os.MkdirAll(customDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", customDir)
|
||||
|
||||
token, _, _, err := ReadCodexCliCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "custom-token" {
|
||||
t.Errorf("token = %q, want %q", token, "custom-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
source := CreateCodexCliTokenSource()
|
||||
token, accountID, err := source()
|
||||
if err != nil {
|
||||
t.Fatalf("token source error: %v", err)
|
||||
}
|
||||
if token != "fresh-token" {
|
||||
t.Errorf("token = %q, want %q", token, "fresh-token")
|
||||
}
|
||||
if accountID != "acc" {
|
||||
t.Errorf("accountID = %q, want %q", accountID, "acc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set file modification time to 2 hours ago
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(authPath, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
source := CreateCodexCliTokenSource()
|
||||
_, _, err := source()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired credentials")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user