mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
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 <noreply@swe-agent.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
193
codeagent-wrapper/internal/executor/env_inject_test.go
Normal file
193
codeagent-wrapper/internal/executor/env_inject_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
333
codeagent-wrapper/internal/executor/env_logging_test.go
Normal file
333
codeagent-wrapper/internal/executor/env_logging_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
133
codeagent-wrapper/internal/executor/env_stderr_test.go
Normal file
133
codeagent-wrapper/internal/executor/env_stderr_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user