Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
go get github.com/activadee/godex
```

`godex` automatically downloads the Codex CLI into your user cache the first time it is needed. The cached build is keyed by platform and release tag so upgrades are seamless. Advanced users can override the cache directory or release tag via the `GODEX_CLI_CACHE` and `GODEX_CLI_RELEASE_TAG` environment variables. If you prefer to use a self-managed binary, set `CodexOptions.CodexPathOverride` or ensure the CLI is already available on your `PATH` (e.g. `which codex`). Authentication is handled entirely by the CLI; reuse whichever credentials you already configured (environment variables, `codex auth login`, etc.) or set `CodexOptions.APIKey` to override the API key programmatically:
`godex` automatically downloads the Codex CLI into your user cache the first time it is needed. The cached build is keyed by platform and release tag so upgrades are seamless. Advanced users can override the cache directory or release tag via `CodexOptions.CLICacheDir` / `CodexOptions.CLIReleaseTag` (or the `GODEX_CLI_CACHE` / `GODEX_CLI_RELEASE_TAG` environment variables) and enforce SHA-256 verification with `CodexOptions.CLIChecksum` or `GODEX_CLI_CHECKSUM`. If you prefer to use a self-managed binary, set `CodexOptions.CodexPathOverride` or ensure the CLI is already available on your `PATH` (e.g. `which codex`). Authentication is handled entirely by the CLI; reuse whichever credentials you already configured (environment variables, `codex auth login`, etc.) or set `CodexOptions.APIKey` to override the API key programmatically:

```bash
export CODEX_API_KEY=sk-...
Expand All @@ -18,6 +18,40 @@ codex auth login

Override service endpoints (for self-hosted deployments) with `CodexOptions.BaseURL`.

### CLI bootstrap controls

`CodexOptions` exposes the same knobs that previously required environment variables when
you need deterministic CLI bootstrapping:

- `CLICacheDir` overrides where downloaded binaries are stored. It takes precedence over
`GODEX_CLI_CACHE` and falls back to the user cache or `os.TempDir()`.
- `CLIReleaseTag` pins the release asset fetched from `github.com/openai/codex`. It overrides
`GODEX_CLI_RELEASE_TAG` and defaults to the SDK's bundled tag.
- `CLIChecksum` enforces integrity by verifying the SHA-256 checksum of the extracted binary.
Supply the expected digest (hex encoded) from the official release notes or your
distribution channel. The environment variable equivalent is `GODEX_CLI_CHECKSUM`.

```go
import (
"os"
"path/filepath"

"github.com/activadee/godex"
)

client, err := godex.New(godex.CodexOptions{
CLICacheDir: filepath.Join(os.TempDir(), "codex-cache"),
CLIReleaseTag: "rust-v0.55.0",
CLIChecksum: "f8a1...",
})
```

When a checksum is configured, `godex` verifies both cached binaries and freshly downloaded
ones, forcing a re-download or returning an error if the digest does not match. This allows
you to gate Codex upgrades on an allowlisted fingerprint without writing custom bootstrap
code. The checksum is calculated over the extracted `codex` executable for the detected
platform/architecture.

## Quick start

```go
Expand Down
7 changes: 6 additions & 1 deletion codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ type Codex struct {
// New constructs a Codex SDK instance. The Codex binary is discovered automatically unless
// CodexOptions.CodexPathOverride is provided.
func New(options CodexOptions) (*Codex, error) {
exec, err := codexexec.New(options.CodexPathOverride)
exec, err := codexexec.New(codexexec.RunnerOptions{
PathOverride: options.CodexPathOverride,
CacheDir: options.CLICacheDir,
ReleaseTag: options.CLIReleaseTag,
ChecksumHex: options.CLIChecksum,
})
if err != nil {
return nil, err
}
Expand Down
140 changes: 119 additions & 21 deletions internal/codexexec/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import (
"archive/zip"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)

Expand All @@ -24,6 +27,81 @@ const (

const defaultCodexReleaseTag = "rust-v0.55.0"

var ErrChecksumMismatch = errors.New("codex bundle checksum mismatch")

type bundleConfig struct {
cacheDir string
releaseTag string
checksumHex string
}

func (cfg bundleConfig) cacheDirPath() (string, error) {
if dir := strings.TrimSpace(cfg.cacheDir); dir != "" {
return dir, nil
}
if override := strings.TrimSpace(os.Getenv("GODEX_CLI_CACHE")); override != "" {
return override, nil
}
if dir, err := os.UserCacheDir(); err == nil && dir != "" {
return filepath.Join(dir, "godex", "codex"), nil
}
return filepath.Join(os.TempDir(), "godex", "codex"), nil
}

func (cfg bundleConfig) releaseTagName() string {
if tag := strings.TrimSpace(cfg.releaseTag); tag != "" {
return tag
}
if env := strings.TrimSpace(os.Getenv("GODEX_CLI_RELEASE_TAG")); env != "" {
return env
}
return defaultCodexReleaseTag
}

func (cfg bundleConfig) checksumValue() (string, error) {
value := strings.TrimSpace(cfg.checksumHex)
if value == "" {
value = strings.TrimSpace(os.Getenv("GODEX_CLI_CHECKSUM"))
}
if value == "" {
return "", nil
}
normalized, err := normalizeChecksum(value)
if err != nil {
return "", err
}
return normalized, nil
}

func normalizeChecksum(value string) (string, error) {
cleaned := strings.ToLower(strings.TrimSpace(value))
if cleaned == "" {
return "", nil
}
if _, err := hex.DecodeString(cleaned); err != nil {
return "", fmt.Errorf("invalid checksum value %q: %w", value, err)
}
return cleaned, nil
}

func (cfg bundleConfig) releasePinned() bool {
if strings.TrimSpace(cfg.releaseTag) != "" {
return true
}
return strings.TrimSpace(os.Getenv("GODEX_CLI_RELEASE_TAG")) != ""
}

func (cfg bundleConfig) checksumRequired() bool {
if strings.TrimSpace(cfg.checksumHex) != "" {
return true
}
return strings.TrimSpace(os.Getenv("GODEX_CLI_CHECKSUM")) != ""
}

func (cfg bundleConfig) requireBundledBinary() bool {
return cfg.releasePinned() || cfg.checksumRequired()
}

var downloadBinaryFunc = downloadBinaryFromRelease
var runtimeGOOS = runtime.GOOS
var runtimeGOARCH = runtime.GOARCH
Expand All @@ -36,13 +114,6 @@ type targetInfo struct {
exeName string
}

func releaseTag() string {
if v := os.Getenv("GODEX_CLI_RELEASE_TAG"); v != "" {
return v
}
return defaultCodexReleaseTag
}

func detectTarget(goos, goarch string) (targetInfo, bool) {
switch goos {
case "linux":
Expand Down Expand Up @@ -106,33 +177,52 @@ func detectTarget(goos, goarch string) (targetInfo, bool) {
return targetInfo{}, false
}

func ensureBundledBinary() (string, error) {
func ensureBundledBinary(cfg bundleConfig) (string, error) {
info, ok := detectTarget(runtimeGOOS, runtimeGOARCH)
if !ok {
return "", fmt.Errorf("unsupported platform: %s/%s", runtimeGOOS, runtimeGOARCH)
}

cacheDir, err := bundleCacheDir()
cacheDir, err := cfg.cacheDirPath()
if err != nil {
return "", err
}

release := releaseTag()
release := cfg.releaseTagName()
checksumHex, err := cfg.checksumValue()
if err != nil {
return "", fmt.Errorf("resolve checksum: %w", err)
}
targetDir := filepath.Join(cacheDir, release, info.triple)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return "", fmt.Errorf("create bundle directory: %w", err)
}

destPath := filepath.Join(targetDir, info.exeName)
if statErr := ensureBinaryState(destPath); statErr == nil {
return destPath, nil
if checksumHex == "" {
return destPath, nil
}
if err := verifyChecksum(destPath, checksumHex); err == nil {
return destPath, nil
} else if errors.Is(err, ErrChecksumMismatch) {
_ = os.Remove(destPath)
} else {
return "", fmt.Errorf("verify cached binary: %w", err)
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return "", fmt.Errorf("stat bundled binary: %w", statErr)
}

if err := downloadBinaryFunc(info, release, destPath); err != nil {
return "", err
}
if checksumHex != "" {
if err := verifyChecksum(destPath, checksumHex); err != nil {
_ = os.Remove(destPath)
return "", fmt.Errorf("verify downloaded binary: %w", err)
}
}
return destPath, nil
}

Expand All @@ -141,16 +231,6 @@ func ensureBinaryState(path string) error {
return err
}

func bundleCacheDir() (string, error) {
if override := os.Getenv("GODEX_CLI_CACHE"); override != "" {
return override, nil
}
if dir, err := os.UserCacheDir(); err == nil && dir != "" {
return filepath.Join(dir, "godex", "codex"), nil
}
return filepath.Join(os.TempDir(), "godex", "codex"), nil
}

func downloadBinaryFromRelease(info targetInfo, release, destPath string) error {
url := fmt.Sprintf("https://github.com/openai/codex/releases/download/%s/%s", release, info.assetName)

Expand Down Expand Up @@ -226,6 +306,24 @@ func extractZipBinary(data []byte, info targetInfo, destPath string) error {
return fmt.Errorf("binary %s not found in archive", info.binaryName)
}

func verifyChecksum(path, expectedHex string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open binary for checksum: %w", err)
}
defer file.Close()

hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("hash binary: %w", err)
}
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expectedHex {
return fmt.Errorf("%w: expected %s, got %s", ErrChecksumMismatch, expectedHex, actual)
}
return nil
}

func writeBinary(r io.Reader, destPath string) error {
tmpFile, err := os.CreateTemp(filepath.Dir(destPath), filepath.Base(destPath)+".tmp-*")
if err != nil {
Expand Down
Loading