diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index 75807f36b8..885946fc06 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -65,12 +65,15 @@ func (a *ClaudeACP) Logo(v LogoVariant) []byte { } func (a *ClaudeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { - // ACP agents use npx which auto-installs the package, so we just need npx available - result, err := Detect(ctx, WithCommand("npx")) + // Use the same detection as claude-code: check for ~/.claude.json which is + // created after onboarding. Just having npx is not enough — the user needs + // Claude Code actually set up. + result, err := Detect(ctx, WithFileExists("~/.claude.json")) if err != nil { return result, err } result.SupportsMCP = true + result.InstallationPaths = []string{expandHomePath("~/.claude.json")} result.Capabilities = DiscoveryCapabilities{ SupportsSessionResume: true, } diff --git a/apps/backend/internal/agent/discovery/discovery.go b/apps/backend/internal/agent/discovery/discovery.go index 07db908f7f..b2d85b9dd4 100644 --- a/apps/backend/internal/agent/discovery/discovery.go +++ b/apps/backend/internal/agent/discovery/discovery.go @@ -177,8 +177,14 @@ func (r *Registry) getCached() []Availability { return copied } +const detectAllTimeout = 15 * time.Second + // detectAll runs IsInstalled for all agents concurrently. +// A timeout bounds the overall detection to prevent hanging when agent +// binaries are missing or unresponsive (e.g. fresh K8s deploy). func (r *Registry) detectAll(ctx context.Context) []Availability { + ctx, cancel := context.WithTimeout(ctx, detectAllTimeout) + defer cancel() type indexedResult struct { index int avail Availability @@ -219,20 +225,27 @@ func (r *Registry) detectAll(ctx context.Context) []Availability { } // Collect results preserving original order. + // If the context expires before all agents respond, return partial results. slots := make([]Availability, len(r.agents)) valid := make([]bool, len(r.agents)) for range r.agents { - res := <-ch - if res.err != nil { - r.logger.Warn("discovery: detect failed for agent", - zap.String("agent", r.agents[res.index].ID()), - zap.Error(res.err), - ) - continue + select { + case res := <-ch: + if res.err != nil { + r.logger.Warn("discovery: detect failed for agent", + zap.String("agent", r.agents[res.index].ID()), + zap.Error(res.err), + ) + continue + } + slots[res.index] = res.avail + valid[res.index] = true + case <-ctx.Done(): + r.logger.Warn("discovery: detectAll timed out, returning partial results") + goto collect } - slots[res.index] = res.avail - valid[res.index] = true } +collect: results := make([]Availability, 0, len(r.agents)) for i, v := range valid { diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index fed793ea45..0e85ef7aa0 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -297,6 +297,13 @@ func (c *Controller) httpListRepoBranches(ctx *gin.Context) { repo := ctx.Param("repo") branches, err := c.service.ListRepoBranches(ctx.Request.Context(), owner, repo) if err != nil { + if errors.Is(err, ErrNoClient) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", + "code": "github_not_configured", + }) + return + } status := http.StatusInternalServerError var apiErr *GitHubAPIError if errors.As(err, &apiErr) { diff --git a/apps/backend/internal/github/factory.go b/apps/backend/internal/github/factory.go index 5b4e60f781..8f8eb692c7 100644 --- a/apps/backend/internal/github/factory.go +++ b/apps/backend/internal/github/factory.go @@ -43,19 +43,29 @@ func NewClient(ctx context.Context, secrets SecretProvider, log *logger.Logger) log.Debug("gh CLI available but not authenticated", zap.Error(err)) } + // Fall back to PAT from environment variable + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + log.Info("using GITHUB_TOKEN from environment for GitHub integration") + return NewPATClient(token), AuthMethodPAT, nil + } + if token := os.Getenv("GH_TOKEN"); token != "" { + log.Info("using GH_TOKEN from environment for GitHub integration") + return NewPATClient(token), AuthMethodPAT, nil + } + // Fall back to PAT from secrets store if secrets != nil { token, err := findGitHubPAT(ctx, secrets) if err == nil && token != "" { log.Info("using PAT from secrets store for GitHub integration") - return NewPATClient(token), "pat", nil + return NewPATClient(token), AuthMethodPAT, nil } if err != nil { log.Debug("failed to find GitHub PAT in secrets", zap.Error(err)) } } - return &NoopClient{}, "none", nil + return &NoopClient{}, AuthMethodNone, nil } // findGitHubPAT looks for a secret named "GITHUB_TOKEN" or "github_token". diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index ab7fd282bc..17e5df3ff7 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/url" "os/exec" @@ -34,16 +35,19 @@ func (c *GHClient) IsAuthenticated(ctx context.Context) (bool, error) { if err == nil { return true, nil } - // Propagate context errors so callers can distinguish cancellation/timeout - // from a genuine "not authenticated" result. - if ctx.Err() != nil { - return false, ctx.Err() + // Propagate timeout/cancellation errors so callers can distinguish them + // from a genuine "not authenticated" result. Check the returned error + // (not ctx.Err()) because run() may apply its own child deadline. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false, err } return false, nil } // RunAuthDiagnostics executes gh auth status and captures the raw output for troubleshooting. func (c *GHClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics { + ctx, cancel := withDefaultGHTimeout(ctx) + defer cancel() cmd := exec.CommandContext(ctx, "gh", "auth", "status", "--hostname", "github.com") var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -411,9 +415,22 @@ func (c *GHClient) ListRepoBranches(ctx context.Context, owner, repo string) ([] return branches, nil } +const ghCLITimeout = 30 * time.Second + +// withDefaultGHTimeout applies a 30s timeout if the context has no deadline. +func withDefaultGHTimeout(ctx context.Context) (context.Context, context.CancelFunc) { + if _, ok := ctx.Deadline(); ok { + return ctx, func() {} + } + return context.WithTimeout(ctx, ghCLITimeout) +} + // run executes a gh CLI command and returns its stdout output. // Stderr is captured separately to avoid contaminating JSON output. +// A default 30s timeout is applied if the context has no deadline. func (c *GHClient) run(ctx context.Context, args ...string) (string, error) { + ctx, cancel := withDefaultGHTimeout(ctx) + defer cancel() cmd := exec.CommandContext(ctx, "gh", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index f2b7bd859b..a332246740 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -15,6 +15,12 @@ import ( "github.com/kandev/kandev/internal/events/bus" ) +// Auth method constants. +const ( + AuthMethodNone = "none" + AuthMethodPAT = "pat" +) + // Service coordinates GitHub integration operations. type Service struct { mu sync.Mutex @@ -49,8 +55,11 @@ func (s *Service) TestStore() *Store { } // IsAuthenticated returns whether the service has a working GitHub client. +// Returns false when using the NoopClient fallback (authMethod == "none"). func (s *Service) IsAuthenticated() bool { - return s.client != nil + s.mu.Lock() + defer s.mu.Unlock() + return s.client != nil && s.authMethod != AuthMethodNone } // AuthMethod returns the authentication method ("gh_cli", "pat", or "none"). @@ -59,9 +68,10 @@ func (s *Service) AuthMethod() string { } // GetStatus returns the current GitHub connection status. -// If no client exists, it retries client creation to pick up auth changes made after startup. +// If not authenticated, it retries client creation to pick up auth changes +// (e.g. GITHUB_TOKEN secret added after startup). func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { - if s.client == nil { + if !s.IsAuthenticated() { s.retryClientCreation(ctx) } @@ -93,12 +103,13 @@ func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { return status, nil } -// retryClientCreation attempts to create a GitHub client when none exists. +// retryClientCreation attempts to create a GitHub client when not authenticated. +// This picks up auth changes made after startup (secrets added, env vars set). func (s *Service) retryClientCreation(ctx context.Context) { s.mu.Lock() defer s.mu.Unlock() - if s.client != nil { - return // another goroutine already recovered + if s.authMethod != AuthMethodNone { + return // already authenticated } client, authMethod, err := NewClient(ctx, s.secrets, s.logger) if err != nil { diff --git a/apps/backend/internal/health/checks.go b/apps/backend/internal/health/checks.go index 8b58452158..97c34cf4e5 100644 --- a/apps/backend/internal/health/checks.go +++ b/apps/backend/internal/health/checks.go @@ -1,6 +1,10 @@ package health -import "context" +import ( + "context" + "errors" + "time" +) // GitHubStatusProvider abstracts the GitHub service status check. type GitHubStatusProvider interface { @@ -60,13 +64,29 @@ func NewAgentChecker(provider AgentDiscoveryProvider) *AgentChecker { return &AgentChecker{provider: provider} } +const agentCheckTimeout = 10 * time.Second + func (c *AgentChecker) Check(ctx context.Context) []Issue { if c.provider == nil { return nil } - available, err := c.provider.HasAvailableAgents(ctx) + checkCtx, cancel := context.WithTimeout(ctx, agentCheckTimeout) + defer cancel() + available, err := c.provider.HasAvailableAgents(checkCtx) if err != nil { - return nil + title := "Agent detection failed" + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + title = "Agent detection timed out" + } + return []Issue{{ + ID: "agent_detection_failed", + Category: "agents", + Title: title, + Message: "Could not verify agent installations. Check Settings > Agents for details.", + Severity: SeverityWarning, + FixURL: "/settings/agents", + FixLabel: "Check Agents", + }} } if !available { return []Issue{{ diff --git a/apps/backend/internal/health/checks_test.go b/apps/backend/internal/health/checks_test.go index f92518668d..e0cee4494b 100644 --- a/apps/backend/internal/health/checks_test.go +++ b/apps/backend/internal/health/checks_test.go @@ -94,8 +94,14 @@ func TestAgentChecker_NotAvailable(t *testing.T) { func TestAgentChecker_Error(t *testing.T) { checker := NewAgentChecker(&mockAgentProvider{err: fmt.Errorf("discovery failed")}) issues := checker.Check(context.Background()) - if len(issues) != 0 { - t.Errorf("expected 0 issues on error, got %d", len(issues)) + if len(issues) != 1 { + t.Fatalf("expected 1 issue on error, got %d", len(issues)) + } + if issues[0].ID != "agent_detection_failed" { + t.Errorf("issue ID = %q, want %q", issues[0].ID, "agent_detection_failed") + } + if issues[0].Severity != SeverityWarning { + t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) } } diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 55d371c553..cf820df81a 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -45,19 +45,38 @@ export function GitHubStatusCard() { if (!status || !status.authenticated) { return ( -
+
- Not connected to GitHub - - Run gh auth login or add a GITHUB_TOKEN - secret - + Not connected to GitHub
+
+

Connect using one of these methods:

+
    +
  1. + Environment variable — set{" "} + GITHUB_TOKEN or{" "} + GH_TOKEN when starting Kandev +
  2. +
  3. + Kandev secret — add a secret named{" "} + GITHUB_TOKEN in{" "} + + Settings > Secrets + + , then click Refresh above +
  4. +
  5. + GitHub CLI — install{" "} + gh and run{" "} + gh auth login +
  6. +
+
{status?.diagnostics && }
); diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 6cb2b80e1b..ebce9c35d3 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -296,9 +296,13 @@ export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) { setGitHubBranchesLoading(true); setGitHubPrHeadBranch(null); - const branchesPromise = fetchRepoBranches(parsed.owner, parsed.repo); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); + const fetchOpts = { init: { signal: controller.signal } }; + + const branchesPromise = fetchRepoBranches(parsed.owner, parsed.repo, fetchOpts); const prPromise = parsed.prNumber - ? fetchPRInfo(parsed.owner, parsed.repo, parsed.prNumber).catch(() => null) + ? fetchPRInfo(parsed.owner, parsed.repo, parsed.prNumber, fetchOpts).catch(() => null) : Promise.resolve(null); Promise.all([branchesPromise, prPromise]) @@ -312,16 +316,27 @@ export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) { setGitHubPrHeadBranch(prInfo.head_branch); } }) - .catch(() => { + .catch((err) => { if (cancelled) return; - setGitHubUrlError("Repository not found or not accessible"); + const isAbort = err instanceof DOMException && err.name === "AbortError"; + const isNotConfigured = err instanceof Error && err.message.includes("not configured"); + let errorMessage = "Repository not found or not accessible"; + if (isAbort) { + errorMessage = "Request timed out. Check your GitHub configuration in Settings."; + } else if (isNotConfigured) { + errorMessage = "GitHub is not configured. Set up a token in Settings > GitHub."; + } + setGitHubUrlError(errorMessage); setGitHubBranches([]); }) .finally(() => { + clearTimeout(timeoutId); if (!cancelled) setGitHubBranchesLoading(false); }); return () => { cancelled = true; + clearTimeout(timeoutId); + controller.abort(); }; }, [ open, diff --git a/apps/web/components/task/quick-chat-button.tsx b/apps/web/components/task/quick-chat-button.tsx index 80852828f3..10ff50d4a8 100644 --- a/apps/web/components/task/quick-chat-button.tsx +++ b/apps/web/components/task/quick-chat-button.tsx @@ -15,9 +15,9 @@ export function QuickChatButton({ workspaceId }: { workspaceId?: string | null } return (