初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+663
View File
@@ -0,0 +1,663 @@
package claudecode
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
"github.com/creack/pty"
)
const (
claudeUsageSessionWindowSeconds = 5 * 60 * 60
claudeUsageWeekWindowSeconds = 7 * 24 * 60 * 60
claudeUsagePollInterval = 100 * time.Millisecond
claudeUsageStableFor = 450 * time.Millisecond
claudeUsageActionGap = 250 * time.Millisecond
)
var (
claudeUsagePercentRe = regexp.MustCompile(`(?i)\b(\d{1,3})\s*%\s*used\b`)
claudeUsageResetLineRe = regexp.MustCompile(`(?i)^resets\s+(.+?)\s*$`)
claudeUsageParenTZRe = regexp.MustCompile(`^(.*?)\s*\(([^()]+)\)\s*$`)
claudeUsageWhitespaceRe = regexp.MustCompile(`[ \t]+`)
claudeUsageRuleLineRe = regexp.MustCompile(`^[\p{Zs}\-─━_=]{4,}$`)
)
type claudeUsageProbeState struct {
promptResponses int
sentWake bool
sentUsage bool
sentEnterRetry bool
sentUsageRetry bool
lastActionAt time.Time
usageSentAt time.Time
}
func (a *Agent) GetUsage(ctx context.Context) (*core.UsageReport, error) {
if _, err := exec.LookPath("claude"); err != nil {
return nil, fmt.Errorf("claudecode: 'claude' CLI not found in PATH")
}
screen, err := a.runClaudeUsageProbe(ctx)
if err != nil {
return nil, err
}
return parseClaudeUsageReport(screen, time.Now())
}
func (a *Agent) runClaudeUsageProbe(ctx context.Context) (string, error) {
probeCtx, cancel := context.WithCancel(ctx)
defer cancel()
workDir, err := os.MkdirTemp("", "cc-connect-claude-usage-*")
if err != nil {
return "", fmt.Errorf("claudecode: create usage temp dir: %w", err)
}
defer os.RemoveAll(workDir)
args := []string{
"--tools", "",
"--permission-mode", "plan",
"--no-chrome",
}
cmd := exec.CommandContext(probeCtx, "claude", args...)
cmd.Dir = workDir
env := filterEnv(os.Environ(), "CLAUDECODE")
env = append(env, "DISABLE_TELEMETRY=true")
env = append(env, "DISABLE_COST_WARNINGS=true")
if extra := a.usageProbeEnv(); len(extra) > 0 {
env = core.MergeEnv(env, extra)
}
cmd.Env = env
var stderr bytes.Buffer
cmd.Stderr = &stderr
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120})
if err != nil {
return "", fmt.Errorf("claudecode: start claude usage probe: %w", err)
}
var waitErr error
processDone := make(chan struct{})
go func() {
waitErr = cmd.Wait()
close(processDone)
}()
terminal := newClaudeUsageTerminal()
readDone := make(chan error, 1)
go func() {
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if n > 0 {
terminal.Write(buf[:n])
}
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) {
readDone <- nil
} else {
readDone <- err
}
return
}
}
}()
defer func() {
if err := ptmx.Close(); err != nil {
slog.Warn("claudeSession: close ptmx", "error", err)
}
cancel()
// Wait for reader goroutine to finish so it is never leaked.
<-readDone
select {
case <-processDone:
case <-time.After(2 * time.Second):
if cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil {
slog.Warn("claudeSession: kill process", "error", err)
}
}
<-processDone
}
}()
ticker := time.NewTicker(claudeUsagePollInterval)
defer ticker.Stop()
var (
state claudeUsageProbeState
lastScreen string
lastChange = time.Now()
usageScreen string
)
for {
select {
case <-probeCtx.Done():
if usageScreen != "" {
return usageScreen, nil
}
if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil {
return "", screenErr
}
return "", fmt.Errorf("claudecode: timed out waiting for Claude Code /usage panel: %w", probeCtx.Err())
case err := <-readDone:
if err != nil {
return "", fmt.Errorf("claudecode: read Claude Code /usage output: %w", err)
}
case <-processDone:
if usageScreen != "" {
return usageScreen, nil
}
if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil {
return "", screenErr
}
if waitErr != nil {
return "", fmt.Errorf("claudecode: Claude Code exited before /usage rendered: %w", waitErr)
}
return "", fmt.Errorf("claudecode: Claude Code exited before /usage rendered")
case <-ticker.C:
screen := normalizeClaudeUsageText(terminal.String())
if screen != lastScreen {
lastScreen = screen
lastChange = time.Now()
}
if usageReady(lastScreen) {
if usageScreen == "" {
usageScreen = lastScreen
}
}
if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil {
return "", screenErr
}
if usageScreen != "" && time.Since(lastChange) >= claudeUsageStableFor {
return usageScreen, nil
}
if action := nextClaudeUsageProbeAction(lastScreen, &state, time.Now()); action != "" {
if _, err := io.WriteString(ptmx, action); err != nil {
return "", fmt.Errorf("claudecode: write Claude Code /usage probe input: %w", err)
}
}
}
}
}
func (a *Agent) usageProbeEnv() []string {
a.mu.Lock()
defer a.mu.Unlock()
return a.runtimeEnvLocked()
}
func nextClaudeUsageProbeAction(screen string, state *claudeUsageProbeState, now time.Time) string {
if now.Sub(state.lastActionAt) < claudeUsageActionGap {
return ""
}
if action := promptActionForScreen(screen); action != "" && state.promptResponses < 6 {
state.promptResponses++
state.lastActionAt = now
return action
}
if usageReady(screen) {
return ""
}
if !state.sentWake {
state.sentWake = true
state.lastActionAt = now
return "\r"
}
if !state.sentUsage {
state.sentUsage = true
state.usageSentAt = now
state.lastActionAt = now
return "/usage\r"
}
if !state.sentEnterRetry && now.Sub(state.usageSentAt) >= 900*time.Millisecond {
state.sentEnterRetry = true
state.lastActionAt = now
return "\r"
}
if !state.sentUsageRetry && now.Sub(state.usageSentAt) >= 1500*time.Millisecond {
state.sentUsageRetry = true
state.lastActionAt = now
return "/usage\r"
}
return ""
}
func promptActionForScreen(screen string) string {
lower := strings.ToLower(screen)
if lower == "" {
return ""
}
if strings.Contains(lower, "quick safety check") || strings.Contains(lower, "yes, i trust this folder") {
return "\r"
}
if (strings.Contains(lower, "telemetry") || strings.Contains(lower, "help improve") || strings.Contains(lower, "usage data")) &&
(strings.Contains(lower, "2. no") || strings.Contains(lower, "2. disable") || strings.Contains(lower, "2. don't")) {
return "\x1b[B\r"
}
if strings.Contains(lower, "enter to confirm") && !usageReady(lower) {
return "\r"
}
return ""
}
func usageReady(screen string) bool {
lower := strings.ToLower(screen)
return strings.Contains(lower, "current session") &&
strings.Contains(lower, "current week") &&
strings.Contains(lower, "resets") &&
claudeUsagePercentRe.MatchString(screen)
}
func normalizeClaudeUsageText(raw string) string {
raw = strings.ReplaceAll(raw, "\r", "\n")
lines := strings.Split(raw, "\n")
out := make([]string, 0, len(lines))
lastBlank := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
if !lastBlank {
out = append(out, "")
lastBlank = true
}
continue
}
line = claudeUsageWhitespaceRe.ReplaceAllString(line, " ")
if claudeUsageRuleLineRe.MatchString(line) {
continue
}
out = append(out, line)
lastBlank = false
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func parseClaudeUsageReport(text string, now time.Time) (*core.UsageReport, error) {
text = normalizeClaudeUsageText(text)
if text == "" {
return nil, fmt.Errorf("claudecode: Claude Code /usage produced empty output")
}
if err := detectClaudeUsageOutputError(text, ""); err != nil {
return nil, err
}
report := &core.UsageReport{
Provider: "claudecode",
}
lines := strings.Split(text, "\n")
session, err := parseClaudeUsageWindow(lines, "Current session", claudeUsageSessionWindowSeconds, now)
if err != nil {
return nil, err
}
week, err := parseClaudeUsageWindow(lines, "Current week", claudeUsageWeekWindowSeconds, now)
if err != nil {
return nil, err
}
report.Buckets = []core.UsageBucket{{
Name: "Usage",
Allowed: true,
Windows: []core.UsageWindow{session, week},
}}
return report, nil
}
func parseClaudeUsageWindow(lines []string, header string, windowSeconds int, now time.Time) (core.UsageWindow, error) {
start := -1
headerLower := strings.ToLower(header)
for i, line := range lines {
lower := strings.ToLower(strings.TrimSpace(line))
if strings.HasPrefix(lower, headerLower) {
start = i
break
}
}
if start < 0 {
return core.UsageWindow{}, fmt.Errorf("claudecode: missing %s block in Claude Code /usage output", header)
}
var (
usedPercent *int
resetRaw string
)
for i := start + 1; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
lower := strings.ToLower(line)
if i > start+1 && (strings.HasPrefix(lower, "current ") || strings.HasPrefix(lower, "extra usage")) {
break
}
if usedPercent == nil {
if m := claudeUsagePercentRe.FindStringSubmatch(line); len(m) == 2 {
v, _ := strconv.Atoi(m[1])
usedPercent = &v
continue
}
}
if resetRaw == "" {
if m := claudeUsageResetLineRe.FindStringSubmatch(line); len(m) == 2 {
resetRaw = strings.TrimSpace(m[1])
}
}
}
if usedPercent == nil {
return core.UsageWindow{}, fmt.Errorf("claudecode: missing usage percentage in %s block", header)
}
window := core.UsageWindow{
Name: header,
UsedPercent: *usedPercent,
WindowSeconds: windowSeconds,
}
if resetRaw != "" {
resetAt, err := parseClaudeUsageResetTime(resetRaw, now)
if err == nil {
resetAfter := int(resetAt.Sub(now).Round(time.Second).Seconds())
if resetAfter < 0 {
resetAfter = 0
}
window.ResetAfterSeconds = resetAfter
window.ResetAtUnix = resetAt.Unix()
}
}
return window, nil
}
func parseClaudeUsageResetTime(raw string, now time.Time) (time.Time, error) {
label := strings.TrimSpace(raw)
loc := now.Location()
if m := claudeUsageParenTZRe.FindStringSubmatch(label); len(m) == 3 {
label = strings.TrimSpace(m[1])
tzName := strings.TrimSpace(m[2])
tzLoc, err := time.LoadLocation(tzName)
if err != nil {
return time.Time{}, fmt.Errorf("unknown timezone %q", tzName)
}
loc = tzLoc
}
nowInLoc := now.In(loc)
for _, layout := range []string{"3:04pm", "3pm"} {
parsed, err := time.ParseInLocation(layout, label, loc)
if err != nil {
continue
}
resetAt := time.Date(nowInLoc.Year(), nowInLoc.Month(), nowInLoc.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
if !resetAt.After(nowInLoc) {
resetAt = resetAt.Add(24 * time.Hour)
}
return resetAt, nil
}
for _, layout := range []string{"Jan 2, 3:04pm", "Jan 2, 3pm"} {
parsed, err := time.ParseInLocation(layout, label, loc)
if err != nil {
continue
}
resetAt := time.Date(nowInLoc.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
if !resetAt.After(nowInLoc) {
resetAt = resetAt.AddDate(1, 0, 0)
}
return resetAt, nil
}
return time.Time{}, fmt.Errorf("unsupported reset time %q", raw)
}
func detectClaudeUsageOutputError(screen, stderr string) error {
joined := strings.ToLower(strings.TrimSpace(screen + "\n" + stderr))
switch {
case joined == "":
return nil
case strings.Contains(joined, "unknown command") && strings.Contains(joined, "/usage"):
return fmt.Errorf("claudecode: this Claude Code version does not support /usage; please upgrade Claude Code")
case strings.Contains(joined, "auth login") || strings.Contains(joined, "not logged in") || strings.Contains(joined, "sign in"):
return fmt.Errorf("claudecode: Claude Code is not logged in for /usage; run `claude auth login`")
case strings.Contains(joined, "/usage") && (strings.Contains(joined, "not available") || strings.Contains(joined, "not supported") || strings.Contains(joined, "subscription")):
return fmt.Errorf("claudecode: current Claude account does not support /usage")
default:
return nil
}
}
type claudeUsageTerminal struct {
mu sync.RWMutex
lines [][]rune
row int
col int
}
func newClaudeUsageTerminal() *claudeUsageTerminal {
return &claudeUsageTerminal{
lines: [][]rune{nil},
}
}
func (t *claudeUsageTerminal) Write(p []byte) {
t.mu.Lock()
defer t.mu.Unlock()
for i := 0; i < len(p); {
switch p[i] {
case 0x1b:
next := t.consumeEscape(p[i:])
if next <= 0 {
i++
} else {
i += next
}
case '\r':
t.col = 0
i++
case '\n':
t.row++
t.ensureRow(t.row)
i++
case '\b':
if t.col > 0 {
t.col--
}
i++
case '\t':
advance := 4 - (t.col % 4)
for j := 0; j < advance; j++ {
t.writeRune(' ')
}
i++
default:
if p[i] < 0x20 || p[i] == 0x7f {
i++
continue
}
r, size := utf8.DecodeRune(p[i:])
if r == utf8.RuneError && size == 1 {
i++
continue
}
t.writeRune(r)
i += size
}
}
}
func (t *claudeUsageTerminal) String() string {
t.mu.RLock()
defer t.mu.RUnlock()
lines := make([]string, 0, len(t.lines))
for _, line := range t.lines {
lines = append(lines, strings.TrimRight(string(line), " "))
}
return strings.Join(lines, "\n")
}
func (t *claudeUsageTerminal) consumeEscape(p []byte) int {
if len(p) < 2 {
return len(p)
}
switch p[1] {
case '[':
for i := 2; i < len(p); i++ {
if p[i] >= '@' && p[i] <= '~' {
t.applyCSI(string(p[2:i]), p[i])
return i + 1
}
}
return len(p)
case ']':
for i := 2; i < len(p); i++ {
if p[i] == '\a' {
return i + 1
}
if p[i] == 0x1b && i+1 < len(p) && p[i+1] == '\\' {
return i + 2
}
}
return len(p)
default:
return 2
}
}
func (t *claudeUsageTerminal) applyCSI(params string, final byte) {
switch final {
case 'A', 'B', 'C', 'D':
n := parseCSIInt(params, 1)
switch final {
case 'A':
t.row -= n
if t.row < 0 {
t.row = 0
}
case 'B':
t.row += n
t.ensureRow(t.row)
case 'C':
t.col += n
case 'D':
t.col -= n
if t.col < 0 {
t.col = 0
}
}
case 'H', 'f':
row, col := parseCSICursor(params)
if row < 0 {
row = 0
}
if col < 0 {
col = 0
}
t.row, t.col = row, col
t.ensureRow(t.row)
case 'J':
if params == "2" || params == "" {
t.lines = [][]rune{nil}
t.row, t.col = 0, 0
}
case 'K':
t.ensureRow(t.row)
switch params {
case "", "0":
if t.col < len(t.lines[t.row]) {
t.lines[t.row] = t.lines[t.row][:t.col]
}
case "2":
t.lines[t.row] = nil
t.col = 0
}
default:
}
}
func (t *claudeUsageTerminal) writeRune(r rune) {
if t.row >= maxTerminalRows || t.col >= maxTerminalCols {
return
}
t.ensureCell(t.row, t.col)
if t.row < len(t.lines) && t.col < len(t.lines[t.row]) {
t.lines[t.row][t.col] = r
}
t.col++
}
const maxTerminalRows = 500
const maxTerminalCols = 500
func (t *claudeUsageTerminal) ensureRow(row int) {
if row >= maxTerminalRows {
return
}
for len(t.lines) <= row {
t.lines = append(t.lines, nil)
}
}
func (t *claudeUsageTerminal) ensureCell(row, col int) {
if row >= maxTerminalRows || col >= maxTerminalCols {
return
}
t.ensureRow(row)
for len(t.lines[row]) <= col {
t.lines[row] = append(t.lines[row], ' ')
}
}
func parseCSIInt(raw string, fallback int) int {
raw = strings.TrimPrefix(raw, "?")
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return fallback
}
return n
}
func parseCSICursor(raw string) (int, int) {
raw = strings.TrimPrefix(raw, "?")
if raw == "" {
return 0, 0
}
parts := strings.Split(raw, ";")
row := parseCSIInt(parts[0], 1) - 1
col := 0
if len(parts) > 1 {
col = parseCSIInt(parts[1], 1) - 1
}
return row, col
}
+271
View File
@@ -0,0 +1,271 @@
package claudecode
import (
"context"
"os"
"strings"
"testing"
"time"
)
func TestSanitizeClaudeUsageOutput_RendersCursorMoves(t *testing.T) {
raw := "" +
"\x1b[?2026h\rSettings:\x1b[1CStatus\x1b[1CConfig\x1b[1CUsage (tab to cycle)\r\n" +
"\x1b]8;;https://code.claude.com/docs/en/security\aSecurity guide\x1b]8;;\a\r\n" +
"Current session\r\n" +
"\x1b[32m1% used\x1b[0m\r\n"
terminal := newClaudeUsageTerminal()
terminal.Write([]byte(raw))
got := normalizeClaudeUsageText(terminal.String())
if strings.Contains(got, "\x1b") {
t.Fatalf("sanitizeClaudeUsageOutput still contains escape codes: %q", got)
}
if !strings.Contains(got, "Settings: Status Config Usage (tab to cycle)") {
t.Fatalf("sanitized output missing spaced header: %q", got)
}
if !strings.Contains(got, "Security guide") {
t.Fatalf("sanitized output missing link text: %q", got)
}
}
func TestParseClaudeUsageReport_Success(t *testing.T) {
loc := mustLoadLocation(t, "Asia/Seoul")
now := time.Date(2026, time.December, 22, 12, 0, 0, 0, loc)
text := strings.TrimSpace(`
Current session
1% used
Resets 3:59pm (Asia/Seoul)
Current week (all models)
2% used
Resets Dec 23, 4:59pm (Asia/Seoul)
Extra usage
Extra usage not enabled
`)
report, err := parseClaudeUsageReport(text, now)
if err != nil {
t.Fatalf("parseClaudeUsageReport returned error: %v", err)
}
if report.Provider != "claudecode" {
t.Fatalf("Provider = %q, want claudecode", report.Provider)
}
if len(report.Buckets) != 1 {
t.Fatalf("Buckets = %d, want 1", len(report.Buckets))
}
if len(report.Buckets[0].Windows) != 2 {
t.Fatalf("Windows = %d, want 2", len(report.Buckets[0].Windows))
}
session := report.Buckets[0].Windows[0]
if session.Name != "Current session" {
t.Fatalf("session.Name = %q", session.Name)
}
if session.WindowSeconds != 18000 {
t.Fatalf("session.WindowSeconds = %d, want 18000", session.WindowSeconds)
}
if session.UsedPercent != 1 {
t.Fatalf("session.UsedPercent = %d, want 1", session.UsedPercent)
}
if session.ResetAfterSeconds != 14340 {
t.Fatalf("session.ResetAfterSeconds = %d, want 14340", session.ResetAfterSeconds)
}
week := report.Buckets[0].Windows[1]
if week.Name != "Current week" {
t.Fatalf("week.Name = %q", week.Name)
}
if week.WindowSeconds != 604800 {
t.Fatalf("week.WindowSeconds = %d, want 604800", week.WindowSeconds)
}
if week.UsedPercent != 2 {
t.Fatalf("week.UsedPercent = %d, want 2", week.UsedPercent)
}
if week.ResetAfterSeconds != 104340 {
t.Fatalf("week.ResetAfterSeconds = %d, want 104340", week.ResetAfterSeconds)
}
}
func TestParseClaudeUsageReport_MissingOptionalFields(t *testing.T) {
loc := mustLoadLocation(t, "Asia/Seoul")
now := time.Date(2026, time.December, 22, 12, 0, 0, 0, loc)
text := strings.TrimSpace(`
Current session
15% used
Resets 3:00pm (Asia/Seoul)
Current week
47% used
Resets Dec 23, 11:30am (Asia/Seoul)
`)
report, err := parseClaudeUsageReport(text, now)
if err != nil {
t.Fatalf("parseClaudeUsageReport returned error: %v", err)
}
if report.Email != "" {
t.Fatalf("Email = %q, want empty", report.Email)
}
if report.AccountID != "" {
t.Fatalf("AccountID = %q, want empty", report.AccountID)
}
if report.UserID != "" {
t.Fatalf("UserID = %q, want empty", report.UserID)
}
if report.Plan != "" {
t.Fatalf("Plan = %q, want empty", report.Plan)
}
if len(report.Buckets) != 1 || len(report.Buckets[0].Windows) != 2 {
t.Fatalf("unexpected bucket layout: %+v", report.Buckets)
}
}
func TestParseClaudeUsageReport_UpgradeRequired(t *testing.T) {
text := "Unknown command: /usage"
_, err := parseClaudeUsageReport(text, time.Now())
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "upgrade") {
t.Fatalf("err = %v, want upgrade guidance", err)
}
}
func TestParseClaudeUsageReport_LoginRequired(t *testing.T) {
text := "Please run `claude auth login` to continue."
_, err := parseClaudeUsageReport(text, time.Now())
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "login") {
t.Fatalf("err = %v, want login guidance", err)
}
}
func TestParseClaudeUsageReport_MissingWindowFields(t *testing.T) {
loc := mustLoadLocation(t, "Asia/Seoul")
now := time.Date(2026, time.December, 22, 12, 0, 0, 0, loc)
text := strings.TrimSpace(`
Current session
Resets 3:00pm (Asia/Seoul)
Current week
47% used
Resets Dec 23, 11:30am (Asia/Seoul)
`)
_, err := parseClaudeUsageReport(text, now)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "current session") {
t.Fatalf("err = %v, want current session parse error", err)
}
}
func TestParseClaudeUsageReport_UnknownResetTimeDoesNotFail(t *testing.T) {
loc := mustLoadLocation(t, "America/Los_Angeles")
now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc)
text := strings.TrimSpace(`
Current session
14% used
Resets later today somehow
Current week
33% used
Resets Apr 2, 4pm (America/Los_Angeles)
`)
report, err := parseClaudeUsageReport(text, now)
if err != nil {
t.Fatalf("parseClaudeUsageReport returned error: %v", err)
}
if len(report.Buckets) != 1 || len(report.Buckets[0].Windows) != 2 {
t.Fatalf("unexpected bucket layout: %+v", report.Buckets)
}
session := report.Buckets[0].Windows[0]
if session.UsedPercent != 14 {
t.Fatalf("session.UsedPercent = %d, want 14", session.UsedPercent)
}
if session.ResetAfterSeconds != 0 || session.ResetAtUnix != 0 {
t.Fatalf("session reset should be unknown, got %+v", session)
}
}
func TestParseClaudeUsageReport_MissingResetTimeDoesNotFail(t *testing.T) {
loc := mustLoadLocation(t, "America/Los_Angeles")
now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc)
text := strings.TrimSpace(`
Current session
14% used
Current week
33% used
Resets Apr 2, 4pm (America/Los_Angeles)
`)
report, err := parseClaudeUsageReport(text, now)
if err != nil {
t.Fatalf("parseClaudeUsageReport returned error: %v", err)
}
session := report.Buckets[0].Windows[0]
if session.UsedPercent != 14 {
t.Fatalf("session.UsedPercent = %d, want 14", session.UsedPercent)
}
if session.ResetAfterSeconds != 0 || session.ResetAtUnix != 0 {
t.Fatalf("session reset should be absent, got %+v", session)
}
}
func TestParseClaudeUsageResetTime_AllowsWholeHourWithTimezone(t *testing.T) {
loc := mustLoadLocation(t, "America/Los_Angeles")
now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc)
resetAt, err := parseClaudeUsageResetTime("2pm (America/Los_Angeles)", now)
if err != nil {
t.Fatalf("parseClaudeUsageResetTime returned error: %v", err)
}
want := time.Date(2026, time.March, 30, 14, 0, 0, 0, loc)
if !resetAt.Equal(want) {
t.Fatalf("resetAt = %v, want %v", resetAt, want)
}
}
func TestParseClaudeUsageResetTime_AllowsMonthDayWholeHour(t *testing.T) {
loc := mustLoadLocation(t, "America/Los_Angeles")
now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc)
resetAt, err := parseClaudeUsageResetTime("Apr 2, 4pm (America/Los_Angeles)", now)
if err != nil {
t.Fatalf("parseClaudeUsageResetTime returned error: %v", err)
}
want := time.Date(2026, time.April, 2, 16, 0, 0, 0, loc)
if !resetAt.Equal(want) {
t.Fatalf("resetAt = %v, want %v", resetAt, want)
}
}
func TestAgentGetUsageSmoke(t *testing.T) {
if os.Getenv("CC_CONNECT_SMOKE_CLAUDE_USAGE") == "" {
t.Skip("set CC_CONNECT_SMOKE_CLAUDE_USAGE=1 to run")
}
if _, err := os.Stat("/usr/bin/env"); err != nil {
t.Skipf("environment not suitable: %v", err)
}
a := &Agent{workDir: ".", mode: "plan"}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
report, err := a.GetUsage(ctx)
if err != nil {
t.Fatalf("GetUsage returned error: %v", err)
}
if report == nil || len(report.Buckets) == 0 {
t.Fatalf("unexpected empty report: %+v", report)
}
}
func mustLoadLocation(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%q): %v", name, err)
}
return loc
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
package claudecode
import (
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestConfiguredModels_BoundaryConditions(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{Models: []core.ModelOption{{Name: "first"}}},
{Models: []core.ModelOption{{Name: "second"}}},
},
}
tests := []struct {
name string
activeIdx int
wantNil bool
wantName string
}{
{name: "negative index", activeIdx: -1, wantNil: true},
{name: "out of range", activeIdx: 2, wantNil: true},
{name: "valid index", activeIdx: 1, wantName: "second"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a.activeIdx = tt.activeIdx
got := a.configuredModels()
if tt.wantNil {
if got != nil {
t.Fatalf("configuredModels() = %v, want nil", got)
}
return
}
if len(got) != 1 || got[0].Name != tt.wantName {
t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName)
}
})
}
}
func TestGetModel_PrefersActiveProviderModel(t *testing.T) {
a := &Agent{
model: "sonnet",
providers: []core.ProviderConfig{
{Name: "anthropic", Model: "opus"},
},
activeIdx: 0,
}
if got := a.GetModel(); got != "opus" {
t.Fatalf("GetModel() = %q, want opus", got)
}
}
+752
View File
@@ -0,0 +1,752 @@
package claudecode
import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestNew_ParsesRunAsUserAndRunAsEnv(t *testing.T) {
opts := map[string]any{
"work_dir": "/tmp/claudecode-test",
"run_as_user": "partseeker-coder",
"run_as_env": []any{"PGSSLROOTCERT", "PGSSLMODE"},
}
a, err := New(opts)
if err != nil {
t.Fatalf("New returned error: %v", err)
}
ag, ok := a.(*Agent)
if !ok {
t.Fatalf("agent is not *Agent: %T", a)
}
if ag.spawnOpts.RunAsUser != "partseeker-coder" {
t.Errorf("spawnOpts.RunAsUser = %q, want %q", ag.spawnOpts.RunAsUser, "partseeker-coder")
}
if got := ag.spawnOpts.EnvAllowlist; len(got) != 2 || got[0] != "PGSSLROOTCERT" || got[1] != "PGSSLMODE" {
t.Errorf("spawnOpts.EnvAllowlist = %v, want [PGSSLROOTCERT PGSSLMODE]", got)
}
}
func TestNew_RunAsUserSkipsClaudeLookPath(t *testing.T) {
// With run_as_user set, the supervisor's PATH lookup for "claude" is
// skipped because the target user's PATH is what matters. Verify that
// New() doesn't fail even when claude isn't on this test process's PATH.
opts := map[string]any{
"work_dir": "/tmp/claudecode-test",
"run_as_user": "target-that-definitely-exists",
}
// Note: this test relies on New() NOT calling exec.LookPath("claude")
// when run_as_user is set. If claude IS on PATH in the test env,
// either branch of the code returns success and the test still passes.
if _, err := New(opts); err != nil {
// The only other reason New() could fail for these opts is the
// LookPath check — fail loudly if that's what happened.
t.Errorf("New with run_as_user returned error (LookPath not skipped?): %v", err)
}
_ = core.AgentSystemPrompt // keep the core import used
}
func TestParseUserQuestions_ValidInput(t *testing.T) {
input := map[string]any{
"questions": []any{
map[string]any{
"question": "Which database?",
"header": "Setup",
"multiSelect": false,
"options": []any{
map[string]any{"label": "PostgreSQL", "description": "Production"},
map[string]any{"label": "SQLite", "description": "Dev"},
},
},
},
}
qs := parseUserQuestions(input)
if len(qs) != 1 {
t.Fatalf("expected 1 question, got %d", len(qs))
}
q := qs[0]
if q.Question != "Which database?" {
t.Errorf("question = %q", q.Question)
}
if q.Header != "Setup" {
t.Errorf("header = %q", q.Header)
}
if q.MultiSelect {
t.Error("expected multiSelect=false")
}
if len(q.Options) != 2 {
t.Fatalf("expected 2 options, got %d", len(q.Options))
}
if q.Options[0].Label != "PostgreSQL" {
t.Errorf("option[0].label = %q", q.Options[0].Label)
}
if q.Options[1].Description != "Dev" {
t.Errorf("option[1].description = %q", q.Options[1].Description)
}
}
func TestParseUserQuestions_EmptyInput(t *testing.T) {
qs := parseUserQuestions(map[string]any{})
if len(qs) != 0 {
t.Errorf("expected 0 questions, got %d", len(qs))
}
}
func TestParseUserQuestions_NoQuestionText(t *testing.T) {
input := map[string]any{
"questions": []any{
map[string]any{"header": "Setup"},
},
}
qs := parseUserQuestions(input)
if len(qs) != 0 {
t.Errorf("expected 0 questions (no question text), got %d", len(qs))
}
}
func TestParseUserQuestions_MultiSelect(t *testing.T) {
input := map[string]any{
"questions": []any{
map[string]any{
"question": "Select features",
"multiSelect": true,
"options": []any{
map[string]any{"label": "Auth"},
map[string]any{"label": "Logging"},
},
},
},
}
qs := parseUserQuestions(input)
if len(qs) != 1 {
t.Fatalf("expected 1 question, got %d", len(qs))
}
if !qs[0].MultiSelect {
t.Error("expected multiSelect=true")
}
}
func TestNormalizePermissionMode(t *testing.T) {
tests := []struct {
input string
want string
}{
// dontAsk aliases
{"dontAsk", "dontAsk"},
{"dontask", "dontAsk"},
{"dont-ask", "dontAsk"},
{"dont_ask", "dontAsk"},
// auto
{"auto", "auto"},
// bypassPermissions aliases
{"bypassPermissions", "bypassPermissions"},
{"yolo", "bypassPermissions"},
// acceptEdits aliases
{"acceptEdits", "acceptEdits"},
{"edit", "acceptEdits"},
// plan
{"plan", "plan"},
// default fallback
{"", "default"},
{"unknown", "default"},
}
for _, tt := range tests {
got := normalizePermissionMode(tt.input)
if got != tt.want {
t.Errorf("normalizePermissionMode(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestClaudeSessionSetLiveMode(t *testing.T) {
cs := &claudeSession{}
cs.setPermissionMode("default")
if cs.autoApprove.Load() || cs.acceptEditsOnly.Load() || cs.dontAsk.Load() {
t.Fatal("expected default mode flags to be off")
}
if !cs.SetLiveMode("acceptEdits") {
t.Fatal("SetLiveMode(acceptEdits) = false, want true")
}
if !cs.acceptEditsOnly.Load() || cs.autoApprove.Load() || cs.dontAsk.Load() {
t.Fatal("acceptEdits flags not set correctly")
}
if cs.SetLiveMode("auto") {
t.Fatal("SetLiveMode(auto) = true, want false")
}
cs.SetLiveMode("dontAsk")
if !cs.dontAsk.Load() || cs.autoApprove.Load() || cs.acceptEditsOnly.Load() {
t.Fatal("dontAsk flags not set correctly")
}
cs.SetLiveMode("bypassPermissions")
if !cs.autoApprove.Load() || cs.acceptEditsOnly.Load() || cs.dontAsk.Load() {
t.Fatal("bypassPermissions alias flags not set correctly")
}
}
func TestClaudeSessionSetLiveMode_AutoSessionRequiresRestart(t *testing.T) {
cs := &claudeSession{}
cs.setPermissionMode("auto")
if cs.SetLiveMode("default") {
t.Fatal("SetLiveMode(default) from auto session = true, want false")
}
}
func TestAgent_PermissionModes(t *testing.T) {
a := &Agent{}
modes := a.PermissionModes()
if len(modes) == 0 {
t.Fatal("PermissionModes() returned no modes")
}
foundAuto := false
foundBypass := false
for _, mode := range modes {
if mode.Key == "auto" {
foundAuto = true
}
if mode.Key == "bypassPermissions" {
foundBypass = true
}
}
if !foundAuto {
t.Fatal("PermissionModes() missing auto mode")
}
if !foundBypass {
t.Fatal("PermissionModes() missing bypassPermissions mode")
}
}
func TestIsClaudeEditTool(t *testing.T) {
for _, tool := range []string{"Edit", "Write", "NotebookEdit", "MultiEdit"} {
if !isClaudeEditTool(tool) {
t.Fatalf("isClaudeEditTool(%q) = false, want true", tool)
}
}
if isClaudeEditTool("Bash") {
t.Fatal("isClaudeEditTool(Bash) = true, want false")
}
}
func TestSummarizeInput_AskUserQuestion(t *testing.T) {
input := map[string]any{
"questions": []any{
map[string]any{
"question": "Which framework?",
"options": []any{
map[string]any{"label": "React"},
map[string]any{"label": "Vue"},
},
},
},
}
result := summarizeInput("AskUserQuestion", input)
if result == "" {
t.Error("expected non-empty summary for AskUserQuestion")
}
}
func TestAgent_Name(t *testing.T) {
a := &Agent{}
if got := a.Name(); got != "claudecode" {
t.Errorf("Name() = %q, want %q", got, "claudecode")
}
}
func TestAgent_CLIBinaryName(t *testing.T) {
a := &Agent{cliBin: "claude"}
if got := a.CLIBinaryName(); got != "claude" {
t.Errorf("CLIBinaryName() = %q, want %q", got, "claude")
}
a2 := &Agent{cliBin: "my-cli"}
if got := a2.CLIBinaryName(); got != "my-cli" {
t.Errorf("CLIBinaryName() = %q, want %q", got, "my-cli")
}
}
func TestAgent_CLIDisplayName(t *testing.T) {
a := &Agent{}
if got := a.CLIDisplayName(); got != "Claude" {
t.Errorf("CLIDisplayName() = %q, want %q", got, "Claude")
}
}
func TestAgent_SetWorkDir(t *testing.T) {
a := &Agent{}
a.SetWorkDir("/tmp/test")
if got := a.GetWorkDir(); got != "/tmp/test" {
t.Errorf("GetWorkDir() = %q, want %q", got, "/tmp/test")
}
}
func TestAgent_SetModel(t *testing.T) {
a := &Agent{}
a.SetModel("claude-sonnet-4-20250514")
if got := a.GetModel(); got != "claude-sonnet-4-20250514" {
t.Errorf("GetModel() = %q, want %q", got, "claude-sonnet-4-20250514")
}
}
func TestAgent_SetSessionEnv(t *testing.T) {
a := &Agent{}
a.SetSessionEnv([]string{"KEY=value"})
if len(a.sessionEnv) != 1 || a.sessionEnv[0] != "KEY=value" {
t.Errorf("sessionEnv = %v, want [KEY=value]", a.sessionEnv)
}
}
func TestAgent_SetPlatformPrompt(t *testing.T) {
a := &Agent{}
a.SetPlatformPrompt("You are a helpful assistant on Feishu.")
if a.platformPrompt != "You are a helpful assistant on Feishu." {
t.Errorf("platformPrompt = %q, want %q", a.platformPrompt, "You are a helpful assistant on Feishu.")
}
}
func TestAgent_SetMode(t *testing.T) {
a := &Agent{}
a.SetMode("auto")
if got := a.GetMode(); got != "auto" {
t.Fatalf("GetMode() after SetMode(auto) = %q, want auto", got)
}
a.SetMode("yolo")
if got := a.GetMode(); got != "bypassPermissions" {
t.Fatalf("GetMode() after SetMode(yolo) = %q, want bypassPermissions", got)
}
}
func TestStripXMLTags(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"<tag>content</tag>", "content"},
{"no tags", "no tags"},
{"<a>hello</a><b>world</b>", "helloworld"},
{"<nested><inner>text</inner></nested>", "text"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := stripXMLTags(tt.input)
if got != tt.expected {
t.Errorf("stripXMLTags(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
// verify Agent implements core.Agent
var _ core.Agent = (*Agent)(nil)
func TestEncodeClaudeProjectKey(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "simple ASCII path",
input: "/Users/username/Documents/project",
expected: "-Users-username-Documents-project",
},
{
name: "path with Chinese characters",
input: "/Users/username/Documents/项目文件夹",
expected: "-Users-username-Documents------", // 6 hyphens: 1 for "/" + 5 for Chinese chars
},
{
name: "path with Japanese characters",
input: "/Users/username/Documents/プロジェクト",
expected: "-Users-username-Documents-------", // 6 hyphens: 1 for "/" + 5 for Japanese chars
},
{
name: "path with emoji",
input: "/Users/username/Documents/🎉project",
expected: "-Users-username-Documents--project", // 2 hyphens: 1 for "/" + 1 for emoji
},
{
name: "Windows path with colon",
input: "C:\\Users\\username\\Documents",
expected: "C--Users-username-Documents",
},
{
name: "path with underscore",
input: "/Users/username/my_project",
expected: "-Users-username-my-project",
},
{
name: "path with spaces",
input: "/Users/username/Mobile Documents/my project",
expected: "-Users-username-Mobile-Documents-my-project",
},
{
name: "path with tildes",
input: "/Users/username/com~apple~CloudDocs/project",
expected: "-Users-username-com-apple-CloudDocs-project",
},
{
name: "iCloud path with spaces and tildes",
input: "/Users/username/Library/Mobile Documents/com~apple~CloudDocs/my project",
expected: "-Users-username-Library-Mobile-Documents-com-apple-CloudDocs-my-project",
},
{
name: "mixed ASCII and non-ASCII",
input: "/Users/username/中文folder/english文件夹",
expected: "-Users-username---folder-english---", // "/中文" = 3 hyphens, "/文件夹" = 4 hyphens
},
{
name: "empty path",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := encodeClaudeProjectKey(tt.input)
if got != tt.expected {
t.Errorf("encodeClaudeProjectKey(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestFindProjectDir_NonASCIIPath(t *testing.T) {
// This test verifies that findProjectDir can handle non-ASCII paths
// by creating a mock projects directory structure
homeDir := t.TempDir()
projectsBase := filepath.Join(homeDir, ".claude", "projects")
// Test case: Chinese characters in path
chineseWorkDir := "/Users/test/Documents/项目文件夹"
expectedKey := encodeClaudeProjectKey(chineseWorkDir)
// Create the mock project directory
mockProjectDir := filepath.Join(projectsBase, expectedKey)
if err := os.MkdirAll(mockProjectDir, 0755); err != nil {
t.Fatalf("failed to create mock project dir: %v", err)
}
// Verify findProjectDir finds the directory
found := findProjectDir(homeDir, chineseWorkDir)
if found != mockProjectDir {
t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, chineseWorkDir, found, mockProjectDir)
}
}
func TestFindProjectDir_ASCIIPath(t *testing.T) {
// Verify ASCII paths still work correctly
homeDir := t.TempDir()
projectsBase := filepath.Join(homeDir, ".claude", "projects")
asciiWorkDir := "/Users/test/Documents/project"
expectedKey := encodeClaudeProjectKey(asciiWorkDir)
mockProjectDir := filepath.Join(projectsBase, expectedKey)
if err := os.MkdirAll(mockProjectDir, 0755); err != nil {
t.Fatalf("failed to create mock project dir: %v", err)
}
found := findProjectDir(homeDir, asciiWorkDir)
if found != mockProjectDir {
t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, asciiWorkDir, found, mockProjectDir)
}
}
func TestFindProjectDir_NotFound(t *testing.T) {
homeDir := t.TempDir()
// Don't create any project directories
workDir := "/Users/test/Documents/nonexistent"
found := findProjectDir(homeDir, workDir)
if found != "" {
t.Errorf("findProjectDir for nonexistent project = %q, want empty string", found)
}
}
func TestFindProjectDir_ICloudPath(t *testing.T) {
// Regression for issue #500: paths containing spaces and "~" (common in macOS
// iCloud Drive paths like "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/...")
// must match the on-disk project key that Claude Code CLI generates, which
// collapses both spaces and "~" to "-".
homeDir := t.TempDir()
projectsBase := filepath.Join(homeDir, ".claude", "projects")
iCloudWorkDir := "/Users/test/Library/Mobile Documents/com~apple~CloudDocs/my project"
// The on-disk key Claude Code CLI actually writes (spaces and "~" → "-").
expectedKey := "-Users-test-Library-Mobile-Documents-com-apple-CloudDocs-my-project"
mockProjectDir := filepath.Join(projectsBase, expectedKey)
if err := os.MkdirAll(mockProjectDir, 0755); err != nil {
t.Fatalf("failed to create mock project dir: %v", err)
}
found := findProjectDir(homeDir, iCloudWorkDir)
if found != mockProjectDir {
t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, iCloudWorkDir, found, mockProjectDir)
}
}
func TestSnapshotCLIPath(t *testing.T) {
cases := []struct {
name string
cliBin string
extraArgs []string
want string
}{
{"default-claude-skipped", "claude", nil, ""},
{"empty-binary-skipped", "", nil, ""},
{"custom-binary-only", "/usr/local/bin/claude", nil, "/usr/local/bin/claude"},
{"wrapper-with-args", "my-cli", []string{"code", "-t", "foo"}, "my-cli code -t foo"},
{"claude-with-add-dir", "claude", []string{"--add-dir", "/parent"}, "claude --add-dir /parent"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := snapshotCLIPath(tc.cliBin, tc.extraArgs)
if got != tc.want {
t.Errorf("snapshotCLIPath(%q, %v) = %q, want %q", tc.cliBin, tc.extraArgs, got, tc.want)
}
})
}
}
func TestWorkspaceAgentOptions_FullSnapshot(t *testing.T) {
// Construct an Agent directly so we don't depend on `claude` being on
// PATH. WorkspaceAgentOptions only reads fields that the production
// New() also writes; this just verifies the snapshot shape.
a := &Agent{
cliBin: "my-cli",
cliExtraArgs: []string{"--add-dir", "/parent"},
cliArgsFlag: "-a",
model: "claude-opus-4-7",
reasoningEffort: "high",
mode: "acceptEdits",
allowedTools: []string{"Edit", "Read"},
disallowedTools: []string{"Bash"},
maxContextTokens: 200000,
routerURL: "http://127.0.0.1:3456",
routerAPIKey: "secret",
}
got := a.WorkspaceAgentOptions()
want := map[string]any{
"mode": "acceptEdits",
"cli_path": "my-cli --add-dir /parent",
"cli_args_flag": "-a",
"model": "claude-opus-4-7",
"reasoning_effort": "high",
"allowed_tools": []any{"Edit", "Read"},
"disallowed_tools": []any{"Bash"},
"max_context_tokens": 200000,
"router_url": "http://127.0.0.1:3456",
"router_api_key": "secret",
}
if len(got) != len(want) {
t.Errorf("snapshot len = %d, want %d (got=%v)", len(got), len(want), got)
}
for k, wv := range want {
gv, ok := got[k]
if !ok {
t.Errorf("snapshot missing key %q", k)
continue
}
if !reflect.DeepEqual(gv, wv) {
t.Errorf("snapshot[%q] = %v (%T), want %v (%T)", k, gv, gv, wv, wv)
}
}
}
func TestWorkspaceAgentOptions_OmitsZeroValues(t *testing.T) {
// Default agent (only mode is always emitted, plus default cliBin
// "claude" should be skipped by snapshotCLIPath).
a := &Agent{cliBin: "claude", mode: "default"}
got := a.WorkspaceAgentOptions()
if len(got) != 1 {
t.Errorf("snapshot len = %d, want 1 (got=%v)", len(got), got)
}
if got["mode"] != "default" {
t.Errorf("snapshot[mode] = %v, want %q", got["mode"], "default")
}
for _, k := range []string{
"cli_path", "cli_args_flag", "model", "reasoning_effort",
"allowed_tools", "disallowed_tools", "max_context_tokens",
"router_url", "router_api_key",
} {
if _, ok := got[k]; ok {
t.Errorf("snapshot unexpectedly includes %q = %v", k, got[k])
}
}
}
func TestWorkspaceAgentOptions_RoundTripsThroughNew(t *testing.T) {
// End-to-end: snapshot → New() should reproduce every field. Use
// run_as_user to skip the supervisor-side LookPath check, since the
// fake "my-cli" binary doesn't exist on the test host's PATH.
//
// run_as_user only short-circuits LookPath on platforms where
// SpawnOptions.IsolationMode() can be true — i.e. Unix. On Windows
// it always returns false (see core/runas_windows.go), so the fake
// CLI would fail LookPath and New() would error out before the
// round-trip assertions run.
if runtime.GOOS == "windows" {
t.Skip("run_as_user-based LookPath bypass is Unix-only")
}
parent := &Agent{
cliBin: "my-cli",
cliExtraArgs: []string{"code", "--add-dir", "/parent"},
cliArgsFlag: "-a",
model: "claude-opus-4-7",
reasoningEffort: "high",
mode: "acceptEdits",
allowedTools: []string{"Edit", "Read"},
disallowedTools: []string{"Bash"},
maxContextTokens: 200000,
routerURL: "http://127.0.0.1:3456",
routerAPIKey: "secret",
}
opts := parent.WorkspaceAgentOptions()
opts["work_dir"] = "/tmp/claudecode-test"
opts["run_as_user"] = "skip-lookpath"
a, err := New(opts)
if err != nil {
t.Fatalf("New(snapshot) returned error: %v", err)
}
child := a.(*Agent)
if child.cliBin != "my-cli" {
t.Errorf("cliBin = %q, want %q", child.cliBin, "my-cli")
}
if !reflect.DeepEqual(child.cliExtraArgs, []string{"code", "--add-dir", "/parent"}) {
t.Errorf("cliExtraArgs = %v, want [code --add-dir /parent]", child.cliExtraArgs)
}
if child.cliArgsFlag != "-a" {
t.Errorf("cliArgsFlag = %q, want -a", child.cliArgsFlag)
}
if child.model != "claude-opus-4-7" {
t.Errorf("model = %q, want claude-opus-4-7", child.model)
}
if child.reasoningEffort != "high" {
t.Errorf("reasoningEffort = %q, want high", child.reasoningEffort)
}
if child.mode != "acceptEdits" {
t.Errorf("mode = %q, want acceptEdits", child.mode)
}
if !reflect.DeepEqual(child.allowedTools, []string{"Edit", "Read"}) {
t.Errorf("allowedTools = %v, want [Edit Read]", child.allowedTools)
}
if !reflect.DeepEqual(child.disallowedTools, []string{"Bash"}) {
t.Errorf("disallowedTools = %v, want [Bash]", child.disallowedTools)
}
if child.maxContextTokens != 200000 {
t.Errorf("maxContextTokens = %d, want 200000", child.maxContextTokens)
}
if child.routerURL != "http://127.0.0.1:3456" {
t.Errorf("routerURL = %q, want http://127.0.0.1:3456", child.routerURL)
}
if child.routerAPIKey != "secret" {
t.Errorf("routerAPIKey = %q, want secret", child.routerAPIKey)
}
}
func TestScanSessionMeta_ArrayContent(t *testing.T) {
// Regression test for: scanSessionMeta skips entries where content is a JSON array
// (e.g., assistant messages with thinking blocks, or user messages with tool results).
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.jsonl")
lines := []string{
`{"type": "queue-operation", "operation": "start"}`,
`{"type": "user", "message": {"content": "Hello world"}}`,
`{"type": "assistant", "message": {"content": [{"type": "thinking", "text": ""}, {"type": "text", "text": "Hi there"}]}}`,
`{"type": "user", "message": {"content": [{"tool_use_id": "call_abc", "type": "tool_result", "content": "result data"}]}}`,
`{"type": "assistant", "message": {"content": "Plain text reply"}}`,
`{"type": "last-prompt", "lastPrompt": "test"}`,
}
data := ""
for _, line := range lines {
data += line + "\n"
}
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatalf("write test jsonl: %v", err)
}
summary, count := scanSessionMeta(path)
// Expected: 2 user + 2 assistant = 4 messages
if count != 4 {
t.Errorf("scanSessionMeta count = %d, want 4 (2 user + 2 assistant, array content should not be skipped)", count)
}
// Summary should come from the last user message with string content (line 2)
if summary != "Hello world" {
t.Errorf("scanSessionMeta summary = %q, want %q", summary, "Hello world")
}
}
func TestScanSessionMeta_AllArrayContent(t *testing.T) {
// When all user messages have array content, summary should remain empty
// and count should still be correct.
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.jsonl")
lines := []string{
`{"type": "user", "message": {"content": [{"type": "tool_result", "content": "data"}]}}`,
`{"type": "assistant", "message": {"content": [{"type": "text", "text": "reply"}]}}`,
}
data := ""
for _, line := range lines {
data += line + "\n"
}
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatalf("write test jsonl: %v", err)
}
summary, count := scanSessionMeta(path)
if count != 2 {
t.Errorf("scanSessionMeta count = %d, want 2", count)
}
if summary != "" {
t.Errorf("scanSessionMeta summary = %q, want empty string when no string user content exists", summary)
}
}
func TestExtractStringContent(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{"string", `"hello"`, "hello"},
{"empty", `""`, ""},
{"array", `[{"type": "text"}]`, ""},
{"object", `{"key": "val"}`, ""},
{"null", `null`, ""},
{"number", `42`, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractStringContent([]byte(tt.raw))
if got != tt.want {
t.Errorf("extractStringContent(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
@@ -0,0 +1,53 @@
package claudecode
import (
"path/filepath"
"sync"
"testing"
)
// TestClaudecodeAgent_WorkDirRaceFreeReaders pins the bug where
// ListSessions, DeleteSession, GetSessionHistory, CommandDirs,
// SkillDirs, and ProjectMemoryFile read a.workDir without holding
// a.mu, while SetWorkDir writes a.workDir under the lock. Run with
// -race to detect the data race; with the production fix the
// detector stays quiet.
//
// claudecode is the primary agent and exposes the most readers of
// the workDir field across the project, so the cumulative impact of
// this race is the largest of any agent.
func TestClaudecodeAgent_WorkDirRaceFreeReaders(t *testing.T) {
dir := t.TempDir()
a := &Agent{workDir: dir}
var wg sync.WaitGroup
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if i%2 == 0 {
a.SetWorkDir(filepath.Join(dir, "a"))
} else {
a.SetWorkDir(filepath.Join(dir, "b"))
}
}(i)
}
for i := 0; i < 30; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = a.CommandDirs()
}()
wg.Add(1)
go func() {
defer wg.Done()
_ = a.SkillDirs()
}()
wg.Add(1)
go func() {
defer wg.Done()
_ = a.ProjectMemoryFile()
}()
}
wg.Wait()
}
+48
View File
@@ -0,0 +1,48 @@
//go:build unix
package claudecode
import (
"errors"
"os"
"os/exec"
"syscall"
)
// prepareCmdForKill puts the spawned child into its own process group so that
// the entire descendant tree can be terminated with a single signal aimed at
// the negative PID. Without this, cc-connect can only signal the direct
// child (e.g. the `claude` CLI), leaving any grandchildren (MCP server
// processes such as the Telegram bridge) as orphans that may spin at 100%
// CPU when their parent disappears.
//
// Mirrors the pattern used by agent/codex/proc_unix.go.
func prepareCmdForKill(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
// signalProcessGroup sends sig to the entire process group rooted at cmd.
// Returns nil if the group is already gone.
func signalProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error {
if cmd == nil || cmd.Process == nil {
return nil
}
if err := syscall.Kill(-cmd.Process.Pid, sig); err != nil &&
!errors.Is(err, os.ErrProcessDone) &&
!errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
// forceKillCmd SIGKILLs the entire process group rooted at cmd. Use this
// as the last-resort escalation when graceful shutdown has timed out.
func forceKillCmd(cmd *exec.Cmd) error {
return signalProcessGroup(cmd, syscall.SIGKILL)
}
+113
View File
@@ -0,0 +1,113 @@
//go:build unix
package claudecode
import (
"os/exec"
"syscall"
"testing"
"time"
)
func TestPrepareCmdForKill_SetsSetpgid(t *testing.T) {
cmd := exec.Command("/bin/true")
prepareCmdForKill(cmd)
if cmd.SysProcAttr == nil {
t.Fatal("SysProcAttr is nil after prepareCmdForKill")
}
if !cmd.SysProcAttr.Setpgid {
t.Fatal("Setpgid not set after prepareCmdForKill")
}
}
func TestPrepareCmdForKill_PreservesExistingSysProcAttr(t *testing.T) {
cmd := exec.Command("/bin/true")
cmd.SysProcAttr = &syscall.SysProcAttr{Foreground: false}
prepareCmdForKill(cmd)
if !cmd.SysProcAttr.Setpgid {
t.Fatal("Setpgid not set when SysProcAttr was pre-populated")
}
}
func TestPrepareCmdForKill_NilCmd(t *testing.T) {
// Must not panic on a nil *exec.Cmd.
prepareCmdForKill(nil)
}
func TestForceKillCmd_NoProcess(t *testing.T) {
cmd := exec.Command("/bin/true")
// cmd has not been Start()ed, so cmd.Process is nil.
if err := forceKillCmd(cmd); err != nil {
t.Errorf("expected no error on un-started cmd, got %v", err)
}
}
func TestForceKillCmd_NilCmd(t *testing.T) {
if err := forceKillCmd(nil); err != nil {
t.Errorf("expected no error on nil cmd, got %v", err)
}
}
// TestForceKillCmd_KillsGrandchild is the regression test for the original
// bug: spawning a shell that backgrounds a long-running grandchild, then
// proving that forceKillCmd reaps the grandchild along with the direct
// child via process-group kill. Without prepareCmdForKill setting up the
// process group, the grandchild would survive and spin.
func TestForceKillCmd_KillsGrandchild(t *testing.T) {
// /bin/sh -c 'sleep 60 & echo $! ; wait'
// The grandchild PID is printed on stdout so we can verify it is reaped.
cmd := exec.Command("/bin/sh", "-c", "sleep 60 & echo $! ; wait")
prepareCmdForKill(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
// Read the grandchild PID.
buf := make([]byte, 32)
deadline := time.Now().Add(2 * time.Second)
var grandchildPidStr string
for time.Now().Before(deadline) {
n, _ := stdout.Read(buf)
if n > 0 {
grandchildPidStr = string(buf[:n])
break
}
time.Sleep(10 * time.Millisecond)
}
if grandchildPidStr == "" {
_ = forceKillCmd(cmd)
_ = cmd.Wait()
t.Fatal("did not receive grandchild PID")
}
if err := forceKillCmd(cmd); err != nil {
t.Fatalf("forceKillCmd: %v", err)
}
_ = cmd.Wait()
// Verify the grandchild is gone by checking that signaling it with 0
// (no-op, just checks existence) returns ESRCH within a short window.
// We can't easily parse the PID without strconv import bloat in tests,
// so we rely on `pgrep` semantics: re-kill the group should be a no-op.
if err := forceKillCmd(cmd); err != nil {
t.Errorf("second forceKillCmd should be no-op, got %v", err)
}
}
func TestSignalProcessGroup_NoProcess(t *testing.T) {
cmd := exec.Command("/bin/true")
if err := signalProcessGroup(cmd, syscall.SIGTERM); err != nil {
t.Errorf("expected no error on un-started cmd, got %v", err)
}
}
func TestSignalProcessGroup_NilCmd(t *testing.T) {
if err := signalProcessGroup(nil, syscall.SIGTERM); err != nil {
t.Errorf("expected no error on nil cmd, got %v", err)
}
}
+85
View File
@@ -0,0 +1,85 @@
//go:build windows
package claudecode
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
)
// prepareCmdForKill puts the spawned child into a new process group on
// Windows so that taskkill /T can later terminate the entire descendant
// tree. Without this, cc-connect can only signal the direct child,
// leaving grandchildren (such as MCP server bridges) as orphans.
//
// Mirrors the pattern used by agent/codex/proc_windows.go.
func prepareCmdForKill(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP
}
// signalProcessGroup is a graceful best-effort equivalent of forceKillCmd
// on Windows: taskkill without /F asks the target to close cleanly. Falls
// back to cmd.Process.Signal if taskkill is unavailable.
func signalProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error {
if cmd == nil || cmd.Process == nil {
return nil
}
killCmd := exec.Command("taskkill", "/T", "/PID", strconv.Itoa(cmd.Process.Pid))
output, err := killCmd.CombinedOutput()
if err == nil {
return nil
}
if isTaskkillNotRunning(output) {
return nil
}
if killErr := cmd.Process.Signal(sig); killErr == nil || errors.Is(killErr, os.ErrProcessDone) {
return nil
}
return fmt.Errorf("taskkill failed: %w: %s", err, processKillOutput(output))
}
// forceKillCmd taskkill /T /F's the entire descendant tree rooted at cmd.
func forceKillCmd(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
killCmd := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid))
output, err := killCmd.CombinedOutput()
if err == nil {
return nil
}
if isTaskkillNotRunning(output) {
return nil
}
if killErr := cmd.Process.Kill(); killErr == nil || errors.Is(killErr, os.ErrProcessDone) {
return nil
} else {
return fmt.Errorf("taskkill failed: %w: %s; process kill fallback failed: %w", err, processKillOutput(output), killErr)
}
}
func isTaskkillNotRunning(output []byte) bool {
lower := bytes.ToLower(output)
return bytes.Contains(lower, []byte("there is no running instance")) ||
bytes.Contains(lower, []byte("not found"))
}
func processKillOutput(output []byte) string {
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return "(empty output)"
}
return trimmed
}
+135
View File
@@ -0,0 +1,135 @@
package claudecode
import (
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestNew_ParsesProjectEnvFromOpts(t *testing.T) {
opts := map[string]any{
"work_dir": "/tmp/test",
"run_as_user": "skip-lookpath",
"env": map[string]string{
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding",
"ANTHROPIC_AUTH_TOKEN": "sk-kimi-test",
"ANTHROPIC_MODEL": "K2.6",
"ANTHROPIC_REASONING_MODEL": "K2.6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "K2.6",
},
}
a, err := New(opts)
if err != nil {
t.Fatalf("New() error: %v", err)
}
agent := a.(*Agent)
agent.mu.Lock()
defer agent.mu.Unlock()
if len(agent.configEnv) != 5 {
t.Fatalf("expected 5 env vars, got %d: %v", len(agent.configEnv), agent.configEnv)
}
envMap := envSliceToMap(agent.configEnv)
if got := envMap["ANTHROPIC_BASE_URL"]; got != "https://api.kimi.com/coding" {
t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, "https://api.kimi.com/coding")
}
if got := envMap["ANTHROPIC_AUTH_TOKEN"]; got != "sk-kimi-test" {
t.Errorf("ANTHROPIC_AUTH_TOKEN = %q, want %q", got, "sk-kimi-test")
}
if got := envMap["ANTHROPIC_MODEL"]; got != "K2.6" {
t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, "K2.6")
}
}
func TestNew_ParsesProjectEnvFromMapStringAny(t *testing.T) {
opts := map[string]any{
"work_dir": "/tmp/test",
"run_as_user": "test-user",
"env": map[string]any{
"ANTHROPIC_BASE_URL": "https://api.mimo.com/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-mimo-test",
"ANTHROPIC_MODEL": "mimo-large",
},
}
a, err := New(opts)
if err != nil {
t.Fatalf("New() error: %v", err)
}
agent := a.(*Agent)
agent.mu.Lock()
defer agent.mu.Unlock()
envMap := envSliceToMap(agent.configEnv)
if got := envMap["ANTHROPIC_BASE_URL"]; got != "https://api.mimo.com/v1" {
t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, "https://api.mimo.com/v1")
}
if got := envMap["ANTHROPIC_MODEL"]; got != "mimo-large" {
t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, "mimo-large")
}
}
func TestNew_NoEnvOpts(t *testing.T) {
opts := map[string]any{
"work_dir": "/tmp/test",
"run_as_user": "test-user",
}
a, err := New(opts)
if err != nil {
t.Fatalf("New() error: %v", err)
}
agent := a.(*Agent)
agent.mu.Lock()
defer agent.mu.Unlock()
if len(agent.configEnv) != 0 {
t.Fatalf("expected 0 env vars when no env in opts, got %d: %v", len(agent.configEnv), agent.configEnv)
}
}
func TestNew_ProjectEnvOverridesProviderEnv(t *testing.T) {
opts := map[string]any{
"work_dir": "/tmp/test",
"run_as_user": "test-user",
"env": map[string]string{
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-deepseek-test",
"ANTHROPIC_MODEL": "deepseek-chat",
},
}
a, err := New(opts)
if err != nil {
t.Fatalf("New() error: %v", err)
}
agent := a.(*Agent)
// Set providers to simulate a provider being configured
agent.providers = []core.ProviderConfig{
{
Name: "deepseek",
BaseURL: "https://api.deepseek.com/v1",
APIKey: "sk-deepseek-test",
Model: "deepseek-chat",
},
}
agent.activeIdx = 0
// runtimeEnvLocked merges configEnv + providerEnv + sessionEnv
// configEnv (from opts["env"]) should be present
env := agent.runtimeEnvLocked()
envMap := envSliceToMap(env)
if got := envMap["ANTHROPIC_BASE_URL"]; got != "https://api.deepseek.com/v1" {
t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, "https://api.deepseek.com/v1")
}
if got := envMap["ANTHROPIC_MODEL"]; got != "deepseek-chat" {
t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, "deepseek-chat")
}
}
+338
View File
@@ -0,0 +1,338 @@
package claudecode
import (
"strings"
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestAgentUsageProbeEnv_AddsHostManagedFlagForCustomProvider(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "custom",
BaseURL: "https://example.com/v1",
APIKey: "secret",
},
},
activeIdx: 0,
}
env := envSliceToMap(a.usageProbeEnv())
if got := env["ANTHROPIC_BASE_URL"]; got != "https://example.com/v1" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want custom base URL", got)
}
if got := env["ANTHROPIC_AUTH_TOKEN"]; got != "secret" {
t.Fatalf("ANTHROPIC_AUTH_TOKEN = %q, want injected bearer token", got)
}
if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" {
t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got)
}
}
func TestAgentUsageProbeEnv_DoesNotAddHostManagedFlagForModelOnlyProvider(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "model-only",
Model: "claude-sonnet-4",
},
},
activeIdx: 0,
}
env := envSliceToMap(a.usageProbeEnv())
if _, ok := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; ok {
t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST unexpectedly set: %v", env)
}
}
func TestAgentUsageProbeEnv_AddsHostManagedFlagForProviderEnvRoutingOverrides(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "bedrock",
Env: map[string]string{
"CLAUDE_CODE_USE_BEDROCK": "1",
},
},
},
activeIdx: 0,
}
env := envSliceToMap(a.usageProbeEnv())
if got := env["CLAUDE_CODE_USE_BEDROCK"]; got != "1" {
t.Fatalf("CLAUDE_CODE_USE_BEDROCK = %q, want 1", got)
}
if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" {
t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got)
}
}
func TestAgentUsageProbeEnv_AddsHostManagedFlagForSessionEnvRoutingOverrides(t *testing.T) {
a := &Agent{
sessionEnv: []string{
"ANTHROPIC_BASE_URL=https://session.example/v1",
},
}
env := envSliceToMap(a.usageProbeEnv())
if got := env["ANTHROPIC_BASE_URL"]; got != "https://session.example/v1" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want session override", got)
}
if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" {
t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got)
}
}
func TestAgentUsageProbeEnv_AddsHostManagedFlagForRouterOverrides(t *testing.T) {
a := &Agent{
routerURL: "http://127.0.0.1:3456",
routerAPIKey: "router-secret",
}
env := envSliceToMap(a.usageProbeEnv())
if got := env["ANTHROPIC_BASE_URL"]; got != "http://127.0.0.1:3456" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want router URL", got)
}
if got := env["ANTHROPIC_API_KEY"]; got != "router-secret" {
t.Fatalf("ANTHROPIC_API_KEY = %q, want router API key", got)
}
if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" {
t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got)
}
}
func TestProviderEnv_SetsAnthropicModel(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "provider-a",
BaseURL: "https://a.example.com/v1",
APIKey: "key-a",
Model: "model-a",
},
{
Name: "provider-b",
BaseURL: "https://b.example.com/v1",
APIKey: "key-b",
Model: "model-b",
},
},
activeIdx: 0,
}
env := envSliceToMap(a.providerEnvLocked())
if got := env["ANTHROPIC_MODEL"]; got != "model-a" {
t.Fatalf("ANTHROPIC_MODEL = %q, want %q", got, "model-a")
}
if got := env["ANTHROPIC_BASE_URL"]; got != "https://a.example.com/v1" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want provider-a URL", got)
}
a.SetActiveProvider("provider-b")
env = envSliceToMap(a.providerEnvLocked())
if got := env["ANTHROPIC_MODEL"]; got != "model-b" {
t.Fatalf("after switch: ANTHROPIC_MODEL = %q, want %q", got, "model-b")
}
if got := env["ANTHROPIC_BASE_URL"]; got != "https://b.example.com/v1" {
t.Fatalf("after switch: ANTHROPIC_BASE_URL = %q, want provider-b URL", got)
}
}
func TestProviderEnv_NoModelWhenEmpty(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "no-model",
BaseURL: "https://example.com/v1",
APIKey: "key",
},
},
activeIdx: 0,
}
env := envSliceToMap(a.providerEnvLocked())
if _, ok := env["ANTHROPIC_MODEL"]; ok {
t.Fatalf("ANTHROPIC_MODEL should not be set when provider has no model")
}
}
func TestProviderEnv_ClearReturnsNil(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{Name: "p", BaseURL: "https://x.com", APIKey: "k", Model: "m"},
},
activeIdx: 0,
}
a.SetActiveProvider("")
env := a.providerEnvLocked()
if env != nil {
t.Fatalf("expected nil env after clearing provider, got %v", env)
}
}
func TestStartSession_UsesActiveProviderModel(t *testing.T) {
a := &Agent{
model: "default-model",
providers: []core.ProviderConfig{
{Name: "p1", Model: "provider-model-1"},
{Name: "p2", Model: "provider-model-2"},
},
activeIdx: 0,
}
a.mu.Lock()
activeIdx := a.activeIdx
model := a.model
if activeIdx >= 0 && activeIdx < len(a.providers) {
if m := a.providers[activeIdx].Model; m != "" {
model = m
}
}
a.mu.Unlock()
if model != "provider-model-1" {
t.Fatalf("model = %q, want %q", model, "provider-model-1")
}
a.SetActiveProvider("p2")
a.mu.Lock()
activeIdx = a.activeIdx
model = a.model
if activeIdx >= 0 && activeIdx < len(a.providers) {
if m := a.providers[activeIdx].Model; m != "" {
model = m
}
}
a.mu.Unlock()
if model != "provider-model-2" {
t.Fatalf("after switch: model = %q, want %q", model, "provider-model-2")
}
}
func envSliceToMap(env []string) map[string]string {
out := make(map[string]string, len(env))
for _, entry := range env {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
out[key] = value
}
return out
}
func TestProviderEnv_BedrockThinkingRewrite(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "bedrock",
Env: map[string]string{
"CLAUDE_CODE_USE_BEDROCK": "1",
"AWS_PROFILE": "bedrock",
},
Thinking: "disabled",
},
},
activeIdx: 0,
}
env := envSliceToMap(a.providerEnvLocked())
// Should set ANTHROPIC_BEDROCK_BASE_URL to local proxy URL.
baseURL := env["ANTHROPIC_BEDROCK_BASE_URL"]
if baseURL == "" {
t.Fatalf("ANTHROPIC_BEDROCK_BASE_URL should be set for Bedrock with thinking rewrite")
}
if !strings.HasPrefix(baseURL, "http://127.0.0.1:") {
t.Fatalf("ANTHROPIC_BEDROCK_BASE_URL = %q, want local proxy URL", baseURL)
}
// Should preserve Bedrock env vars.
if got := env["CLAUDE_CODE_USE_BEDROCK"]; got != "1" {
t.Fatalf("CLAUDE_CODE_USE_BEDROCK = %q, want 1", got)
}
// Should set NO_PROXY for local proxy.
if got := env["NO_PROXY"]; got != "127.0.0.1" {
t.Fatalf("NO_PROXY = %q, want 127.0.0.1", got)
}
}
func TestProviderEnv_VertexThinkingRewrite(t *testing.T) {
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "vertex",
Env: map[string]string{
"CLAUDE_CODE_USE_VERTEX": "1",
"CLOUD_ML_REGION": "us-east1",
},
Thinking: "disabled",
},
},
activeIdx: 0,
}
env := envSliceToMap(a.providerEnvLocked())
// Should set ANTHROPIC_VERTEX_BASE_URL to local proxy URL.
baseURL := env["ANTHROPIC_VERTEX_BASE_URL"]
if baseURL == "" {
t.Fatalf("ANTHROPIC_VERTEX_BASE_URL should be set for Vertex with thinking rewrite")
}
if !strings.HasPrefix(baseURL, "http://127.0.0.1:") {
t.Fatalf("ANTHROPIC_VERTEX_BASE_URL = %q, want local proxy URL", baseURL)
}
}
func TestProviderEnv_BedrockNoThinking(t *testing.T) {
// Without thinking override, Bedrock provider should not use proxy.
a := &Agent{
providers: []core.ProviderConfig{
{
Name: "bedrock",
Env: map[string]string{
"CLAUDE_CODE_USE_BEDROCK": "1",
},
},
},
activeIdx: 0,
}
env := envSliceToMap(a.providerEnvLocked())
// Should NOT set ANTHROPIC_BEDROCK_BASE_URL when thinking is not set.
if _, ok := env["ANTHROPIC_BEDROCK_BASE_URL"]; ok {
t.Fatalf("ANTHROPIC_BEDROCK_BASE_URL should not be set without thinking override")
}
// Should preserve Bedrock env var.
if got := env["CLAUDE_CODE_USE_BEDROCK"]; got != "1" {
t.Fatalf("CLAUDE_CODE_USE_BEDROCK = %q, want 1", got)
}
}
func TestDetectEnvOnlyProviderType(t *testing.T) {
tests := []struct {
env map[string]string
expected string
}{
{map[string]string{"CLAUDE_CODE_USE_BEDROCK": "1"}, "bedrock"},
{map[string]string{"CLAUDE_CODE_USE_VERTEX": "1"}, "vertex"},
{map[string]string{"CLAUDE_CODE_USE_FOUNDRY": "1"}, "foundry"},
{map[string]string{"CLAUDE_CODE_USE_BEDROCK": "0"}, ""},
{map[string]string{"OTHER_VAR": "1"}, ""},
{nil, ""},
}
for _, tt := range tests {
got := detectEnvOnlyProviderType(tt.env)
if got != tt.expected {
t.Errorf("detectEnvOnlyProviderType(%v) = %q, want %q", tt.env, got, tt.expected)
}
}
}
@@ -0,0 +1,288 @@
package claudecode
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/config"
"github.com/chenhg5/cc-connect/core"
)
// These integration tests use real provider credentials from ~/.cc-connect/config.toml.
// They verify that provider switching correctly sets up env vars and that agent
// sessions can be started with the right configuration.
//
// Run with: CC_RUN_PROVIDER_INTEGRATION=1 go test ./agent/claudecode -run TestIntegration -v
// Skip explicitly with: CC_SKIP_INTEGRATION=1
func skipIfNoConfig(t *testing.T) *config.Config {
t.Helper()
if os.Getenv("CC_SKIP_INTEGRATION") == "1" {
t.Skip("CC_SKIP_INTEGRATION=1")
}
if os.Getenv("CC_RUN_PROVIDER_INTEGRATION") != "1" {
t.Skip("set CC_RUN_PROVIDER_INTEGRATION=1 to run provider integration tests")
}
cfgPath := os.ExpandEnv("$HOME/.cc-connect/config.toml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
t.Skipf("config not found at %s", cfgPath)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
cfg.ResolveProviderRefs()
return cfg
}
func configToCoreProv(p config.ProviderConfig) core.ProviderConfig {
cp := core.ProviderConfig{
Name: p.Name,
APIKey: p.APIKey,
BaseURL: p.BaseURL,
Model: p.Model,
Env: p.Env,
}
if p.Thinking != "" {
cp.Thinking = p.Thinking
}
for _, m := range p.Models {
cp.Models = append(cp.Models, core.ModelOption{Name: m.Model})
}
return cp
}
func findProjectProviders(cfg *config.Config, agentType string) (projName string, providers []core.ProviderConfig, workDir string) {
for i := range cfg.Projects {
proj := &cfg.Projects[i]
if proj.Agent.Type != agentType || len(proj.Agent.Providers) == 0 {
continue
}
for _, p := range proj.Agent.Providers {
providers = append(providers, configToCoreProv(p))
}
projName = proj.Name
if wd, ok := proj.Agent.Options["work_dir"].(string); ok {
workDir = wd
}
return
}
return
}
func TestIntegration_ProviderSwitch_EnvVars(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, _ := findProjectProviders(cfg, "claudecode")
if name == "" {
t.Skip("no claudecode project with providers found")
}
if len(providers) < 2 {
t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers))
}
a := &Agent{
providers: providers,
activeIdx: -1,
}
p0, p1 := providers[0], providers[1]
t.Logf("provider[0]: name=%s model=%s base_url=%s", p0.Name, p0.Model, p0.BaseURL)
t.Logf("provider[1]: name=%s model=%s base_url=%s", p1.Name, p1.Model, p1.BaseURL)
a.SetActiveProvider(p0.Name)
env0 := envSliceToMap(a.providerEnvLocked())
if p0.BaseURL != "" {
if got := env0["ANTHROPIC_BASE_URL"]; got != p0.BaseURL {
t.Errorf("provider[0] ANTHROPIC_BASE_URL = %q, want %q", got, p0.BaseURL)
}
}
if p0.Model != "" {
if got := env0["ANTHROPIC_MODEL"]; got != p0.Model {
t.Errorf("provider[0] ANTHROPIC_MODEL = %q, want %q", got, p0.Model)
}
}
a.SetActiveProvider(p1.Name)
env1 := envSliceToMap(a.providerEnvLocked())
if p1.BaseURL != "" {
if got := env1["ANTHROPIC_BASE_URL"]; got != p1.BaseURL {
t.Errorf("provider[1] ANTHROPIC_BASE_URL = %q, want %q", got, p1.BaseURL)
}
}
if p1.Model != "" {
if got := env1["ANTHROPIC_MODEL"]; got != p1.Model {
t.Errorf("provider[1] ANTHROPIC_MODEL = %q, want %q", got, p1.Model)
}
}
if p0.BaseURL != p1.BaseURL {
if env0["ANTHROPIC_BASE_URL"] == env1["ANTHROPIC_BASE_URL"] {
t.Error("providers have different base_urls but env didn't change after switch")
}
}
if p0.Model != p1.Model && p0.Model != "" && p1.Model != "" {
if env0["ANTHROPIC_MODEL"] == env1["ANTHROPIC_MODEL"] {
t.Error("providers have different models but ANTHROPIC_MODEL didn't change after switch")
}
}
}
func TestIntegration_ProviderSwitch_SessionStartModel(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, workDir := findProjectProviders(cfg, "claudecode")
if name == "" {
t.Skip("no claudecode project with providers found")
}
if len(providers) < 2 {
t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers))
}
if workDir == "" {
workDir = "/tmp"
}
cliBin, err := exec.LookPath("claude")
if err != nil {
t.Skipf("claude CLI not found: %v", err)
}
for _, prov := range providers[:2] {
t.Run(prov.Name, func(t *testing.T) {
a := &Agent{
model: "default-model",
providers: providers,
activeIdx: -1,
workDir: workDir,
cliBin: cliBin,
}
a.SetActiveProvider(prov.Name)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sess, err := a.StartSession(ctx, "")
if err != nil {
t.Fatalf("StartSession failed: %v", err)
}
defer sess.Close()
cs := sess.(*claudeSession)
envMap := envSliceToMap(cs.cmd.Env)
if prov.Model != "" {
found := false
for _, arg := range cs.cmd.Args {
if arg == prov.Model {
found = true
break
}
}
if !found {
t.Errorf("--model %q not found in args: %v", prov.Model, cs.cmd.Args)
}
}
if prov.BaseURL != "" {
gotURL := envMap["ANTHROPIC_BASE_URL"]
if gotURL == "" {
t.Error("ANTHROPIC_BASE_URL not set in session env")
} else if !strings.Contains(gotURL, "127.0.0.1") && gotURL != prov.BaseURL {
t.Errorf("ANTHROPIC_BASE_URL = %q, want %q (or proxy URL)", gotURL, prov.BaseURL)
}
}
if prov.Model != "" {
if got := envMap["ANTHROPIC_MODEL"]; got != prov.Model {
t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, prov.Model)
}
}
t.Logf("session started OK: pid=%d, model=%s, env_model=%s, env_base_url=%s",
cs.cmd.Process.Pid,
prov.Model,
envMap["ANTHROPIC_MODEL"],
envMap["ANTHROPIC_BASE_URL"])
})
}
}
func TestIntegration_CodexProvider_EnvVars(t *testing.T) {
cfg := skipIfNoConfig(t)
name, providers, _ := findProjectProviders(cfg, "codex")
if name == "" {
t.Skip("no codex project with providers found")
}
for _, prov := range providers {
t.Run(prov.Name, func(t *testing.T) {
t.Logf("codex provider: name=%s base_url=%s model=%s", prov.Name, prov.BaseURL, prov.Model)
if prov.BaseURL == "" {
t.Error("codex provider should have a base_url")
}
if prov.Model == "" {
t.Error("codex provider should have a model")
}
})
}
}
func TestIntegration_AgentTypeChange_FiltersProviders(t *testing.T) {
cfg := skipIfNoConfig(t)
if len(cfg.Providers) == 0 {
t.Skip("no global providers in config")
}
globalByName := make(map[string]config.ProviderConfig, len(cfg.Providers))
for _, p := range cfg.Providers {
globalByName[p.Name] = p
}
for i := range cfg.Projects {
proj := &cfg.Projects[i]
if proj.Agent.Type != "claudecode" || len(proj.Agent.ProviderRefs) == 0 {
continue
}
var compatible, incompatible []string
for _, ref := range proj.Agent.ProviderRefs {
gp, ok := globalByName[ref]
if !ok {
continue
}
if len(gp.AgentTypes) > 0 {
hasCodex := false
for _, at := range gp.AgentTypes {
if at == "codex" {
hasCodex = true
}
}
if hasCodex {
compatible = append(compatible, ref)
} else {
incompatible = append(incompatible, ref)
}
} else {
compatible = append(compatible, ref)
}
}
if len(incompatible) == 0 {
continue
}
t.Logf("project %q: switching claudecode→codex: keep=%v remove=%v",
proj.Name, compatible, incompatible)
t.Logf("provider filtering validated: %d provider(s) would be removed on agent type change", len(incompatible))
return
}
t.Skip("no project with incompatible providers found for agent type change test")
}
+796
View File
@@ -0,0 +1,796 @@
package claudecode
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/chenhg5/cc-connect/core"
)
// claudeSession manages a long-running Claude Code process using
// --input-format stream-json and --permission-prompt-tool stdio.
//
// In "auto" mode, permission requests are auto-approved internally
// (avoiding --dangerously-skip-permissions which fails under root).
type claudeSession struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdinMu sync.Mutex
events chan core.Event
sessionID atomic.Value // stores string
permissionMode atomic.Value // stores string
autoApprove atomic.Bool
acceptEditsOnly atomic.Bool
dontAsk atomic.Bool
workDir string
ctx context.Context
cancel context.CancelFunc
done chan struct{}
alive atomic.Bool
// gracefulStopTimeout is how long Close() waits for a clean exit
// (stdin close → Stop hooks → process exit) before escalating to
// SIGTERM and then SIGKILL. Default: 120s to match claude-mem's
// Stop hook timeout. The wait ends as soon as the process exits,
// so typical shutdowns take seconds, not the full timeout.
gracefulStopTimeout time.Duration
}
func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs []string, cliArgsFlag string, model, effort, sessionID, mode, systemPrompt string, allowedTools, disallowedTools []string, extraEnv []string, platformPrompt string, disableVerbose bool, spawnOpts core.SpawnOptions, maxContextTokens int) (*claudeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
// innerArgs are Claude Code CLI flags — when a wrapper is used with
// cliArgsFlag these get bundled into a single passthrough string.
// outerArgs are flags the wrapper itself understands (e.g. --model).
innerArgs := []string{
"--output-format", "stream-json",
"--input-format", "stream-json",
"--permission-prompt-tool", "stdio",
"--replay-user-messages",
}
if !disableVerbose {
innerArgs = append(innerArgs, "--verbose")
}
if mode != "" && mode != "default" {
innerArgs = append(innerArgs, "--permission-mode", mode)
}
switch sessionID {
case "", core.ContinueSession:
// Truly fresh session — no resume, no continue.
default:
// Resuming a known session ID — this is cc-connect's own session
// from a previous connection, safe to resume directly.
innerArgs = append(innerArgs, "--resume", sessionID)
}
if len(allowedTools) > 0 {
innerArgs = append(innerArgs, "--allowedTools", strings.Join(allowedTools, ","))
}
if len(disallowedTools) > 0 {
innerArgs = append(innerArgs, "--disallowedTools", strings.Join(disallowedTools, ","))
}
// Handle custom system prompt
if systemPrompt != "" {
innerArgs = append(innerArgs, "--system-prompt", systemPrompt)
}
// Always append cc-connect system prompt for functionality awareness
if sysPrompt := core.AgentSystemPrompt(); sysPrompt != "" {
if platformPrompt != "" {
sysPrompt += "\n## Formatting\n" + platformPrompt + "\n"
}
innerArgs = append(innerArgs, "--append-system-prompt", sysPrompt)
}
if effort != "" {
innerArgs = append(innerArgs, "--effort", effort)
}
if maxContextTokens > 0 {
innerArgs = append(innerArgs, "--max-context-tokens", strconv.Itoa(maxContextTokens))
}
// outerArgs are understood by both the wrapper and Claude CLI directly.
var outerArgs []string
if model != "" {
outerArgs = append(outerArgs, "--model", model)
}
slog.Debug("claudeSession: starting", "innerArgs", core.RedactArgs(innerArgs), "outerArgs", core.RedactArgs(outerArgs), "dir", workDir, "mode", mode, "run_as_user", spawnOpts.RunAsUser)
// Per-spawn defense in depth: if run_as_user is set, re-run the cheap
// preflight (sudo still works + target still can't escalate) right
// before we build the command. This catches sudoers being edited
// between startup preflight and now.
if spawnOpts.IsolationMode() {
verifyCtx, verifyCancel := context.WithTimeout(sessionCtx, 10*time.Second)
err := core.VerifyRunAsUserCheap(verifyCtx, core.ExecSudoRunner{}, spawnOpts.RunAsUser)
verifyCancel()
if err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: run_as_user spawn refused: %w", err)
}
}
// Build final argument list.
// When cliArgsFlag is set (e.g. "-a"), inner args are bundled into a
// single passthrough string via that flag, while outer args (--model etc.)
// are appended directly so the wrapper can also interpret them.
// Args containing spaces/newlines are quoted so the wrapper's command-line
// parser (e.g. splitCommandLine) keeps them as single tokens.
// Result: my-cli code -t foo -a "--verbose --append-system-prompt 'long text'" --model x
var allArgs []string
if cliArgsFlag != "" {
allArgs = append(allArgs, cliExtraArgs...)
allArgs = append(allArgs, cliArgsFlag, shellJoinArgs(innerArgs))
allArgs = append(allArgs, outerArgs...)
} else {
allArgs = append(allArgs, cliExtraArgs...)
allArgs = append(allArgs, innerArgs...)
allArgs = append(allArgs, outerArgs...)
}
cmd := core.BuildSpawnCommand(sessionCtx, spawnOpts, cliBin, allArgs...)
cmd.Dir = workDir
// Put the child into its own process group so Close() can terminate the
// entire descendant tree (claude CLI → MCP server bridges → ...) with a
// single signal. Without this, killing only the direct child can leave
// MCP grandchildren (e.g. the Telegram bridge bun process) spinning at
// 100% CPU after their parent's stdio pipe closes.
prepareCmdForKill(cmd)
// Filter out CLAUDECODE env var to prevent "nested session" detection,
// since cc-connect is a bridge, not a nested Claude Code session.
env := filterEnv(os.Environ(), "CLAUDECODE")
if len(extraEnv) > 0 {
env = core.MergeEnv(env, extraEnv)
}
// When run_as_user is set, strip the supervisor's environment down to
// the allowlist before passing it to sudo. sudo --preserve-env also
// enforces this, but filtering here makes the cc-connect spawn argv
// the single source of truth.
env = core.FilterEnvForSpawn(env, spawnOpts)
cmd.Env = env
var providerEnvSnapshot []string
for _, e := range env {
for _, prefix := range []string{"ANTHROPIC_", "CLAUDE_", "AWS_", "NO_PROXY", "DISABLE_"} {
if strings.HasPrefix(e, prefix) {
providerEnvSnapshot = append(providerEnvSnapshot, e)
break
}
}
}
slog.Debug("claudeSession: spawn details",
"bin", cliBin,
"allArgs", core.RedactArgs(allArgs),
"model", model,
"providerEnv", core.RedactEnv(providerEnvSnapshot))
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: start: %w", err)
}
cs := &claudeSession{
cmd: cmd,
stdin: stdin,
events: make(chan core.Event, 64),
workDir: workDir,
ctx: sessionCtx,
cancel: cancel,
done: make(chan struct{}),
gracefulStopTimeout: 120 * time.Second,
}
cs.setPermissionMode(mode)
cs.sessionID.Store(sessionID)
cs.alive.Store(true)
go cs.readLoop(stdout, &stderrBuf)
return cs, nil
}
func (cs *claudeSession) readLoop(stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
waitErrCh, waitDone := cs.startReadLoopWait(stdout)
defer cs.finishReadLoop(waitErrCh, stderrBuf)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
cs.handleReadLoopLine(scanner.Text())
}
cs.handleReadLoopScanErr(scanner.Err(), waitDone)
}
func (cs *claudeSession) startReadLoopWait(stdout io.ReadCloser) (<-chan error, <-chan struct{}) {
waitErrCh := make(chan error, 1)
waitDone := make(chan struct{})
go func() {
waitErrCh <- cs.cmd.Wait()
close(waitDone)
}()
go func() {
select {
case <-cs.ctx.Done():
_ = stdout.Close()
return
case <-waitDone:
}
// Grace period: give scanner a brief window to drain any data the
// agent wrote to the pipe buffer before exiting. If scanner finishes
// on its own (pipe fully closed, no descendants holding it),
// cs.done fires first and we skip the force-close entirely
select {
case <-cs.done:
return
case <-time.After(50 * time.Millisecond):
}
_ = stdout.Close()
}()
return waitErrCh, waitDone
}
func (cs *claudeSession) finishReadLoop(waitErrCh <-chan error, stderrBuf *bytes.Buffer) {
err := <-waitErrCh
cs.alive.Store(false)
if err != nil {
stderrMsg := ""
if stderrBuf != nil {
stderrMsg = strings.TrimSpace(stderrBuf.String())
}
if stderrMsg != "" {
slog.Error("claudeSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
// INVARIANT: readLoop must close cs.events and cs.done exactly once
// on every termination path. Callers (engine event loop) rely on
// these closures to observe session end.
}
}
}
close(cs.events)
close(cs.done)
}
func (cs *claudeSession) handleReadLoopScanErr(err error, waitDone <-chan struct{}) {
if err == nil {
return
}
select {
case <-cs.ctx.Done():
return
case <-waitDone:
return
default:
}
slog.Error("claudeSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
func (cs *claudeSession) handleReadLoopLine(line string) {
if line == "" {
return
}
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("claudeSession: non-JSON line", "line", line)
return
}
eventType, _ := raw["type"].(string)
slog.Debug("claudeSession: event", "type", eventType)
switch eventType {
case "system":
cs.handleSystem(raw)
case "assistant":
cs.handleAssistant(raw)
case "user":
cs.handleUser(raw)
case "result":
cs.handleResult(raw)
case "control_request":
cs.handleControlRequest(raw)
case "control_cancel_request":
requestID, _ := raw["request_id"].(string)
slog.Debug("claudeSession: permission cancelled", "request_id", requestID)
}
}
func (cs *claudeSession) handleSystem(raw map[string]any) {
if sid, ok := raw["session_id"].(string); ok && sid != "" {
cs.sessionID.Store(sid)
evt := core.Event{Type: core.EventText, SessionID: sid}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func (cs *claudeSession) handleAssistant(raw map[string]any) {
msg, ok := raw["message"].(map[string]any)
if !ok {
return
}
contentArr, ok := msg["content"].([]any)
if !ok {
return
}
for _, contentItem := range contentArr {
item, ok := contentItem.(map[string]any)
if !ok {
continue
}
contentType, _ := item["type"].(string)
switch contentType {
case "tool_use":
toolName, _ := item["name"].(string)
if toolName == "AskUserQuestion" {
continue
}
inputSummary := summarizeInput(toolName, item["input"])
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: inputSummary}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "thinking":
if thinking, ok := item["thinking"].(string); ok && thinking != "" {
evt := core.Event{Type: core.EventThinking, Content: thinking}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
case "text":
if text, ok := item["text"].(string); ok && text != "" {
evt := core.Event{Type: core.EventText, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
}
}
func (cs *claudeSession) handleUser(raw map[string]any) {
msg, ok := raw["message"].(map[string]any)
if !ok {
return
}
contentArr, ok := msg["content"].([]any)
if !ok {
return
}
for _, contentItem := range contentArr {
item, ok := contentItem.(map[string]any)
if !ok {
continue
}
contentType, _ := item["type"].(string)
if contentType == "tool_result" {
isError, _ := item["is_error"].(bool)
if isError {
result, _ := item["content"].(string)
slog.Debug("claudeSession: tool error", "content", result)
}
}
}
}
func (cs *claudeSession) handleResult(raw map[string]any) {
var content string
if result, ok := raw["result"].(string); ok {
content = result
}
if sid, ok := raw["session_id"].(string); ok && sid != "" {
cs.sessionID.Store(sid)
}
var inputTokens, outputTokens int
if usage, ok := raw["usage"].(map[string]any); ok {
if v, ok := usage["input_tokens"].(float64); ok {
inputTokens = int(v)
}
if v, ok := usage["output_tokens"].(float64); ok {
outputTokens = int(v)
}
}
evt := core.Event{
Type: core.EventResult,
Content: content,
SessionID: cs.CurrentSessionID(),
Done: true,
InputTokens: inputTokens,
OutputTokens: outputTokens,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
func (cs *claudeSession) handleControlRequest(raw map[string]any) {
requestID, _ := raw["request_id"].(string)
request, _ := raw["request"].(map[string]any)
if request == nil {
return
}
subtype, _ := request["subtype"].(string)
if subtype != "can_use_tool" {
slog.Debug("claudeSession: unknown control request subtype", "subtype", subtype)
return
}
toolName, _ := request["tool_name"].(string)
input, _ := request["input"].(map[string]any)
if cs.autoApprove.Load() {
slog.Debug("claudeSession: auto-approving", "request_id", requestID, "tool", toolName)
_ = cs.RespondPermission(requestID, core.PermissionResult{
Behavior: "allow",
UpdatedInput: input,
})
return
}
if cs.dontAsk.Load() {
slog.Debug("claudeSession: auto-denying", "request_id", requestID, "tool", toolName)
_ = cs.RespondPermission(requestID, core.PermissionResult{
Behavior: "deny",
Message: "Permission mode is set to dontAsk.",
})
return
}
if cs.acceptEditsOnly.Load() && isClaudeEditTool(toolName) {
slog.Debug("claudeSession: auto-approving edit tool", "request_id", requestID, "tool", toolName)
_ = cs.RespondPermission(requestID, core.PermissionResult{
Behavior: "allow",
UpdatedInput: input,
})
return
}
slog.Info("claudeSession: permission request", "request_id", requestID, "tool", toolName)
evt := core.Event{
Type: core.EventPermissionRequest,
RequestID: requestID,
ToolName: toolName,
ToolInput: summarizeInput(toolName, input),
ToolInputRaw: input,
}
if toolName == "AskUserQuestion" {
evt.Questions = parseUserQuestions(input)
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
// Send writes a user message (with optional images and files) to the Claude process stdin.
// Images are sent as base64 in the multimodal content array.
// Files are saved to local temp files and referenced in the text prompt
// so Claude Code can read them with its built-in tools.
func (cs *claudeSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if !cs.alive.Load() {
return fmt.Errorf("session process is not running")
}
if len(images) == 0 && len(files) == 0 {
return cs.writeJSON(map[string]any{
"type": "user",
"message": map[string]any{"role": "user", "content": prompt},
})
}
attachDir := filepath.Join(cs.workDir, ".cc-connect", "attachments")
if err := os.MkdirAll(attachDir, 0o755); err != nil {
slog.Warn("claudeSession: mkdir attachments failed", "error", err, "path", attachDir)
}
var parts []map[string]any
var savedPaths []string
// Save and encode images
for i, img := range images {
ext := extFromMime(img.MimeType)
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(attachDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
slog.Error("claudeSession: save image failed", "error", err)
continue
}
savedPaths = append(savedPaths, fpath)
slog.Debug("claudeSession: image saved", "path", fpath, "size", len(img.Data))
mimeType := img.MimeType
if mimeType == "" {
mimeType = "image/png"
}
parts = append(parts, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": mimeType,
"data": base64.StdEncoding.EncodeToString(img.Data),
},
})
}
// Save files to disk so Claude Code can read them
filePaths := core.SaveFilesToDisk(cs.workDir, files)
// Build text part: user prompt + file path references
textPart := prompt
if textPart == "" && len(filePaths) > 0 {
textPart = "Please analyze the attached file(s)."
} else if textPart == "" {
textPart = "Please analyze the attached image(s)."
}
if len(savedPaths) > 0 {
textPart += "\n\n(Images also saved locally: " + strings.Join(savedPaths, ", ") + ")"
}
if len(filePaths) > 0 {
textPart += "\n\n(Files saved locally, please read them: " + strings.Join(filePaths, ", ") + ")"
}
parts = append(parts, map[string]any{"type": "text", "text": textPart})
return cs.writeJSON(map[string]any{
"type": "user",
"message": map[string]any{"role": "user", "content": parts},
})
}
func extFromMime(mime string) string {
switch mime {
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".png"
}
}
// RespondPermission writes a control_response to the Claude process stdin.
func (cs *claudeSession) RespondPermission(requestID string, result core.PermissionResult) error {
if !cs.alive.Load() {
return fmt.Errorf("session process is not running")
}
var permResponse map[string]any
if result.Behavior == "allow" {
updatedInput := result.UpdatedInput
if updatedInput == nil {
updatedInput = make(map[string]any)
}
permResponse = map[string]any{
"behavior": "allow",
"updatedInput": updatedInput,
}
} else {
msg := result.Message
if msg == "" {
msg = "The user denied this tool use. Stop and wait for the user's instructions."
}
permResponse = map[string]any{
"behavior": "deny",
"message": msg,
}
}
controlResponse := map[string]any{
"type": "control_response",
"response": map[string]any{
"subtype": "success",
"request_id": requestID,
"response": permResponse,
},
}
slog.Debug("claudeSession: permission response", "request_id", requestID, "behavior", result.Behavior)
return cs.writeJSON(controlResponse)
}
func (cs *claudeSession) writeJSON(v any) error {
cs.stdinMu.Lock()
defer cs.stdinMu.Unlock()
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if _, err := cs.stdin.Write(append(data, '\n')); err != nil {
return fmt.Errorf("write stdin: %w", err)
}
return nil
}
func isClaudeEditTool(toolName string) bool {
switch toolName {
case "Edit", "Write", "NotebookEdit", "MultiEdit":
return true
default:
return false
}
}
func (cs *claudeSession) setPermissionMode(mode string) {
cs.permissionMode.Store(mode)
cs.autoApprove.Store(mode == "bypassPermissions")
cs.acceptEditsOnly.Store(mode == "acceptEdits")
cs.dontAsk.Store(mode == "dontAsk")
}
func (cs *claudeSession) SetLiveMode(mode string) bool {
current, _ := cs.permissionMode.Load().(string)
if mode == "auto" || mode == "plan" || current == "auto" || current == "plan" {
return false
}
cs.setPermissionMode(mode)
return true
}
func (cs *claudeSession) Events() <-chan core.Event {
return cs.events
}
func (cs *claudeSession) CurrentSessionID() string {
v, _ := cs.sessionID.Load().(string)
return v
}
func (cs *claudeSession) Alive() bool {
return cs.alive.Load()
}
func (cs *claudeSession) Close() error {
// Phase 1: Close stdin to signal EOF. Claude Code exits cleanly on
// stdin close, running Stop hooks (e.g. claude-mem session summary).
cs.stdinMu.Lock()
if err := cs.stdin.Close(); err != nil {
slog.Warn("claudeSession: close stdin", "error", err)
}
cs.stdinMu.Unlock()
graceful := cs.gracefulStopTimeout
if graceful <= 0 {
graceful = 8 * time.Second // legacy fallback
}
select {
case <-cs.done:
slog.Info("claudeSession: exited cleanly after stdin close")
return nil
case <-time.After(graceful):
slog.Warn("claudeSession: graceful stop timed out, sending SIGTERM",
"timeout", graceful)
}
// Phase 2: SIGTERM the whole process group — gives the process and its
// descendants (e.g. MCP server bridges) a second chance to run cleanup
// handlers that respond to signals but not stdin EOF.
if err := signalProcessGroup(cs.cmd, syscall.SIGTERM); err != nil {
slog.Warn("claudeSession: signal SIGTERM", "error", err)
}
select {
case <-cs.done:
slog.Info("claudeSession: exited after SIGTERM")
return nil
case <-time.After(5 * time.Second):
slog.Warn("claudeSession: SIGTERM timed out, sending SIGKILL")
}
// Phase 3: SIGKILL the whole process group — last resort. Using a
// group-wide kill ensures grandchildren (Claude Code's MCP servers
// such as the Telegram bridge) are reaped along with the direct child;
// otherwise they can survive as orphans and spin at 100% CPU.
cs.cancel()
if err := forceKillCmd(cs.cmd); err != nil {
slog.Warn("claudeSession: force kill", "error", err)
}
<-cs.done
return nil
}
// shellJoinArgs joins args into a single string, quoting any arg that
// contains whitespace so that a shell-style splitter (like my_cli's
// splitCommandLine) preserves each arg as one token.
//
// Uses single quotes because some splitters (e.g. my_cli) don't support
// backslash escapes inside double quotes. For values containing single
// quotes, we close the single-quoted segment, add an escaped single
// quote, and reopen: 'it'\”s' → it's
func shellJoinArgs(args []string) string {
var b strings.Builder
for i, a := range args {
if i > 0 {
b.WriteByte(' ')
}
if !strings.ContainsAny(a, " \t\n\r'\"\\") {
b.WriteString(a)
continue
}
b.WriteByte('\'')
for _, c := range a {
if c == '\'' {
b.WriteString("'\\''")
} else {
b.WriteRune(c)
}
}
b.WriteByte('\'')
}
return b.String()
}
// filterEnv returns a copy of env with entries matching the given key removed.
func filterEnv(env []string, key string) []string {
prefix := key + "="
out := make([]string, 0, len(env))
for _, e := range env {
if !strings.HasPrefix(e, prefix) {
out = append(out, e)
}
}
return out
}
+285
View File
@@ -0,0 +1,285 @@
package claudecode
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
func TestHandleResultParsesUsage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &claudeSession{
events: make(chan core.Event, 8),
ctx: ctx,
}
cs.sessionID.Store("test-session")
cs.alive.Store(true)
raw := map[string]any{
"type": "result",
"result": "done",
"session_id": "test-session",
"usage": map[string]any{
"input_tokens": float64(150000),
"output_tokens": float64(2000),
},
}
cs.handleResult(raw)
evt := <-cs.events
if evt.InputTokens != 150000 {
t.Errorf("InputTokens = %d, want 150000", evt.InputTokens)
}
if evt.OutputTokens != 2000 {
t.Errorf("OutputTokens = %d, want 2000", evt.OutputTokens)
}
}
func TestHandleResultNoUsage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &claudeSession{
events: make(chan core.Event, 8),
ctx: ctx,
}
cs.sessionID.Store("test-session")
cs.alive.Store(true)
raw := map[string]any{
"type": "result",
"result": "done",
}
cs.handleResult(raw)
evt := <-cs.events
if evt.InputTokens != 0 {
t.Errorf("InputTokens = %d, want 0", evt.InputTokens)
}
if evt.OutputTokens != 0 {
t.Errorf("OutputTokens = %d, want 0", evt.OutputTokens)
}
}
func TestReadLoop_ChildHoldsStdoutPipe(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pr, pw := io.Pipe()
t.Cleanup(func() {
_ = pw.Close()
})
writeDone := make(chan error, 1)
go func() {
_, err := io.WriteString(pw, `{"type":"system","session_id":"test-pipe"}`+"\n")
writeDone <- err
}()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^$")
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
cs := &claudeSession{
cmd: cmd,
events: make(chan core.Event, 64),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
cs.alive.Store(true)
go cs.readLoop(pr, &stderrBuf)
timeout := time.After(5 * time.Second)
gotEvent := false
for {
select {
case err := <-writeDone:
if err != nil {
t.Fatal(err)
}
writeDone = nil
case evt, ok := <-cs.events:
if !ok {
if !gotEvent {
t.Fatal("events closed but system event lost")
}
return
}
if evt.SessionID == "test-pipe" {
gotEvent = true
}
case <-timeout:
t.Fatal("HANG: events not closed within 5s - readLoop stuck in scanner.Scan()")
}
}
}
func TestReadLoop_CtxCancelClosesChannels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pr, pw := io.Pipe()
t.Cleanup(func() {
_ = pw.Close()
})
// "err-then-sleep" emits stderr before sleeping so that ctx cancel
// produces a non-empty stderrBuf in readLoop's defer — exercising the
// `case <-cs.ctx.Done()` select branch in finishReadLoop.
cmd := helperCommand(ctx, "err-then-sleep")
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
cs := &claudeSession{
cmd: cmd,
events: make(chan core.Event, 64),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
cs.alive.Store(true)
go cs.readLoop(pr, &stderrBuf)
time.Sleep(200 * time.Millisecond)
cancel()
timeout := time.After(5 * time.Second)
for {
select {
case _, ok := <-cs.events:
if !ok {
goto closed
}
case <-timeout:
t.Fatal("HANG: events not closed within 5s after ctx cancel")
}
}
closed:
select {
case <-cs.done:
case <-timeout:
t.Fatal("HANG: done not closed within 5s after ctx cancel")
}
}
func TestClaudeSessionClose_IdempotentNoPanic(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := helperCommand(ctx, "stdin-eof-exit")
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() {
_ = cmd.Wait()
close(done)
}()
cs := &claudeSession{
cmd: cmd,
stdin: stdin,
ctx: ctx,
cancel: cancel,
done: done,
gracefulStopTimeout: 200 * time.Millisecond,
}
cs.alive.Store(true)
defer func() {
if r := recover(); r != nil {
t.Errorf("Close panicked: %v", r)
}
}()
if err := cs.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
if err := cs.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
}
func TestShellJoinArgs(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{"empty", nil, ""},
{"single_plain", []string{"--verbose"}, "--verbose"},
{"multiple_plain", []string{"--verbose", "--model", "opus"}, "--verbose --model opus"},
{"arg_with_space", []string{"--prompt", "hello world"}, "--prompt 'hello world'"},
{"arg_with_tab", []string{"a\tb"}, "'a\tb'"},
{"arg_with_newline", []string{"line1\nline2"}, "'line1\nline2'"},
{"arg_with_single_quote", []string{"it's"}, "'it'\\''s'"},
{"arg_with_double_quote", []string{`say "hi"`}, `'say "hi"'`},
{"arg_with_backslash", []string{`path\to`}, `'path\to'`},
{"mixed", []string{"--flag", "has space", "plain", "it's here"}, "--flag 'has space' plain 'it'\\''s here'"},
{"empty_string_arg", []string{""}, ""},
{"long_prompt", []string{"--append-system-prompt", "You are a helpful assistant.\nBe concise."}, "--append-system-prompt 'You are a helpful assistant.\nBe concise.'"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shellJoinArgs(tt.args)
if got != tt.want {
t.Errorf("shellJoinArgs(%v)\n got = %q\n want = %q", tt.args, got, tt.want)
}
})
}
}
func helperCommand(ctx context.Context, mode string) *exec.Cmd {
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestHelperProcess", "--", mode)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
return cmd
}
// TestHelperProcess lets this test binary act as a tiny external command for
// cases that need a process with controlled lifetime semantics.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
mode := os.Args[len(os.Args)-1]
switch mode {
case "sleep":
time.Sleep(30 * time.Second)
os.Exit(0)
case "err-then-sleep":
_, _ = os.Stderr.WriteString("helper: starting up\n")
time.Sleep(30 * time.Second)
os.Exit(0)
case "stdin-eof-exit":
_, _ = io.Copy(io.Discard, os.Stdin)
os.Exit(0)
default:
os.Exit(2)
}
}
+67
View File
@@ -0,0 +1,67 @@
package claudecode
import (
"os"
"path/filepath"
"testing"
)
func TestSkillDirs_UsesClaudeConfigDirAndProjectParents(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "home")
configHome := filepath.Join(tmp, "profile-home")
repo := filepath.Join(tmp, "repo")
workDir := filepath.Join(repo, "nested", "pkg")
t.Setenv("HOME", home)
t.Setenv("CLAUDE_CONFIG_DIR", configHome)
for _, dir := range []string{
filepath.Join(repo, "nested", "pkg"),
filepath.Join(repo, "nested"),
repo,
configHome,
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: fake\n"), 0o644); err != nil {
t.Fatalf("write .git: %v", err)
}
a := &Agent{workDir: workDir}
got := a.SkillDirs()
want := []string{
filepath.Join(workDir, ".claude", "skills"),
filepath.Join(repo, "nested", ".claude", "skills"),
filepath.Join(repo, ".claude", "skills"),
filepath.Join(configHome, "skills"),
}
if len(got) != len(want) {
t.Fatalf("len(SkillDirs()) = %d, want %d\n got=%v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("SkillDirs()[%d] = %q, want %q\nfull=%v", i, got[i], want[i], got)
}
}
}
func TestSkillDirs_FallsBackToHomeClaudeDir(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "home")
workDir := filepath.Join(tmp, "workspace")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
t.Setenv("HOME", home)
t.Setenv("CLAUDE_CONFIG_DIR", "")
a := &Agent{workDir: workDir}
got := a.SkillDirs()
wantLast := filepath.Join(home, ".claude", "skills")
if got[len(got)-1] != wantLast {
t.Fatalf("last SkillDirs() = %q, want %q\nfull=%v", got[len(got)-1], wantLast, got)
}
}