初始化仓库
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user