From 326ad85c746b90040e630f9368a0e85223eea1b3 Mon Sep 17 00:00:00 2001 From: cexll Date: Sat, 24 Jan 2026 15:20:29 +0800 Subject: [PATCH] fix: use ANTHROPIC_AUTH_TOKEN for Claude CLI env injection - Change env var from ANTHROPIC_API_KEY to ANTHROPIC_AUTH_TOKEN - Add Backend field propagation in taskSpec (cli.go) - Add stderr logging for injected env vars with API key masking - Add comprehensive tests for env injection flow Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai --- codeagent-wrapper/Makefile | 9 +- codeagent-wrapper/README.md | 5 +- codeagent-wrapper/internal/app/cli.go | 1 + codeagent-wrapper/internal/backend/claude.go | 2 +- .../internal/executor/env_inject_test.go | 193 ++++++++++ .../internal/executor/env_logging_test.go | 333 ++++++++++++++++++ .../internal/executor/env_stderr_test.go | 133 +++++++ .../internal/executor/executor.go | 22 ++ 8 files changed, 690 insertions(+), 8 deletions(-) create mode 100644 codeagent-wrapper/internal/executor/env_inject_test.go create mode 100644 codeagent-wrapper/internal/executor/env_logging_test.go create mode 100644 codeagent-wrapper/internal/executor/env_stderr_test.go diff --git a/codeagent-wrapper/Makefile b/codeagent-wrapper/Makefile index e74e27c..57b88d6 100644 --- a/codeagent-wrapper/Makefile +++ b/codeagent-wrapper/Makefile @@ -1,8 +1,5 @@ GO ?= go -BINARY ?= codeagent -CMD_PKG := ./cmd/codeagent - TOOLS_BIN := $(CURDIR)/bin TOOLCHAIN ?= go1.22.0 GOLANGCI_LINT_VERSION := v1.56.2 @@ -14,7 +11,8 @@ STATICCHECK := $(TOOLS_BIN)/staticcheck .PHONY: build test lint clean install build: - $(GO) build -o $(BINARY) $(CMD_PKG) + $(GO) build -o codeagent ./cmd/codeagent + $(GO) build -o codeagent-wrapper ./cmd/codeagent-wrapper test: $(GO) test ./... @@ -35,4 +33,5 @@ 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_PKG) + $(GO) install ./cmd/codeagent + $(GO) install ./cmd/codeagent-wrapper diff --git a/codeagent-wrapper/README.md b/codeagent-wrapper/README.md index f5ed350..15db3fa 100644 --- a/codeagent-wrapper/README.md +++ b/codeagent-wrapper/README.md @@ -2,7 +2,7 @@ `codeagent-wrapper` 是一个用 Go 编写的“多后端 AI 代码代理”命令行包装器:用统一的 CLI 入口封装不同的 AI 工具后端(Codex / Claude / Gemini / Opencode),并提供一致的参数、配置与会话恢复体验。 -入口:`cmd/codeagent/main.go`(生成二进制名:`codeagent`)。 +入口:`cmd/codeagent/main.go`(生成二进制名:`codeagent`)和 `cmd/codeagent-wrapper/main.go`(生成二进制名:`codeagent-wrapper`)。两者行为一致。 ## 功能特性 @@ -22,12 +22,14 @@ ```bash go install ./cmd/codeagent +go install ./cmd/codeagent-wrapper ``` 安装后确认: ```bash codeagent version +codeagent-wrapper version ``` ## 使用示例 @@ -148,4 +150,3 @@ make test make lint make clean ``` - diff --git a/codeagent-wrapper/internal/app/cli.go b/codeagent-wrapper/internal/app/cli.go index 12bddcb..107a296 100644 --- a/codeagent-wrapper/internal/app/cli.go +++ b/codeagent-wrapper/internal/app/cli.go @@ -635,6 +635,7 @@ func runSingleMode(cfg *Config, name string) int { WorkDir: cfg.WorkDir, Mode: cfg.Mode, SessionID: cfg.SessionID, + Backend: cfg.Backend, Model: cfg.Model, ReasoningEffort: cfg.ReasoningEffort, Agent: cfg.Agent, diff --git a/codeagent-wrapper/internal/backend/claude.go b/codeagent-wrapper/internal/backend/claude.go index 510fe80..8435e81 100644 --- a/codeagent-wrapper/internal/backend/claude.go +++ b/codeagent-wrapper/internal/backend/claude.go @@ -25,7 +25,7 @@ func (ClaudeBackend) Env(baseURL, apiKey string) map[string]string { env["ANTHROPIC_BASE_URL"] = baseURL } if apiKey != "" { - env["ANTHROPIC_API_KEY"] = apiKey + env["ANTHROPIC_AUTH_TOKEN"] = apiKey } return env } diff --git a/codeagent-wrapper/internal/executor/env_inject_test.go b/codeagent-wrapper/internal/executor/env_inject_test.go new file mode 100644 index 0000000..cce981f --- /dev/null +++ b/codeagent-wrapper/internal/executor/env_inject_test.go @@ -0,0 +1,193 @@ +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + backend "codeagent-wrapper/internal/backend" + config "codeagent-wrapper/internal/config" +) + +// TestEnvInjectionWithAgent tests the full flow of env injection with agent config +func TestEnvInjectionWithAgent(t *testing.T) { + // Setup temp config + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".codeagent") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + // Write test config with agent that has base_url and api_key + configContent := `{ + "default_backend": "codex", + "agents": { + "test-agent": { + "backend": "claude", + "model": "test-model", + "base_url": "https://test.api.com", + "api_key": "test-api-key-12345678" + } + } + }` + configPath := filepath.Join(configDir, "models.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + // Override HOME to use temp dir + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + // Reset config cache + config.ResetModelsConfigCacheForTest() + defer config.ResetModelsConfigCacheForTest() + + // Test ResolveAgentConfig + agentBackend, model, _, _, baseURL, apiKey, _ := config.ResolveAgentConfig("test-agent") + t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q", + agentBackend, model, baseURL, apiKey) + + if agentBackend != "claude" { + t.Errorf("expected backend 'claude', got %q", agentBackend) + } + if baseURL != "https://test.api.com" { + t.Errorf("expected baseURL 'https://test.api.com', got %q", baseURL) + } + if apiKey != "test-api-key-12345678" { + t.Errorf("expected apiKey 'test-api-key-12345678', got %q", apiKey) + } + + // Test Backend.Env + b := backend.ClaudeBackend{} + env := b.Env(baseURL, apiKey) + t.Logf("Backend.Env: %v", env) + + if env == nil { + t.Fatal("expected non-nil env from Backend.Env") + } + 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"]) + } +} + +// TestEnvInjectionLogic tests the exact logic used in executor +func TestEnvInjectionLogic(t *testing.T) { + // Setup temp config + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".codeagent") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + configContent := `{ + "default_backend": "codex", + "agents": { + "explore": { + "backend": "claude", + "model": "MiniMax-M2.1", + "base_url": "https://api.minimaxi.com/anthropic", + "api_key": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" + } + } + }` + configPath := filepath.Join(configDir, "models.json") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + config.ResetModelsConfigCacheForTest() + defer config.ResetModelsConfigCacheForTest() + + // Simulate the executor logic + cfgBackend := "claude" // This should come from taskSpec.Backend + agentName := "explore" + + // Step 1: Get backend config (usually empty for claude without global config) + baseURL, apiKey := config.ResolveBackendConfig(cfgBackend) + t.Logf("Step 1 - ResolveBackendConfig(%q): baseURL=%q, apiKey=%q", cfgBackend, baseURL, apiKey) + + // Step 2: If agent specified, get agent config + if agentName != "" { + agentBackend, _, _, _, agentBaseURL, agentAPIKey, _ := config.ResolveAgentConfig(agentName) + t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q", + agentName, agentBackend, agentBaseURL, agentAPIKey) + + // Step 3: Check if agent backend matches cfg backend + if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfgBackend)) { + baseURL, apiKey = agentBaseURL, agentAPIKey + t.Logf("Step 3 - Backend match! Using agent config: baseURL=%q, apiKey=%q", baseURL, apiKey) + } else { + t.Logf("Step 3 - Backend mismatch: agent=%q, cfg=%q", agentBackend, cfgBackend) + } + } + + // Step 4: Get env vars from backend + b := backend.ClaudeBackend{} + injected := b.Env(baseURL, apiKey) + t.Logf("Step 4 - Backend.Env: %v", injected) + + // Verify + if len(injected) == 0 { + t.Fatal("Expected env vars to be injected, got none") + } + + expectedURL := "https://api.minimaxi.com/anthropic" + if injected["ANTHROPIC_BASE_URL"] != expectedURL { + 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") + } + + // Step 5: Test masking + for k, v := range injected { + masked := maskSensitiveValue(k, v) + t.Logf("Step 5 - Env log: %s=%s", k, masked) + } +} + +// TestTaskSpecBackendPropagation tests that taskSpec.Backend is properly used +func TestTaskSpecBackendPropagation(t *testing.T) { + // Simulate what happens in RunCodexTaskWithContext + taskSpec := TaskSpec{ + ID: "test", + Task: "hello", + Backend: "claude", + Agent: "explore", + } + + // This is the logic from executor.go lines 889-916 + cfg := &config.Config{ + Mode: "new", + Task: taskSpec.Task, + Backend: "codex", // default + } + + var backend Backend = nil // nil in single mode + commandName := "codex" // default + + if backend != nil { + cfg.Backend = backend.Name() + } else if taskSpec.Backend != "" { + cfg.Backend = taskSpec.Backend + } else if commandName != "" { + cfg.Backend = commandName + } + + t.Logf("taskSpec.Backend=%q, cfg.Backend=%q", taskSpec.Backend, cfg.Backend) + + if cfg.Backend != "claude" { + t.Errorf("expected cfg.Backend='claude', got %q", cfg.Backend) + } +} diff --git a/codeagent-wrapper/internal/executor/env_logging_test.go b/codeagent-wrapper/internal/executor/env_logging_test.go new file mode 100644 index 0000000..5ee7da2 --- /dev/null +++ b/codeagent-wrapper/internal/executor/env_logging_test.go @@ -0,0 +1,333 @@ +package executor + +import ( + "strings" + "testing" + + backend "codeagent-wrapper/internal/backend" +) + +func TestMaskSensitiveValue(t *testing.T) { + tests := []struct { + name string + key string + value string + expected string + }{ + { + name: "API_KEY with long value", + key: "ANTHROPIC_AUTH_TOKEN", + value: "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + expected: "sk-a****xxxx", + }, + { + name: "api_key lowercase", + key: "api_key", + value: "abcdefghijklmnop", + expected: "abcd****mnop", + }, + { + name: "AUTH_TOKEN", + key: "AUTH_TOKEN", + value: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", + expected: "eyJh****VCJ9", + }, + { + name: "SECRET", + key: "MY_SECRET", + value: "super-secret-value-12345", + expected: "supe****2345", + }, + { + name: "short key value (8 chars)", + key: "API_KEY", + value: "12345678", + expected: "****", + }, + { + name: "very short key value", + key: "API_KEY", + value: "abc", + expected: "****", + }, + { + name: "empty key value", + key: "API_KEY", + value: "", + expected: "", + }, + { + name: "non-sensitive BASE_URL", + key: "ANTHROPIC_BASE_URL", + value: "https://api.anthropic.com", + expected: "https://api.anthropic.com", + }, + { + name: "non-sensitive MODEL", + key: "MODEL", + value: "claude-3-opus", + expected: "claude-3-opus", + }, + { + name: "case insensitive - Key", + key: "My_Key", + value: "1234567890abcdef", + expected: "1234****cdef", + }, + { + name: "case insensitive - TOKEN", + key: "ACCESS_TOKEN", + value: "access123456789", + expected: "acce****6789", + }, + { + name: "partial match - apikey", + key: "MYAPIKEY", + value: "1234567890", + expected: "1234****7890", + }, + { + name: "partial match - secretvalue", + key: "SECRETVALUE", + value: "abcdefghij", + expected: "abcd****ghij", + }, + { + name: "9 char value (just above threshold)", + key: "API_KEY", + value: "123456789", + expected: "1234****6789", + }, + { + name: "exactly 8 char value (at threshold)", + key: "API_KEY", + value: "12345678", + expected: "****", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maskSensitiveValue(tt.key, tt.value) + if result != tt.expected { + t.Errorf("maskSensitiveValue(%q, %q) = %q, want %q", tt.key, tt.value, result, tt.expected) + } + }) + } +} + +func TestMaskSensitiveValue_NoLeakage(t *testing.T) { + // Ensure sensitive values are never fully exposed + sensitiveKeys := []string{"API_KEY", "api_key", "AUTH_TOKEN", "SECRET", "access_token", "MYAPIKEY"} + longValue := "this-is-a-very-long-secret-value-that-should-be-masked" + + for _, key := range sensitiveKeys { + t.Run(key, func(t *testing.T) { + masked := maskSensitiveValue(key, longValue) + // Should not contain the full value + if masked == longValue { + t.Errorf("key %q: value was not masked", key) + } + // Should contain mask marker + if !strings.Contains(masked, "****") { + t.Errorf("key %q: masked value %q does not contain ****", key, masked) + } + // First 4 chars should be visible + if !strings.HasPrefix(masked, longValue[:4]) { + t.Errorf("key %q: masked value should start with first 4 chars", key) + } + // Last 4 chars should be visible + if !strings.HasSuffix(masked, longValue[len(longValue)-4:]) { + t.Errorf("key %q: masked value should end with last 4 chars", key) + } + }) + } +} + +func TestMaskSensitiveValue_NonSensitivePassthrough(t *testing.T) { + // Non-sensitive keys should pass through unchanged + nonSensitiveKeys := []string{ + "ANTHROPIC_BASE_URL", + "BASE_URL", + "MODEL", + "BACKEND", + "WORKDIR", + "HOME", + "PATH", + } + value := "any-value-here-12345" + + for _, key := range nonSensitiveKeys { + t.Run(key, func(t *testing.T) { + result := maskSensitiveValue(key, value) + if result != value { + t.Errorf("key %q: expected passthrough but got %q", key, result) + } + }) + } +} + +// TestClaudeBackendEnv tests that ClaudeBackend.Env returns correct env vars +func TestClaudeBackendEnv(t *testing.T) { + tests := []struct { + name string + baseURL string + apiKey string + expectKeys []string + expectNil bool + }{ + { + 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"}, + }, + { + name: "only base_url", + baseURL: "https://api.custom.com", + apiKey: "", + expectKeys: []string{"ANTHROPIC_BASE_URL"}, + }, + { + name: "only api_key", + baseURL: "", + apiKey: "sk-test-key-12345", + expectKeys: []string{"ANTHROPIC_AUTH_TOKEN"}, + }, + { + name: "both empty", + baseURL: "", + apiKey: "", + expectNil: true, + }, + { + name: "whitespace only", + baseURL: " ", + apiKey: " ", + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := backend.ClaudeBackend{} + env := b.Env(tt.baseURL, tt.apiKey) + + if tt.expectNil { + if env != nil { + t.Errorf("expected nil env, got %v", env) + } + return + } + + if env == nil { + t.Fatal("expected non-nil env") + } + + for _, key := range tt.expectKeys { + if _, ok := env[key]; !ok { + t.Errorf("expected key %q in env", key) + } + } + + // Verify values are correct + if tt.baseURL != "" && strings.TrimSpace(tt.baseURL) != "" { + if env["ANTHROPIC_BASE_URL"] != strings.TrimSpace(tt.baseURL) { + t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", env["ANTHROPIC_BASE_URL"], strings.TrimSpace(tt.baseURL)) + } + } + 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)) + } + } + }) + } +} + +// TestEnvLoggingIntegration tests that env vars are properly masked in logs +func TestEnvLoggingIntegration(t *testing.T) { + b := backend.ClaudeBackend{} + baseURL := "https://api.minimaxi.com/anthropic" + apiKey := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longjwttoken" + + env := b.Env(baseURL, apiKey) + if env == nil { + t.Fatal("expected non-nil env") + } + + // Verify that when we log these values, sensitive ones are masked + for k, v := range env { + masked := maskSensitiveValue(k, v) + + if k == "ANTHROPIC_BASE_URL" { + // URL should not be masked + if masked != v { + t.Errorf("BASE_URL should not be masked: got %q, want %q", masked, v) + } + } + + if k == "ANTHROPIC_AUTH_TOKEN" { + // API key should be masked + if masked == v { + t.Errorf("API_KEY should be masked, but got original value") + } + if !strings.Contains(masked, "****") { + t.Errorf("masked API_KEY should contain ****: got %q", masked) + } + // Should still show first 4 and last 4 chars + if !strings.HasPrefix(masked, v[:4]) { + t.Errorf("masked value should start with first 4 chars of original") + } + if !strings.HasSuffix(masked, v[len(v)-4:]) { + t.Errorf("masked value should end with last 4 chars of original") + } + } + } +} + +// TestGeminiBackendEnv tests GeminiBackend.Env for comparison +func TestGeminiBackendEnv(t *testing.T) { + b := backend.GeminiBackend{} + env := b.Env("https://custom.api", "gemini-api-key-12345") + + if env == nil { + t.Fatal("expected non-nil env") + } + + // Check that GEMINI env vars are set + if _, ok := env["GOOGLE_GEMINI_BASE_URL"]; !ok { + t.Error("expected GOOGLE_GEMINI_BASE_URL in env") + } + if _, ok := env["GEMINI_API_KEY"]; !ok { + t.Error("expected GEMINI_API_KEY in env") + } + + // Verify masking works for Gemini keys too + for k, v := range env { + masked := maskSensitiveValue(k, v) + if strings.Contains(strings.ToLower(k), "key") { + if masked == v && len(v) > 0 { + t.Errorf("key %q should be masked", k) + } + } + } +} + +// TestCodexBackendEnv tests CodexBackend.Env +func TestCodexBackendEnv(t *testing.T) { + b := backend.CodexBackend{} + env := b.Env("https://custom.api", "codex-api-key-12345") + + if env == nil { + t.Fatal("expected non-nil env for codex") + } + + // Check for OPENAI env vars + if _, ok := env["OPENAI_BASE_URL"]; !ok { + t.Error("expected OPENAI_BASE_URL in env") + } + if _, ok := env["OPENAI_API_KEY"]; !ok { + t.Error("expected OPENAI_API_KEY in env") + } +} diff --git a/codeagent-wrapper/internal/executor/env_stderr_test.go b/codeagent-wrapper/internal/executor/env_stderr_test.go new file mode 100644 index 0000000..0b9baa1 --- /dev/null +++ b/codeagent-wrapper/internal/executor/env_stderr_test.go @@ -0,0 +1,133 @@ +package executor + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + config "codeagent-wrapper/internal/config" +) + +type fakeCmd struct { + env map[string]string +} + +func (f *fakeCmd) Start() error { return nil } +func (f *fakeCmd) Wait() error { return nil } +func (f *fakeCmd) StdoutPipe() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} +func (f *fakeCmd) StderrPipe() (io.ReadCloser, error) { + return nil, errors.New("fake stderr pipe error") +} +func (f *fakeCmd) StdinPipe() (io.WriteCloser, error) { + return nil, errors.New("fake stdin pipe error") +} +func (f *fakeCmd) SetStderr(io.Writer) {} +func (f *fakeCmd) SetDir(string) {} +func (f *fakeCmd) SetEnv(env map[string]string) { + if len(env) == 0 { + return + } + if f.env == nil { + f.env = make(map[string]string, len(env)) + } + for k, v := range env { + f.env[k] = v + } +} +func (f *fakeCmd) Process() processHandle { return nil } + +func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) { + // Arrange ~/.codeagent/models.json via HOME override. + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".codeagent") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + const baseURL = "https://api.minimaxi.com/anthropic" + const apiKey = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" + models := `{ + "agents": { + "explore": { + "backend": "claude", + "model": "MiniMax-M2.1", + "base_url": "` + baseURL + `", + "api_key": "` + apiKey + `" + } + } +}` + if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(models), 0o644); err != nil { + t.Fatal(err) + } + + oldHome := os.Getenv("HOME") + if err := os.Setenv("HOME", tmpDir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Setenv("HOME", oldHome) }() + + config.ResetModelsConfigCacheForTest() + defer config.ResetModelsConfigCacheForTest() + + // Capture stderr (RunCodexTaskWithContext prints env injection lines there). + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = w + defer func() { os.Stderr = oldStderr }() + + readDone := make(chan string, 1) + go func() { + defer r.Close() + b, _ := io.ReadAll(r) + readDone <- string(b) + }() + + var cmd *fakeCmd + restoreRunner := SetNewCommandRunner(func(ctx context.Context, name string, args ...string) CommandRunner { + cmd = &fakeCmd{} + return cmd + }) + defer restoreRunner() + + // Act: force an early return right after env injection by making StderrPipe fail. + _ = RunCodexTaskWithContext( + context.Background(), + TaskSpec{Task: "hi", WorkDir: ".", Backend: "claude", Agent: "explore"}, + nil, + "claude", + nil, + nil, + false, + false, + 1, + ) + + _ = w.Close() + got := <-readDone + + // Assert: env was injected into the command and logging is present with masking. + if cmd == nil || cmd.env == nil { + t.Fatalf("expected cmd env to be set, got cmd=%v env=%v", cmd, nil) + } + 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 !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") { + 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 7e3abe5..7886d04 100644 --- a/codeagent-wrapper/internal/executor/executor.go +++ b/codeagent-wrapper/internal/executor/executor.go @@ -1067,6 +1067,12 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe } if injected := envBackend.Env(baseURL, apiKey); len(injected) > 0 { cmd.SetEnv(injected) + // Log injected env vars with masked API keys (to file and stderr) + for k, v := range injected { + msg := fmt.Sprintf("Env: %s=%s", k, maskSensitiveValue(k, v)) + logInfoFn(msg) + fmt.Fprintln(os.Stderr, " "+msg) + } } } @@ -1449,3 +1455,19 @@ func terminateCommand(cmd commandRunner) *forceKillTimer { return &forceKillTimer{timer: timer, done: done} } + +// maskSensitiveValue masks sensitive values like API keys for logging. +// Values containing "key", "token", or "secret" (case-insensitive) are masked. +// For values longer than 8 chars: shows first 4 + **** + last 4. +// For shorter values: shows only ****. +func maskSensitiveValue(key, value string) string { + keyLower := strings.ToLower(key) + if strings.Contains(keyLower, "key") || strings.Contains(keyLower, "token") || strings.Contains(keyLower, "secret") { + if len(value) > 8 { + return value[:4] + "****" + value[len(value)-4:] + } else if len(value) > 0 { + return "****" + } + } + return value +}