diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23ee6426..85ce63c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,28 @@ jobs: - name: Test (Windows) if: matrix.os == 'windows-latest' - run: go test -tags integration ./... + shell: pwsh + run: | + # go test on Windows can exit 1 due to temp .exe cleanup + # ("go: remove ...\.exe: Access is denied") even when all + # tests pass. Only suppress that specific pattern. + go test -tags integration ./... 2>&1 | Tee-Object -Variable testOut + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + $failed = $testOut | Select-String "^FAIL\s" + if ($failed) { + Write-Host "Tests failed:" + Write-Host ($failed -join "`n") + exit 1 + } + $cleanup = $testOut | Select-String "go: remove .+\.exe: Access is denied" + if (-not $cleanup) { + Write-Host "go test failed with unknown error" + exit $exitCode + } + Write-Host "All tests passed (ignoring temp .exe cleanup error)" + exit 0 + } - name: Build with CGO disabled run: go build ./... diff --git a/cmd/roborev/tui.go b/cmd/roborev/tui.go index f6f85227..1d7b7db0 100644 --- a/cmd/roborev/tui.go +++ b/cmd/roborev/tui.go @@ -1631,8 +1631,8 @@ func queueHelpLines(width int) int { if width <= 0 { return 2 } - h1 := (len(queueHelpLine1) + width - 1) / width - h2 := (len(queueHelpLine2) + width - 1) / width + h1 := (runewidth.StringWidth(queueHelpLine1) + width - 1) / width + h2 := (runewidth.StringWidth(queueHelpLine2) + width - 1) / width return h1 + h2 } @@ -2620,8 +2620,8 @@ func (m tuiModel) renderReviewView() string { const helpLine2 = "↑/↓: scroll | ←/→: prev/next | ?: commands | esc: back" helpLines := 2 if m.width > 0 { - h1 := (len(helpLine1) + m.width - 1) / m.width - h2 := (len(helpLine2) + m.width - 1) / m.width + h1 := (runewidth.StringWidth(helpLine1) + m.width - 1) / m.width + h2 := (runewidth.StringWidth(helpLine2) + m.width - 1) / m.width helpLines = h1 + h2 } diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 6e17d57e..ed11053d 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -136,14 +136,13 @@ func (a *ClaudeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt st cmd.Dir = repoPath cmd.WaitDelay = 5 * time.Second - // Handle API key: use configured key if set, otherwise filter out env var - // to ensure Claude uses subscription auth instead of unexpected API charges + // Strip CLAUDECODE to prevent nested-session detection (#270), + // and handle API key (configured key or subscription auth). + stripKeys := []string{"ANTHROPIC_API_KEY", "CLAUDECODE"} if apiKey := AnthropicAPIKey(); apiKey != "" { - // Use explicitly configured API key from roborev config - cmd.Env = append(filterEnv(os.Environ(), "ANTHROPIC_API_KEY"), "ANTHROPIC_API_KEY="+apiKey) + cmd.Env = append(filterEnv(os.Environ(), stripKeys...), "ANTHROPIC_API_KEY="+apiKey) } else { - // Clear env var so Claude uses subscription auth - cmd.Env = filterEnv(os.Environ(), "ANTHROPIC_API_KEY") + cmd.Env = filterEnv(os.Environ(), stripKeys...) } // Suppress sounds from Claude Code (notification/completion sounds) cmd.Env = append(cmd.Env, "CLAUDE_NO_SOUND=1") @@ -253,11 +252,18 @@ func (a *ClaudeAgent) parseStreamJSON(r io.Reader, output io.Writer) (string, er } // filterEnv returns a copy of env with the specified key removed -func filterEnv(env []string, key string) []string { - prefix := key + "=" +func filterEnv(env []string, keys ...string) []string { result := make([]string, 0, len(env)) for _, e := range env { - if !strings.HasPrefix(e, prefix) { + k, _, _ := strings.Cut(e, "=") + strip := false + for _, key := range keys { + if k == key { + strip = true + break + } + } + if !strip { result = append(result, e) } } diff --git a/internal/agent/claude_test.go b/internal/agent/claude_test.go index e9cea81d..78d2127b 100644 --- a/internal/agent/claude_test.go +++ b/internal/agent/claude_test.go @@ -239,3 +239,47 @@ func TestFilterEnv(t *testing.T) { t.Fatalf("missing expected env vars in filtered result: %v", filtered) } } + +func TestFilterEnvMultipleKeys(t *testing.T) { + env := []string{ + "PATH=/usr/bin", + "ANTHROPIC_API_KEY=secret", + "CLAUDECODE=1", + "HOME=/home/test", + } + + filtered := filterEnv(env, "ANTHROPIC_API_KEY", "CLAUDECODE") + + if len(filtered) != 2 { + t.Fatalf("expected 2 env vars, got %d: %v", len(filtered), filtered) + } + for _, e := range filtered { + if strings.HasPrefix(e, "ANTHROPIC_API_KEY=") { + t.Fatal("ANTHROPIC_API_KEY should be stripped") + } + if strings.HasPrefix(e, "CLAUDECODE=") { + t.Fatal("CLAUDECODE should be stripped") + } + } +} + +func TestFilterEnvExactMatchOnly(t *testing.T) { + env := []string{ + "CLAUDE=1", + "CLAUDECODE=1", + "CLAUDE_NO_SOUND=1", + "PATH=/usr/bin", + } + + filtered := filterEnv(env, "CLAUDE") + + if len(filtered) != 3 { + t.Fatalf("expected 3 env vars, got %d: %v", len(filtered), filtered) + } + for _, e := range filtered { + k, _, _ := strings.Cut(e, "=") + if k == "CLAUDE" { + t.Fatal("CLAUDE should be stripped") + } + } +}