From 43febbadc5c6a32b749a5cdf8ff1d780a2f87d23 Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 9 Nov 2025 21:09:28 +0100 Subject: [PATCH 1/2] feat: enhance Codex options for CLI bootstrap and checksum verification --- README.md | 36 ++++++- codex.go | 7 +- internal/codexexec/bundle.go | 122 +++++++++++++++++++---- internal/codexexec/bundle_test.go | 158 ++++++++++++++++++++++++++++-- internal/codexexec/runner.go | 27 ++++- options.go | 10 ++ thread_cancel_test.go | 2 +- 7 files changed, 327 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index c247ea6..0ec9d93 100644 --- a/README.md +++ b/README.md @@ -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-... @@ -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 diff --git a/codex.go b/codex.go index f19789d..6eda394 100644 --- a/codex.go +++ b/codex.go @@ -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 } diff --git a/internal/codexexec/bundle.go b/internal/codexexec/bundle.go index c09c34f..3f3763e 100644 --- a/internal/codexexec/bundle.go +++ b/internal/codexexec/bundle.go @@ -5,6 +5,8 @@ import ( "archive/zip" "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -12,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "time" ) @@ -24,6 +27,63 @@ 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 +} + var downloadBinaryFunc = downloadBinaryFromRelease var runtimeGOOS = runtime.GOOS var runtimeGOARCH = runtime.GOARCH @@ -36,13 +96,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": @@ -106,18 +159,22 @@ 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) @@ -125,7 +182,16 @@ func ensureBundledBinary() (string, error) { 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) } @@ -133,6 +199,12 @@ func ensureBundledBinary() (string, error) { 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 } @@ -141,16 +213,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) @@ -226,6 +288,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 { diff --git a/internal/codexexec/bundle_test.go b/internal/codexexec/bundle_test.go index 37a469d..943a992 100644 --- a/internal/codexexec/bundle_test.go +++ b/internal/codexexec/bundle_test.go @@ -1,6 +1,9 @@ package codexexec import ( + "crypto/sha256" + "encoding/hex" + "errors" "fmt" "os" "path/filepath" @@ -41,7 +44,7 @@ func TestDetectTargetSupportsKnownCombinations(t *testing.T) { func TestEnsureBundledBinaryDownloadsWhenMissing(t *testing.T) { tmp := t.TempDir() - t.Setenv("GODEX_CLI_CACHE", tmp) + cfg := bundleConfig{cacheDir: tmp} originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH runtimeGOOS, runtimeGOARCH = "linux", "amd64" @@ -60,7 +63,7 @@ func TestEnsureBundledBinaryDownloadsWhenMissing(t *testing.T) { } t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) - path, err := ensureBundledBinary() + path, err := ensureBundledBinary(cfg) if err != nil { t.Fatalf("ensureBundledBinary returned error: %v", err) } @@ -74,7 +77,7 @@ func TestEnsureBundledBinaryDownloadsWhenMissing(t *testing.T) { func TestEnsureBundledBinarySkipsDownloadWhenPresent(t *testing.T) { tmp := t.TempDir() - t.Setenv("GODEX_CLI_CACHE", tmp) + cfg := bundleConfig{cacheDir: tmp} originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH runtimeGOOS, runtimeGOARCH = "linux", "amd64" @@ -83,7 +86,7 @@ func TestEnsureBundledBinarySkipsDownloadWhenPresent(t *testing.T) { }) info, _ := detectTarget(runtimeGOOS, runtimeGOARCH) - release := releaseTag() + release := cfg.releaseTagName() targetDir := filepath.Join(tmp, release, info.triple) if err := os.MkdirAll(targetDir, 0o755); err != nil { t.Fatalf("mkdir: %v", err) @@ -100,7 +103,7 @@ func TestEnsureBundledBinarySkipsDownloadWhenPresent(t *testing.T) { } t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) - path, err := ensureBundledBinary() + path, err := ensureBundledBinary(cfg) if err != nil { t.Fatalf("ensureBundledBinary returned error: %v", err) } @@ -109,6 +112,144 @@ func TestEnsureBundledBinarySkipsDownloadWhenPresent(t *testing.T) { } } +func TestBundleCacheDirPrefersOptionOverEnv(t *testing.T) { + envDir := filepath.Join(t.TempDir(), "env-cache") + t.Setenv("GODEX_CLI_CACHE", envDir) + explicit := filepath.Join(t.TempDir(), "explicit-cache") + cfg := bundleConfig{cacheDir: explicit} + + got, err := cfg.cacheDirPath() + if err != nil { + t.Fatalf("cacheDirPath returned error: %v", err) + } + if got != explicit { + t.Fatalf("cacheDirPath=%s, want %s", got, explicit) + } +} + +func TestEnsureBundledBinaryUsesProvidedReleaseTag(t *testing.T) { + tmp := t.TempDir() + cfg := bundleConfig{cacheDir: tmp, releaseTag: "custom-release"} + t.Setenv("GODEX_CLI_RELEASE_TAG", "env-release") + + originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH + runtimeGOOS, runtimeGOARCH = "linux", "amd64" + t.Cleanup(func() { + runtimeGOOS, runtimeGOARCH = originalGOOS, originalGOARCH + }) + + var releaseUsed string + originalDownloader := downloadBinaryFunc + downloadBinaryFunc = func(info targetInfo, release, destPath string) error { + releaseUsed = release + return os.WriteFile(destPath, []byte("binary"), 0o700) + } + t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) + + if _, err := ensureBundledBinary(cfg); err != nil { + t.Fatalf("ensureBundledBinary returned error: %v", err) + } + if releaseUsed != "custom-release" { + t.Fatalf("expected release custom-release, got %s", releaseUsed) + } +} + +func TestEnsureBundledBinaryVerifiesChecksums(t *testing.T) { + tmp := t.TempDir() + cfg := bundleConfig{ + cacheDir: tmp, + checksumHex: sha256Hex([]byte("binary")), + } + + originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH + runtimeGOOS, runtimeGOARCH = "linux", "amd64" + t.Cleanup(func() { + runtimeGOOS, runtimeGOARCH = originalGOOS, originalGOARCH + }) + + originalDownloader := downloadBinaryFunc + downloadBinaryFunc = func(info targetInfo, release, destPath string) error { + return os.WriteFile(destPath, []byte("binary"), 0o700) + } + t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) + + if _, err := ensureBundledBinary(cfg); err != nil { + t.Fatalf("ensureBundledBinary returned error: %v", err) + } +} + +func TestEnsureBundledBinaryFailsOnChecksumMismatch(t *testing.T) { + tmp := t.TempDir() + cfg := bundleConfig{ + cacheDir: tmp, + checksumHex: strings.Repeat("00", 32), + } + + originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH + runtimeGOOS, runtimeGOARCH = "linux", "amd64" + t.Cleanup(func() { + runtimeGOOS, runtimeGOARCH = originalGOOS, originalGOARCH + }) + + originalDownloader := downloadBinaryFunc + downloadBinaryFunc = func(info targetInfo, release, destPath string) error { + return os.WriteFile(destPath, []byte("binary"), 0o700) + } + t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) + + if _, err := ensureBundledBinary(cfg); err == nil || !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("expected checksum mismatch error, got %v", err) + } +} + +func TestEnsureBundledBinaryRedownloadsWhenCachedChecksumMismatch(t *testing.T) { + tmp := t.TempDir() + cfg := bundleConfig{ + cacheDir: tmp, + checksumHex: sha256Hex([]byte("new")), + } + + originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH + runtimeGOOS, runtimeGOARCH = "linux", "amd64" + t.Cleanup(func() { + runtimeGOOS, runtimeGOARCH = originalGOOS, originalGOARCH + }) + + info, _ := detectTarget(runtimeGOOS, runtimeGOARCH) + release := cfg.releaseTagName() + targetDir := filepath.Join(tmp, release, info.triple) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + destPath := filepath.Join(targetDir, info.exeName) + if err := os.WriteFile(destPath, []byte("old"), 0o700); err != nil { + t.Fatalf("write cache: %v", err) + } + + var downloads int + originalDownloader := downloadBinaryFunc + downloadBinaryFunc = func(info targetInfo, release, destPath string) error { + downloads++ + return os.WriteFile(destPath, []byte("new"), 0o700) + } + t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) + + path, err := ensureBundledBinary(cfg) + if err != nil { + t.Fatalf("ensureBundledBinary returned error: %v", err) + } + if downloads != 1 { + t.Fatalf("expected 1 re-download, got %d", downloads) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read binary: %v", err) + } + if string(data) != "new" { + t.Fatalf("expected binary to be rewritten with new contents") + } +} + func TestFindCodexPathFallsBackToSystemBinary(t *testing.T) { tmpCache := t.TempDir() t.Setenv("GODEX_CLI_CACHE", tmpCache) @@ -137,7 +278,7 @@ func TestFindCodexPathFallsBackToSystemBinary(t *testing.T) { originalPath := os.Getenv("PATH") t.Setenv("PATH", tempBinDir+string(os.PathListSeparator)+originalPath) - path, err := findCodexPath() + path, err := findCodexPath(bundleConfig{}) if err != nil { t.Fatalf("findCodexPath returned error: %v", err) } @@ -145,3 +286,8 @@ func TestFindCodexPathFallsBackToSystemBinary(t *testing.T) { t.Fatalf("expected fallback path within %s, got %s", tempBinDir, path) } } + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/codexexec/runner.go b/internal/codexexec/runner.go index 174c893..f30be1d 100644 --- a/internal/codexexec/runner.go +++ b/internal/codexexec/runner.go @@ -18,6 +18,18 @@ const ( goSDKOriginator = "codex_sdk_go" ) +// RunnerOptions controls how the Codex CLI binary is discovered / bootstrapped before execution. +type RunnerOptions struct { + // PathOverride points directly at a Codex binary instead of discovering/downloading it. + PathOverride string + // CacheDir overrides the directory used to cache downloaded Codex binaries. + CacheDir string + // ReleaseTag pins the Codex CLI release to download. + ReleaseTag string + // ChecksumHex enforces an expected SHA-256 checksum (hex encoded) for the downloaded binary. + ChecksumHex string +} + // Args mirrors the CLI flags accepted by `codex exec`. type Args struct { Input string @@ -39,11 +51,16 @@ type Runner struct { } // New constructs a Runner, optionally overriding the codex binary path. -func New(override string) (*Runner, error) { - path := override +func New(options RunnerOptions) (*Runner, error) { + path := options.PathOverride + bootstrap := bundleConfig{ + cacheDir: options.CacheDir, + releaseTag: options.ReleaseTag, + checksumHex: options.ChecksumHex, + } if path == "" { var err error - path, err = findCodexPath() + path, err = findCodexPath(bootstrap) if err != nil { return nil, err } @@ -233,8 +250,8 @@ func indexByte(s string, b byte) int { return -1 } -func findCodexPath() (string, error) { - bundledPath, bundleErr := ensureBundledBinary() +func findCodexPath(cfg bundleConfig) (string, error) { + bundledPath, bundleErr := ensureBundledBinary(cfg) if bundleErr == nil { return bundledPath, nil } diff --git a/options.go b/options.go index 9c2a8a7..255a0b6 100644 --- a/options.go +++ b/options.go @@ -34,6 +34,16 @@ type CodexOptions struct { // ConfigOverrides forwards CLI configuration overrides as `-c key=value` pairs. When // the `profile` key is present it is emitted as `--profile ` instead. ConfigOverrides map[string]any + // CLICacheDir overrides the directory used to cache downloaded Codex binaries. When empty, + // the SDK falls back to $GODEX_CLI_CACHE, then the user cache directory. + CLICacheDir string + // CLIReleaseTag pins the Codex CLI release tag to download. When unset, the SDK checks + // $GODEX_CLI_RELEASE_TAG before falling back to its default bundled tag. + CLIReleaseTag string + // CLIChecksum optionally enforces integrity verification of the downloaded Codex binary. + // Provide the expected SHA-256 checksum (hex encoded). When empty, checksum verification + // is skipped. Use $GODEX_CLI_CHECKSUM to configure the same behavior via environment. + CLIChecksum string } // ThreadOptions configure how the CLI executes a particular thread. diff --git a/thread_cancel_test.go b/thread_cancel_test.go index ab92c22..5e5e90a 100644 --- a/thread_cancel_test.go +++ b/thread_cancel_test.go @@ -23,7 +23,7 @@ func TestThreadRunStreamedCancellationTerminatesProcess(t *testing.T) { fakeBinary := buildFakeCodexBinary(t) - runner, err := codexexec.New(fakeBinary) + runner, err := codexexec.New(codexexec.RunnerOptions{PathOverride: fakeBinary}) if err != nil { t.Fatalf("codexexec.New returned error: %v", err) } From 6ea5d75a5ae467526743bc62bffe2a2e4aa7e788 Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 9 Nov 2025 21:26:04 +0100 Subject: [PATCH 2/2] feat: add release and checksum validation to bundle configuration --- internal/codexexec/bundle.go | 18 ++++++++ internal/codexexec/bundle_test.go | 72 +++++++++++++++++++++++++++++++ internal/codexexec/runner.go | 3 ++ 3 files changed, 93 insertions(+) diff --git a/internal/codexexec/bundle.go b/internal/codexexec/bundle.go index 3f3763e..77baa2b 100644 --- a/internal/codexexec/bundle.go +++ b/internal/codexexec/bundle.go @@ -84,6 +84,24 @@ func normalizeChecksum(value string) (string, error) { 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 diff --git a/internal/codexexec/bundle_test.go b/internal/codexexec/bundle_test.go index 943a992..0b25fa8 100644 --- a/internal/codexexec/bundle_test.go +++ b/internal/codexexec/bundle_test.go @@ -287,6 +287,78 @@ func TestFindCodexPathFallsBackToSystemBinary(t *testing.T) { } } +func TestFindCodexPathReturnsErrorWhenChecksumConfigured(t *testing.T) { + tmpCache := t.TempDir() + cfg := bundleConfig{cacheDir: tmpCache, checksumHex: strings.Repeat("00", 32)} + + originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH + runtimeGOOS, runtimeGOARCH = runtime.GOOS, runtime.GOARCH + t.Cleanup(func() { + runtimeGOOS, runtimeGOARCH = originalGOOS, originalGOARCH + }) + + originalDownloader := downloadBinaryFunc + downloadBinaryFunc = func(info targetInfo, release, destPath string) error { + return os.WriteFile(destPath, []byte("binary"), 0o700) + } + t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) + + tempBinDir := t.TempDir() + dummyCodex := filepath.Join(tempBinDir, "codex") + if runtime.GOOS == "windows" { + dummyCodex += ".exe" + } + if err := os.WriteFile(dummyCodex, []byte("dummy"), 0o700); err != nil { + t.Fatalf("write dummy binary: %v", err) + } + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempBinDir+string(os.PathListSeparator)+originalPath) + + _, err := findCodexPath(cfg) + if err == nil { + t.Fatalf("expected checksum error") + } + if !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("expected ErrChecksumMismatch, got %v", err) + } +} + +func TestFindCodexPathReturnsErrorWhenReleasePinned(t *testing.T) { + tmpCache := t.TempDir() + cfg := bundleConfig{cacheDir: tmpCache, releaseTag: "custom-release"} + + originalGOOS, originalGOARCH := runtimeGOOS, runtimeGOARCH + runtimeGOOS, runtimeGOARCH = runtime.GOOS, runtime.GOARCH + t.Cleanup(func() { + runtimeGOOS, runtimeGOARCH = originalGOOS, originalGOARCH + }) + + originalDownloader := downloadBinaryFunc + downloadBinaryFunc = func(info targetInfo, release, destPath string) error { + return fmt.Errorf("simulated download failure") + } + t.Cleanup(func() { downloadBinaryFunc = originalDownloader }) + + tempBinDir := t.TempDir() + dummyCodex := filepath.Join(tempBinDir, "codex") + if runtime.GOOS == "windows" { + dummyCodex += ".exe" + } + if err := os.WriteFile(dummyCodex, []byte("dummy"), 0o700); err != nil { + t.Fatalf("write dummy binary: %v", err) + } + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempBinDir+string(os.PathListSeparator)+originalPath) + + _, err := findCodexPath(cfg) + if err == nil { + t.Fatalf("expected error due to pinned release") + } + if !strings.Contains(err.Error(), "simulated download failure") { + t.Fatalf("expected download failure error, got %v", err) + } +} + func sha256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) diff --git a/internal/codexexec/runner.go b/internal/codexexec/runner.go index f30be1d..21bd826 100644 --- a/internal/codexexec/runner.go +++ b/internal/codexexec/runner.go @@ -255,6 +255,9 @@ func findCodexPath(cfg bundleConfig) (string, error) { if bundleErr == nil { return bundledPath, nil } + if cfg.requireBundledBinary() { + return "", fmt.Errorf("ensure bundled codex binary: %w", bundleErr) + } path, err := exec.LookPath("codex") if err == nil {