From cd3115446d4c5596a36ec95fcd3b6366d6faca3c Mon Sep 17 00:00:00 2001 From: cexll Date: Wed, 28 Jan 2026 11:55:55 +0800 Subject: [PATCH] fix(codeagent-wrapper): improve CI, version handling and temp dir - CI: fetch tags for version detection - Makefile: inject version via ldflags - Add CODEAGENT_TMPDIR support for macOS permission issues - Inject ANTHROPIC_BASE_URL/API_KEY for claude backend Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai --- codeagent-wrapper/.github/workflows/ci.yml | 8 ++ codeagent-wrapper/Makefile | 8 +- codeagent-wrapper/README.md | 5 + codeagent-wrapper/internal/app/app.go | 3 +- codeagent-wrapper/internal/app/cli.go | 1 + codeagent-wrapper/internal/app/tmpdir.go | 134 ++++++++++++++++++ codeagent-wrapper/internal/app/tmpdir_test.go | 96 +++++++++++++ codeagent-wrapper/internal/backend/claude.go | 3 +- .../internal/executor/env_inject_test.go | 8 +- .../internal/executor/env_logging_test.go | 12 +- .../internal/executor/env_stderr_test.go | 6 +- .../internal/executor/executor.go | 18 +++ 12 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 codeagent-wrapper/internal/app/tmpdir.go create mode 100644 codeagent-wrapper/internal/app/tmpdir_test.go diff --git a/codeagent-wrapper/.github/workflows/ci.yml b/codeagent-wrapper/.github/workflows/ci.yml index cc59c52..0c6690c 100644 --- a/codeagent-wrapper/.github/workflows/ci.yml +++ b/codeagent-wrapper/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: go-version: ["1.21", "1.22"] steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} @@ -25,11 +28,16 @@ jobs: run: make test - name: Build run: make build + - name: Verify version + run: ./codeagent-wrapper --version lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - uses: actions/setup-go@v5 with: go-version: "1.22" diff --git a/codeagent-wrapper/Makefile b/codeagent-wrapper/Makefile index 57b88d6..85d4000 100644 --- a/codeagent-wrapper/Makefile +++ b/codeagent-wrapper/Makefile @@ -1,4 +1,6 @@ GO ?= go +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -ldflags "-X codeagent-wrapper/internal/app.version=$(VERSION)" TOOLS_BIN := $(CURDIR)/bin TOOLCHAIN ?= go1.22.0 @@ -11,8 +13,7 @@ STATICCHECK := $(TOOLS_BIN)/staticcheck .PHONY: build test lint clean install build: - $(GO) build -o codeagent ./cmd/codeagent - $(GO) build -o codeagent-wrapper ./cmd/codeagent-wrapper + $(GO) build $(LDFLAGS) -o codeagent-wrapper ./cmd/codeagent-wrapper test: $(GO) test ./... @@ -33,5 +34,4 @@ clean: @python3 -c 'import glob, os; paths=["codeagent","codeagent.exe","codeagent-wrapper","codeagent-wrapper.exe","coverage.out","cover.out","coverage.html"]; paths += glob.glob("coverage*.out") + glob.glob("cover_*.out") + glob.glob("*.test"); [os.remove(p) for p in paths if os.path.exists(p)]' install: - $(GO) install ./cmd/codeagent - $(GO) install ./cmd/codeagent-wrapper + $(GO) install $(LDFLAGS) ./cmd/codeagent-wrapper diff --git a/codeagent-wrapper/README.md b/codeagent-wrapper/README.md index 15db3fa..85f1237 100644 --- a/codeagent-wrapper/README.md +++ b/codeagent-wrapper/README.md @@ -150,3 +150,8 @@ make test make lint make clean ``` + +## 故障排查 + +- macOS 下如果看到临时目录相关的 `permission denied`(例如临时可执行文件无法在 `/var/folders/.../T` 执行),可设置一个可执行的临时目录:`CODEAGENT_TMPDIR=$HOME/.codeagent/tmp`。 +- `claude` 后端的 `base_url/api_key`(来自 `~/.codeagent/models.json`)会注入到子进程环境变量:`ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`。若 `base_url` 指向本地代理(如 `localhost:23001`),请确认代理进程在运行。 diff --git a/codeagent-wrapper/internal/app/app.go b/codeagent-wrapper/internal/app/app.go index 100e777..a00dbe6 100644 --- a/codeagent-wrapper/internal/app/app.go +++ b/codeagent-wrapper/internal/app/app.go @@ -9,8 +9,9 @@ import ( "time" ) +var version = "dev" + const ( - version = "6.1.2" defaultWorkdir = "." defaultTimeout = 7200 // seconds (2 hours) defaultCoverageTarget = 90.0 diff --git a/codeagent-wrapper/internal/app/cli.go b/codeagent-wrapper/internal/app/cli.go index ff47e3c..6a9c6b2 100644 --- a/codeagent-wrapper/internal/app/cli.go +++ b/codeagent-wrapper/internal/app/cli.go @@ -168,6 +168,7 @@ func newCleanupCommand() *cobra.Command { } func runWithLoggerAndCleanup(fn func() int) (exitCode int) { + ensureExecutableTempDir() logger, err := NewLogger() if err != nil { fmt.Fprintf(os.Stderr, "ERROR: failed to initialize logger: %v\n", err) diff --git a/codeagent-wrapper/internal/app/tmpdir.go b/codeagent-wrapper/internal/app/tmpdir.go new file mode 100644 index 0000000..d47c88e --- /dev/null +++ b/codeagent-wrapper/internal/app/tmpdir.go @@ -0,0 +1,134 @@ +package wrapper + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const tmpDirEnvOverrideKey = "CODEAGENT_TMPDIR" + +var tmpDirExecutableCheckFn = canExecuteInDir + +func ensureExecutableTempDir() { + // Windows doesn't execute scripts via shebang, and os.TempDir semantics differ. + if runtime.GOOS == "windows" { + return + } + + if override := strings.TrimSpace(os.Getenv(tmpDirEnvOverrideKey)); override != "" { + if resolved, err := resolvePathWithTilde(override); err == nil { + if err := os.MkdirAll(resolved, 0o700); err == nil { + if ok, _ := tmpDirExecutableCheckFn(resolved); ok { + setTempEnv(resolved) + return + } + } + } + // Invalid override should not block execution; fall back to default behavior. + } + + current := currentTempDirFromEnv() + if current == "" { + current = "/tmp" + } + + ok, _ := tmpDirExecutableCheckFn(current) + if ok { + return + } + + fallback := defaultFallbackTempDir() + if fallback == "" { + return + } + if err := os.MkdirAll(fallback, 0o700); err != nil { + return + } + if ok, _ := tmpDirExecutableCheckFn(fallback); !ok { + return + } + + setTempEnv(fallback) + fmt.Fprintf(os.Stderr, "INFO: temp dir is not executable; set TMPDIR=%s\n", fallback) +} + +func setTempEnv(dir string) { + _ = os.Setenv("TMPDIR", dir) + _ = os.Setenv("TMP", dir) + _ = os.Setenv("TEMP", dir) +} + +func defaultFallbackTempDir() string { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "" + } + return filepath.Clean(filepath.Join(home, ".codeagent", "tmp")) +} + +func currentTempDirFromEnv() string { + for _, k := range []string{"TMPDIR", "TMP", "TEMP"} { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + } + return "" +} + +func resolvePathWithTilde(p string) (string, error) { + p = strings.TrimSpace(p) + if p == "" { + return "", errors.New("empty path") + } + + if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~\\") { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + if err == nil { + err = errors.New("empty home directory") + } + return "", fmt.Errorf("resolve ~: %w", err) + } + if p == "~" { + return home, nil + } + return filepath.Clean(home + p[1:]), nil + } + + return filepath.Clean(p), nil +} + +func canExecuteInDir(dir string) (bool, error) { + dir = strings.TrimSpace(dir) + if dir == "" { + return false, errors.New("empty dir") + } + + f, err := os.CreateTemp(dir, "codeagent-tmp-exec-*") + if err != nil { + return false, err + } + path := f.Name() + defer func() { _ = os.Remove(path) }() + + if _, err := f.WriteString("#!/bin/sh\nexit 0\n"); err != nil { + _ = f.Close() + return false, err + } + if err := f.Close(); err != nil { + return false, err + } + if err := os.Chmod(path, 0o700); err != nil { + return false, err + } + + if err := exec.Command(path).Run(); err != nil { + return false, err + } + return true, nil +} diff --git a/codeagent-wrapper/internal/app/tmpdir_test.go b/codeagent-wrapper/internal/app/tmpdir_test.go new file mode 100644 index 0000000..4aff1e5 --- /dev/null +++ b/codeagent-wrapper/internal/app/tmpdir_test.go @@ -0,0 +1,96 @@ +package wrapper + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureExecutableTempDir_Override(t *testing.T) { + restore := captureTempEnv() + t.Cleanup(restore) + + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + + orig := tmpDirExecutableCheckFn + tmpDirExecutableCheckFn = func(string) (bool, error) { return true, nil } + t.Cleanup(func() { tmpDirExecutableCheckFn = orig }) + + override := filepath.Join(t.TempDir(), "mytmp") + t.Setenv(tmpDirEnvOverrideKey, override) + + ensureExecutableTempDir() + + if got := os.Getenv("TMPDIR"); got != override { + t.Fatalf("TMPDIR=%q, want %q", got, override) + } + if got := os.Getenv("TMP"); got != override { + t.Fatalf("TMP=%q, want %q", got, override) + } + if got := os.Getenv("TEMP"); got != override { + t.Fatalf("TEMP=%q, want %q", got, override) + } + if st, err := os.Stat(override); err != nil || !st.IsDir() { + t.Fatalf("override dir not created: stat=%v err=%v", st, err) + } +} + +func TestEnsureExecutableTempDir_FallbackWhenCurrentNotExecutable(t *testing.T) { + restore := captureTempEnv() + t.Cleanup(restore) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + cur := filepath.Join(t.TempDir(), "cur-tmp") + if err := os.MkdirAll(cur, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("TMPDIR", cur) + + fallback := filepath.Join(home, ".codeagent", "tmp") + + orig := tmpDirExecutableCheckFn + tmpDirExecutableCheckFn = func(dir string) (bool, error) { + if filepath.Clean(dir) == filepath.Clean(cur) { + return false, nil + } + if filepath.Clean(dir) == filepath.Clean(fallback) { + return true, nil + } + return true, nil + } + t.Cleanup(func() { tmpDirExecutableCheckFn = orig }) + + ensureExecutableTempDir() + + if got := os.Getenv("TMPDIR"); filepath.Clean(got) != filepath.Clean(fallback) { + t.Fatalf("TMPDIR=%q, want %q", got, fallback) + } + if st, err := os.Stat(fallback); err != nil || !st.IsDir() { + t.Fatalf("fallback dir not created: stat=%v err=%v", st, err) + } +} + +func captureTempEnv() func() { + type entry struct { + set bool + val string + } + snapshot := make(map[string]entry, 3) + for _, k := range []string{"TMPDIR", "TMP", "TEMP"} { + v, ok := os.LookupEnv(k) + snapshot[k] = entry{set: ok, val: v} + } + return func() { + for k, e := range snapshot { + if !e.set { + _ = os.Unsetenv(k) + continue + } + _ = os.Setenv(k, e.val) + } + } +} diff --git a/codeagent-wrapper/internal/backend/claude.go b/codeagent-wrapper/internal/backend/claude.go index 8435e81..f9a9f0f 100644 --- a/codeagent-wrapper/internal/backend/claude.go +++ b/codeagent-wrapper/internal/backend/claude.go @@ -25,7 +25,8 @@ func (ClaudeBackend) Env(baseURL, apiKey string) map[string]string { env["ANTHROPIC_BASE_URL"] = baseURL } if apiKey != "" { - env["ANTHROPIC_AUTH_TOKEN"] = apiKey + // Claude Code CLI uses ANTHROPIC_API_KEY for API-key based auth. + env["ANTHROPIC_API_KEY"] = apiKey } return env } diff --git a/codeagent-wrapper/internal/executor/env_inject_test.go b/codeagent-wrapper/internal/executor/env_inject_test.go index 089e379..0a75884 100644 --- a/codeagent-wrapper/internal/executor/env_inject_test.go +++ b/codeagent-wrapper/internal/executor/env_inject_test.go @@ -72,8 +72,8 @@ func TestEnvInjectionWithAgent(t *testing.T) { if env["ANTHROPIC_BASE_URL"] != baseURL { t.Errorf("expected ANTHROPIC_BASE_URL=%q, got %q", baseURL, env["ANTHROPIC_BASE_URL"]) } - if env["ANTHROPIC_AUTH_TOKEN"] != apiKey { - t.Errorf("expected ANTHROPIC_AUTH_TOKEN=%q, got %q", apiKey, env["ANTHROPIC_AUTH_TOKEN"]) + if env["ANTHROPIC_API_KEY"] != apiKey { + t.Errorf("expected ANTHROPIC_API_KEY=%q, got %q", apiKey, env["ANTHROPIC_API_KEY"]) } } @@ -149,8 +149,8 @@ func TestEnvInjectionLogic(t *testing.T) { t.Errorf("ANTHROPIC_BASE_URL: expected %q, got %q", expectedURL, injected["ANTHROPIC_BASE_URL"]) } - if _, ok := injected["ANTHROPIC_AUTH_TOKEN"]; !ok { - t.Error("ANTHROPIC_AUTH_TOKEN not set") + if _, ok := injected["ANTHROPIC_API_KEY"]; !ok { + t.Error("ANTHROPIC_API_KEY not set") } // Step 5: Test masking diff --git a/codeagent-wrapper/internal/executor/env_logging_test.go b/codeagent-wrapper/internal/executor/env_logging_test.go index 5ee7da2..0fb6075 100644 --- a/codeagent-wrapper/internal/executor/env_logging_test.go +++ b/codeagent-wrapper/internal/executor/env_logging_test.go @@ -16,7 +16,7 @@ func TestMaskSensitiveValue(t *testing.T) { }{ { name: "API_KEY with long value", - key: "ANTHROPIC_AUTH_TOKEN", + key: "ANTHROPIC_API_KEY", value: "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxx", expected: "sk-a****xxxx", }, @@ -180,7 +180,7 @@ func TestClaudeBackendEnv(t *testing.T) { name: "both base_url and api_key", baseURL: "https://api.custom.com", apiKey: "sk-test-key-12345", - expectKeys: []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"}, + expectKeys: []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"}, }, { name: "only base_url", @@ -192,7 +192,7 @@ func TestClaudeBackendEnv(t *testing.T) { name: "only api_key", baseURL: "", apiKey: "sk-test-key-12345", - expectKeys: []string{"ANTHROPIC_AUTH_TOKEN"}, + expectKeys: []string{"ANTHROPIC_API_KEY"}, }, { name: "both empty", @@ -237,8 +237,8 @@ func TestClaudeBackendEnv(t *testing.T) { } } if tt.apiKey != "" && strings.TrimSpace(tt.apiKey) != "" { - if env["ANTHROPIC_AUTH_TOKEN"] != strings.TrimSpace(tt.apiKey) { - t.Errorf("ANTHROPIC_AUTH_TOKEN = %q, want %q", env["ANTHROPIC_AUTH_TOKEN"], strings.TrimSpace(tt.apiKey)) + if env["ANTHROPIC_API_KEY"] != strings.TrimSpace(tt.apiKey) { + t.Errorf("ANTHROPIC_API_KEY = %q, want %q", env["ANTHROPIC_API_KEY"], strings.TrimSpace(tt.apiKey)) } } }) @@ -267,7 +267,7 @@ func TestEnvLoggingIntegration(t *testing.T) { } } - if k == "ANTHROPIC_AUTH_TOKEN" { + if k == "ANTHROPIC_API_KEY" { // API key should be masked if masked == v { t.Errorf("API_KEY should be masked, but got original value") diff --git a/codeagent-wrapper/internal/executor/env_stderr_test.go b/codeagent-wrapper/internal/executor/env_stderr_test.go index 9c9c2b7..693237c 100644 --- a/codeagent-wrapper/internal/executor/env_stderr_test.go +++ b/codeagent-wrapper/internal/executor/env_stderr_test.go @@ -117,14 +117,14 @@ func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) { if cmd.env["ANTHROPIC_BASE_URL"] != baseURL { t.Fatalf("ANTHROPIC_BASE_URL=%q, want %q", cmd.env["ANTHROPIC_BASE_URL"], baseURL) } - if cmd.env["ANTHROPIC_AUTH_TOKEN"] != apiKey { - t.Fatalf("ANTHROPIC_AUTH_TOKEN=%q, want %q", cmd.env["ANTHROPIC_AUTH_TOKEN"], apiKey) + if cmd.env["ANTHROPIC_API_KEY"] != apiKey { + t.Fatalf("ANTHROPIC_API_KEY=%q, want %q", cmd.env["ANTHROPIC_API_KEY"], apiKey) } if !strings.Contains(got, "Env: ANTHROPIC_BASE_URL="+baseURL) { t.Fatalf("stderr missing base URL env log; stderr=%q", got) } - if !strings.Contains(got, "Env: ANTHROPIC_AUTH_TOKEN=eyJh****test") { + if !strings.Contains(got, "Env: ANTHROPIC_API_KEY=eyJh****test") { t.Fatalf("stderr missing masked API key log; stderr=%q", got) } } diff --git a/codeagent-wrapper/internal/executor/executor.go b/codeagent-wrapper/internal/executor/executor.go index 14acccb..66485b7 100644 --- a/codeagent-wrapper/internal/executor/executor.go +++ b/codeagent-wrapper/internal/executor/executor.go @@ -1088,6 +1088,8 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe } } + injectTempEnv(cmd) + // For backends that don't support -C flag (claude, gemini), set working directory via cmd.Dir // Codex passes workdir via -C flag, so we skip setting Dir for it to avoid conflicts if cfg.Mode != "resume" && commandName != "codex" && cfg.WorkDir != "" { @@ -1397,6 +1399,22 @@ waitLoop: return result } +func injectTempEnv(cmd commandRunner) { + if cmd == nil { + return + } + env := make(map[string]string, 3) + for _, k := range []string{"TMPDIR", "TMP", "TEMP"} { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + env[k] = v + } + } + if len(env) == 0 { + return + } + cmd.SetEnv(env) +} + func cancelReason(commandName string, ctx context.Context) string { if ctx == nil { return "Context cancelled"