diff --git a/.dockerignore b/.dockerignore index 17f7223828..e7e640cd6b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,17 +2,30 @@ .git .gitignore -# Build artifacts -apps/backend/bin/ -apps/web/.next/ -apps/web/out/ -dist/ +# Build artifacts (any depth) +**/.next +**/out +**/.turbo +**/tsconfig.tsbuildinfo +apps/backend/bin +apps/web/dist +apps/landing/dist +# Don't ignore apps/cli/{bin,dist} or apps/packages/*/dist — copied at build time + +# Loose Go build outputs in apps/backend/ (go build with no -o) +apps/backend/kandev +apps/backend/agentctl +apps/backend/mock-agent +apps/backend/acpdbg +apps/backend/acp-debug + +# Local SQLite databases +**/*.db +**/*.db-wal +**/*.db-shm # Dependencies (rebuilt in Docker) -node_modules/ -apps/web/node_modules/ -apps/cli/node_modules/ -apps/packages/*/node_modules/ +**/node_modules # IDE and editor files .vscode/ @@ -36,12 +49,16 @@ docs/ k8s/ *.md !apps/web/src/**/*.md +# Needed by apps/web build scripts (generate-changelog.mjs, +# generate-release-notes.mjs) at the repo root inside the builder stage. +!CHANGELOG.md LICENSE # Test artifacts coverage/ +**/playwright-report +**/test-results *.test.ts -*.test.go **/*_test.go # Agent/Claude config diff --git a/Dockerfile b/Dockerfile index 9235ea353c..26bc5e1f41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,11 @@ RUN pnpm install --frozen-lockfile # Copy full source for build COPY apps/ ./ +# CHANGELOG.md lives at the repo root in source; the web build scripts +# (generate-changelog.mjs, generate-release-notes.mjs) resolve it via +# `${WEB_ROOT}/../../CHANGELOG.md`. Mirror that layout inside the builder. +COPY CHANGELOG.md /build/CHANGELOG.md + # Build shared packages, web app, and CLI RUN pnpm --filter @kandev/web build && \ pnpm --filter kandev build @@ -56,10 +61,13 @@ RUN pnpm --filter @kandev/web build && \ # --------------------------------------------------------------------------- FROM node:24-bookworm-slim AS runtime -# Install only essential runtime dependencies, then clean up +# Install only essential runtime dependencies, then clean up. +# gh is included because the GitHub integration (PR review, webhooks) shells out +# to it for auth fallback when GITHUB_TOKEN is not set. RUN apt-get update && \ apt-get install -y --no-install-recommends \ git \ + gh \ ca-certificates \ gosu \ tini \ @@ -99,10 +107,14 @@ RUN cd /usr/local/lib/kandev-cli && npm install --omit=dev && \ # Kandev home directory (DB, worktrees, sessions, repos) VOLUME ["/data"] -# Environment defaults for containerized operation +# Environment defaults for containerized operation. +# NPM_CONFIG_PREFIX points npm global installs at the PV so user-installed +# agent CLIs (claude-code, codex, auggie, ...) survive pod restarts. ENV KANDEV_NO_BROWSER=1 \ KANDEV_HOME_DIR=/data \ KANDEV_DOCKER_ENABLED=false \ + NPM_CONFIG_PREFIX=/data/.npm-global \ + PATH=/data/.npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ HOSTNAME=0.0.0.0 \ NODE_ENV=production diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 7fb8470be6..0ca154eb66 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -20,7 +20,9 @@ import ( agenthandlers "github.com/kandev/kandev/internal/agent/handlers" "github.com/kandev/kandev/internal/agent/hostutility" "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agent/loginpty" "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/agent/registry" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers" "github.com/kandev/kandev/internal/agentctl/client" @@ -415,6 +417,7 @@ type routeParams struct { services *Services agentSettingsController *agentsettingscontroller.Controller agentList taskhandlers.AgentLister + agentRegistry *registry.Registry userCtrl *usercontroller.Controller notificationCtrl *notificationcontroller.Controller editorCtrl *editorcontroller.Controller @@ -578,6 +581,18 @@ func registerSecondaryRoutes( agentsettingshandlers.RegisterRoutes(p.router, p.agentSettingsController, p.gateway.Hub, p.log) p.log.Debug("Registered Agent Settings handlers (HTTP)") + // Login PTY: spawns agent login commands under a PTY on the kandev host + // (claude auth login, auggie login, ...). The user explicitly closes the + // dialog when done, so invalidate the discovery cache on every session + // end regardless of exit code — rescanning is cheap and correctly picks + // up new auth state for agents whose login flow lives inside the TUI + // (e.g. gemini) where the process keeps running after auth completes. + loginMgr := loginpty.NewManager(p.log, func(_ string, _ int, _ error) { + p.agentSettingsController.InvalidateDiscoveryCache() + }) + loginpty.NewHandlers(loginMgr, p.agentRegistry, p.log.Zap(), nil).RegisterRoutes(p.router) + p.log.Debug("Registered Login PTY handlers (HTTP + WebSocket)") + userhandlers.RegisterRoutes(p.router, p.gateway.Dispatcher, p.userCtrl, p.log) p.log.Debug("Registered User handlers (HTTP + WebSocket)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index a81f084519..f29b9cccab 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -555,6 +555,7 @@ func buildHTTPServer( services: services, agentSettingsController: agentSettingsController, agentList: agentRegistry, + agentRegistry: agentRegistry, userCtrl: usercontroller.NewController(services.User), notificationCtrl: notificationCtrl, editorCtrl: editorcontroller.NewController(services.Editor), diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 9aa5a4c37a..bf2fbf476c 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -70,6 +70,25 @@ type PassthroughAgent interface { BuildPassthroughCommand(opts PassthroughOptions) Command } +// LoginCommand describes an interactive CLI command for authenticating with +// an agent. The kandev backend runs this under a PTY so the UI can render a +// terminal and route keystrokes to the underlying process. +type LoginCommand struct { + // Cmd is the command + args to spawn, e.g. []string{"claude", "auth", "login"}. + Cmd []string + // Description renders above the terminal as a one-line hint, e.g. + // "Authenticate with your Anthropic account." + Description string +} + +// LoginAgent is an optional capability for agents that need an interactive +// login flow (browser OAuth callback, token prompt, etc.). Implement it to +// surface a "Login" button in the UI that opens a PTY-backed terminal running +// LoginCommand(). +type LoginAgent interface { + LoginCommand() *LoginCommand +} + // IsPassthroughOnly returns true if the agent only supports passthrough mode // and should not have interactive MCP tools (e.g. ask_user_question) registered. func IsPassthroughOnly(a Agent) bool { diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go index 22b70b7983..601822a4a5 100644 --- a/apps/backend/internal/agent/agents/auggie.go +++ b/apps/backend/internal/agent/agents/auggie.go @@ -127,6 +127,14 @@ func (a *Auggie) RemoteAuth() *RemoteAuth { } } +// Verified per Augment Code docs: "Run `auggie login` and follow the prompts." +func (a *Auggie) LoginCommand() *LoginCommand { + return &LoginCommand{ + Cmd: []string{"auggie", "login"}, + Description: "Sign in with your Augment Code account.", + } +} + func (a *Auggie) InstallScript() string { return "npm install -g " + auggiePkg } diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index 7f65bfaa34..6614ff28d9 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -127,8 +127,20 @@ chmod 600 "${HOME}/.claude.json"`, } } +// Verified: `claude --help` documents `claude auth login` as the dedicated +// sign-in subcommand (also `claude auth logout` / `claude auth status`). +// Source: https://code.claude.com/docs/en/cli-reference +func (a *ClaudeACP) LoginCommand() *LoginCommand { + return &LoginCommand{ + Cmd: []string{"claude", "auth", "login"}, + Description: "Sign in with your Anthropic account.", + } +} + func (a *ClaudeACP) InstallScript() string { - return "npm install -g " + claudeACPPkg + // Install both the user-facing Anthropic CLI (which IsInstalled probes for + // and which `claude /login` runs against) and the ACP bridge package. + return "npm install -g @anthropic-ai/claude-code " + claudeACPPkg } func (a *ClaudeACP) PermissionSettings() map[string]PermissionSetting { diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index c10081c139..6e5c4290f0 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -119,8 +119,22 @@ func (a *CodexACP) RemoteAuth() *RemoteAuth { } } +// Verified against `codex --help`: `codex login --device-auth` is the +// dedicated sign-in subcommand. Device-auth prints a code + URL that works +// even when the kandev process can't open a browser (containers, SSH, +// headless dev boxes), and falls back to a local browser flow otherwise. +func (a *CodexACP) LoginCommand() *LoginCommand { + return &LoginCommand{ + Cmd: []string{"codex", "login", "--device-auth"}, + Description: "Sign in with your OpenAI account.", + } +} + +// Install both the user-facing OpenAI codex CLI (which `codex login` runs +// against) and the ACP bridge package — the bridge wraps codex internally +// and depends on it being on PATH. func (a *CodexACP) InstallScript() string { - return "npm install -g " + codexACPPkg + return "npm install -g @openai/codex " + codexACPPkg } func (a *CodexACP) PermissionSettings() map[string]PermissionSetting { diff --git a/apps/backend/internal/agent/agents/copilot_acp.go b/apps/backend/internal/agent/agents/copilot_acp.go index fd73bc21fd..f6bae2683b 100644 --- a/apps/backend/internal/agent/agents/copilot_acp.go +++ b/apps/backend/internal/agent/agents/copilot_acp.go @@ -100,6 +100,15 @@ func (a *CopilotACP) Runtime() *RuntimeConfig { func (a *CopilotACP) RemoteAuth() *RemoteAuth { return nil } +// Verified per the user: `copilot login` is the dedicated sign-in +// subcommand for the GitHub Copilot CLI. +func (a *CopilotACP) LoginCommand() *LoginCommand { + return &LoginCommand{ + Cmd: []string{"copilot", "login"}, + Description: "Sign in with your GitHub account.", + } +} + func (a *CopilotACP) InstallScript() string { return "npm install -g " + copilotACPPkg } @@ -118,8 +127,12 @@ func (a *CopilotACP) PermissionSettings() map[string]PermissionSetting { // frames. Only `--allow-all-tools` and the other `--allow-all-*` flags do. var copilotPermSettings = map[string]PermissionSetting{ "allow_all_tools": { - Supported: true, - Default: false, + Supported: true, + // Enabled by default so autonomous runs don't stall on per-tool-call + // permission_request frames. kandev's UI can still surface and approve + // them, but for typical orchestration it's just noise. The other + // --allow-all-* toggles stay off as security defaults. + Default: true, Label: "Allow all tools", Description: "Skip confirmation for every tool call (--allow-all-tools)", ApplyMethod: PermissionApplyMethodCLIFlag, diff --git a/apps/backend/internal/agent/agents/copilot_acp_test.go b/apps/backend/internal/agent/agents/copilot_acp_test.go index b262eb74bf..db4a10e46e 100644 --- a/apps/backend/internal/agent/agents/copilot_acp_test.go +++ b/apps/backend/internal/agent/agents/copilot_acp_test.go @@ -3,15 +3,23 @@ package agents import "testing" // TestCopilotACP_PermissionSettings_Curated verifies the four curated flag -// suggestions surfaced to the profile-creation UI. These seed new profiles -// (all disabled by default) and are the origin of the fix for Copilot's -// permission-prompt-every-tool-call behaviour reported by users. +// suggestions surfaced to the profile-creation UI. `allow_all_tools` is +// enabled by default so autonomous runs don't stall on per-tool-call +// permission prompts; the other --allow-all-* and --no-ask-user toggles +// stay off as safe defaults. func TestCopilotACP_PermissionSettings_Curated(t *testing.T) { ag := NewCopilotACP() settings := ag.PermissionSettings() - wantKeys := []string{"allow_all_tools", "allow_all_paths", "allow_all_urls", "no_ask_user"} - for _, k := range wantKeys { + // Pin the expected default for each curated key. Anything not listed + // here defaults off. + wantDefault := map[string]bool{ + "allow_all_tools": true, + "allow_all_paths": false, + "allow_all_urls": false, + "no_ask_user": false, + } + for k, want := range wantDefault { s, ok := settings[k] if !ok { t.Fatalf("missing curated setting %q", k) @@ -19,8 +27,8 @@ func TestCopilotACP_PermissionSettings_Curated(t *testing.T) { if !s.Supported { t.Errorf("%q should be Supported=true", k) } - if s.Default { - t.Errorf("%q should default to Enabled=false so new profiles are safe", k) + if s.Default != want { + t.Errorf("%q Default = %v, want %v", k, s.Default, want) } if s.ApplyMethod != "cli_flag" { t.Errorf("%q should apply as cli_flag, got %q", k, s.ApplyMethod) diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index 10166baa07..822e0bbddb 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -114,6 +114,16 @@ func (a *Gemini) RemoteAuth() *RemoteAuth { } } +// Gemini has no dedicated login subcommand — the first run of `gemini` drops +// the user into the TUI's "Sign in with Google" flow. Spawn the bare CLI +// under the PTY so the user can complete that flow inline. +func (a *Gemini) LoginCommand() *LoginCommand { + return &LoginCommand{ + Cmd: []string{"gemini"}, + Description: "Sign in with your Google account (use the TUI's Sign in option, then quit).", + } +} + func (a *Gemini) InstallScript() string { return "npm install -g " + geminiPkg } diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 8c4aaa1e13..70ddaf00ca 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -109,7 +109,21 @@ func (a *MockAgent) Runtime() *RuntimeConfig { func (a *MockAgent) RemoteAuth() *RemoteAuth { return nil } -func (a *MockAgent) InstallScript() string { return "" } +// InstallScript returns a deterministic short-lived shell script so e2e tests +// can exercise the install streaming endpoint without depending on npm. +func (a *MockAgent) InstallScript() string { + return "echo mock-install: step 1 && echo mock-install: step 2 && echo mock-install: done" +} + +// LoginCommand exposes a deterministic interactive command for e2e PTY tests. +// `cat` echoes any input back to the user; tests send "exit\n" or kill the +// session to terminate. +func (a *MockAgent) LoginCommand() *LoginCommand { + return &LoginCommand{ + Cmd: []string{"cat"}, + Description: "Mock login (echoes input).", + } +} func (a *MockAgent) PermissionSettings() map[string]PermissionSetting { return emptyPermSettings diff --git a/apps/backend/internal/agent/loginpty/handlers.go b/apps/backend/internal/agent/loginpty/handlers.go new file mode 100644 index 0000000000..d1bffdccc4 --- /dev/null +++ b/apps/backend/internal/agent/loginpty/handlers.go @@ -0,0 +1,346 @@ +package loginpty + +import ( + "encoding/json" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + + "github.com/gin-gonic/gin" + gorillaws "github.com/gorilla/websocket" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/registry" +) + +// LoginCommandLookup returns the agent's LoginCommand or nil. Implemented by +// the registry so we don't import agent/settings here. +type LoginCommandLookup interface { + Get(id string) (agents.Agent, bool) +} + +// Handlers wraps the manager + agent registry to expose HTTP/WS endpoints. +type Handlers struct { + mgr *Manager + registry *registry.Registry + logger *zap.Logger + upgrader gorillaws.Upgrader +} + +// NewHandlers constructs Handlers. If checkOrigin is nil, the upgrader uses +// the default localhost-friendly check below — needed because gorilla's +// stock CheckOrigin rejects cross-origin (e.g. Next dev on :37429 → backend +// on :38429). +func NewHandlers(mgr *Manager, reg *registry.Registry, log *zap.Logger, checkOrigin func(*http.Request) bool) *Handlers { + if checkOrigin == nil { + checkOrigin = defaultCheckOrigin + } + up := gorillaws.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: checkOrigin, + } + return &Handlers{ + mgr: mgr, + registry: reg, + logger: log, + upgrader: up, + } +} + +// defaultCheckOrigin mirrors the policy used by the existing terminal +// handler: allow no-origin requests (non-browser clients), allow loopback +// origins (dev), otherwise require Origin host to match Request host. +// +// Loopback is matched by exact hostname after parsing — a HasPrefix check +// against `http://localhost` would also accept `http://localhost.attacker.tld`, +// which a hostile page could use to slip through the dev exception. +func defaultCheckOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + originURL, err := url.Parse(origin) + if err != nil { + return false + } + switch originURL.Hostname() { + case "localhost", "127.0.0.1", "::1": + return true + } + host := r.Host + if host == "" { + host = r.URL.Host + } + // Strip port for flexibility (but keep IPv6 brackets intact). + if colonIdx := strings.LastIndex(host, ":"); colonIdx != -1 { + if !strings.Contains(host, "]") || colonIdx > strings.Index(host, "]") { + host = host[:colonIdx] + } + } + return originURL.Hostname() == host +} + +// RegisterRoutes mounts the login-pty endpoints on the given router. +// Routes are split into agents/* (lookup by agent name) and sessions/* (lookup +// by session ID) so gin doesn't see two routes sharing a wildcard slot with +// different param names. +// +// The /host-shell/start route reuses the same session-manager infrastructure +// to spawn a plain user shell — handy for ad-hoc install/setup commands the +// install button doesn't cover. +func (h *Handlers) RegisterRoutes(r *gin.Engine) { + api := r.Group("/api/v1") + api.POST("/agent-login/agents/:agentName/start", h.httpStart) + api.POST("/agent-login/sessions/:sessionID/stop", h.httpStop) + api.POST("/agent-login/sessions/:sessionID/resize", h.httpResize) + api.GET("/agent-login/sessions/:sessionID/status", h.httpStatus) + api.GET("/agent-login/sessions/:sessionID/stream", h.handleStream) + api.POST("/host-shell/start", h.httpStartHostShell) +} + +// hostShellAgentID is the synthetic key the session manager uses for host +// shell sessions so they don't collide with agent login sessions. +const hostShellAgentID = "_host_shell" + +// httpStartHostShell spawns the user's $SHELL (or /bin/bash, then /bin/sh) +// under a PTY. Returns the standard session snapshot — the client uses the +// same stop/resize/stream endpoints. +func (h *Handlers) httpStartHostShell(c *gin.Context) { + var req startRequest + _ = c.ShouldBindJSON(&req) + + shell := detectShell() + sess, err := h.mgr.Start(hostShellAgentID, []string{shell}, req.Cols, req.Rows) + if err != nil && err != ErrSessionAlreadyRunning { + h.logger.Warn("host shell start failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, sess.Status()) +} + +// detectShell picks a sensible interactive shell, preferring $SHELL. +func detectShell() string { + if s := os.Getenv("SHELL"); s != "" { + if _, err := exec.LookPath(s); err == nil { + return s + } + } + for _, candidate := range []string{"/bin/bash", "/bin/zsh", "/bin/sh"} { + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "/bin/sh" +} + +type startRequest struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +func (h *Handlers) httpStart(c *gin.Context) { + name := strings.TrimSpace(c.Param("agentName")) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"}) + return + } + ag, ok := h.registry.Get(name) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + loginAg, ok := ag.(agents.LoginAgent) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent has no login command"}) + return + } + lc := loginAg.LoginCommand() + if lc == nil || len(lc.Cmd) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent has no login command"}) + return + } + + var req startRequest + _ = c.ShouldBindJSON(&req) // body is optional + + // Wrap the agent's login command so that when the login process exits + // (Ctrl+C, /quit, success exit, anything) the PTY drops the user into + // their normal shell instead of becoming a dead terminal. The user can + // then re-run the login, install a missing dep, or just close the + // dialog. + // + // `sh -c '"$@"; exec "${SHELL:-/bin/sh}"' wrapper ` lets sh do + // the argv parsing - no shell-escaping required on our side. + // + // `trap 'true' INT` stops the wrapper sh from dying when Ctrl+C is + // pressed (non-interactive sh exits on SIGINT by default). The child + // (codex/auggie/...) still receives SIGINT directly via the foreground + // process group, and exec(2) resets the trap to SIG_DFL in the child + // - so the agent dies as expected and sh survives to exec the shell. + wrapped := append( + []string{"sh", "-c", `trap 'true' INT; "$@"; exec "${SHELL:-/bin/sh}"`, "kandev-login-wrapper"}, + lc.Cmd..., + ) + sess, err := h.mgr.Start(name, wrapped, req.Cols, req.Rows) + if err != nil && err != ErrSessionAlreadyRunning { + h.logger.Warn("login session start failed", + zap.String("agent", name), + zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, sess.Status()) +} + +func (h *Handlers) httpStop(c *gin.Context) { + id := c.Param("sessionID") + sess := h.mgr.GetByID(id) + if sess == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + sess.stop() + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +type resizeRequest struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +func (h *Handlers) httpResize(c *gin.Context) { + id := c.Param("sessionID") + sess := h.mgr.GetByID(id) + if sess == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + var req resizeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + if err := sess.Resize(req.Cols, req.Rows); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Handlers) httpStatus(c *gin.Context) { + id := c.Param("sessionID") + sess := h.mgr.GetByID(id) + if sess == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + c.JSON(http.StatusOK, sess.Status()) +} + +// handleStream upgrades to WS and proxies bytes both directions: +// - server → client: PTY output as BinaryMessage frames +// - client → server: +// - BinaryMessage frames = raw input to PTY (keystrokes) +// - TextMessage frames = JSON control msg: {"type":"resize","cols":N,"rows":M} +// +// On session exit the subscriber channel is closed; the writer goroutine +// detects this, sends a final TextMessage {"type":"exit","exit_code":N}, +// and the connection is closed. +func (h *Handlers) handleStream(c *gin.Context) { + id := c.Param("sessionID") + sess := h.mgr.GetByID(id) + if sess == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + conn, err := h.upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + h.logger.Warn("login pty ws upgrade failed", + zap.String("session_id", id), + zap.Error(err), + ) + return + } + defer func() { _ = conn.Close() }() + + // Send buffered output up-front so a late-joining UI catches up. + if buf := sess.BufferedOutput(); len(buf) > 0 { + _ = conn.WriteMessage(gorillaws.BinaryMessage, buf) + } + + out := make(chan []byte, 128) + sess.Subscribe(out) + defer sess.Unsubscribe(out) + + done := make(chan struct{}) + // disconnect is closed by the reader on client disconnect so the writer + // can exit even when the PTY is silent (e.g. idle browser-OAuth flows). + // Without this, an abrupt client disconnect during an idle session leaks + // the writer goroutine for up to IdleTimeout. + disconnect := make(chan struct{}) + + // Writer: forward PTY output → client. + go func() { + defer close(done) + for { + select { + case data, ok := <-out: + if !ok { + // Channel closed: session ended. Send a final exit notice. + status := sess.Status() + payload := map[string]any{"type": "exit"} + if status.ExitCode != nil { + payload["exit_code"] = *status.ExitCode + } + b, _ := json.Marshal(payload) + _ = conn.WriteMessage(gorillaws.TextMessage, b) + return + } + if err := conn.WriteMessage(gorillaws.BinaryMessage, data); err != nil { + return + } + case <-disconnect: + return + } + } + }() + + // Reader: forward client → PTY (binary = input, text = JSON control). + for { + mt, data, err := conn.ReadMessage() + if err != nil { + break + } + switch mt { + case gorillaws.BinaryMessage: + if _, err := sess.Write(data); err != nil { + h.logger.Debug("pty write error", zap.Error(err)) + } + case gorillaws.TextMessage: + h.handleControlMessage(sess, data) + } + } + close(disconnect) + <-done +} + +func (h *Handlers) handleControlMessage(sess *Session, data []byte) { + var msg struct { + Type string `json:"type"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + } + if err := json.Unmarshal(data, &msg); err != nil { + return + } + if msg.Type == "resize" { + _ = sess.Resize(msg.Cols, msg.Rows) + } +} diff --git a/apps/backend/internal/agent/loginpty/manager.go b/apps/backend/internal/agent/loginpty/manager.go new file mode 100644 index 0000000000..a7b3e9ad50 --- /dev/null +++ b/apps/backend/internal/agent/loginpty/manager.go @@ -0,0 +1,490 @@ +// Package loginpty runs an agent's interactive login command under a PTY on +// the kandev host. The UI surfaces this as a terminal so users can complete +// browser-based or token-prompt auth flows without shelling into the +// container. +// +// Sessions are keyed by agent ID — at most one login PTY per agent runs at a +// time, since concurrent logins for the same agent would race on the same +// HOME dotfile. The session terminates when the command exits, on idle +// timeout, or on explicit Stop. +package loginpty + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "time" + + "github.com/creack/pty" + "github.com/google/uuid" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +const ( + // IdleTimeout terminates a session that hasn't produced output for this long. + // Picked to fit typical browser-OAuth flows (open browser → grant → callback). + IdleTimeout = 10 * time.Minute + + // HardTimeout is the wall-clock upper bound; covers stuck sessions. + HardTimeout = 30 * time.Minute + + // outputBufferSize is the rolling buffer of recent PTY output kept for + // late subscribers. Login flows are usually short so 16KB is plenty. + outputBufferSize = 16 * 1024 +) + +// Errors surfaced to callers. +var ( + ErrSessionAlreadyRunning = errors.New("login session already running for this agent") + ErrSessionNotFound = errors.New("login session not found") + ErrSessionNotRunning = errors.New("login session is not running") +) + +// Manager owns active login sessions and dispatches lifecycle callbacks. +type Manager struct { + mu sync.Mutex + sessions map[string]*Session // key: agentID + byID map[string]*Session // key: sessionID + log *logger.Logger + + // onExit is invoked from a goroutine when a session terminates (with the + // agent ID and exit code). Used by callers to invalidate discovery cache + // and broadcast updated availability. Optional. + onExit func(agentID string, exitCode int, exitErr error) +} + +// NewManager constructs a Manager. onExit may be nil. +func NewManager(log *logger.Logger, onExit func(agentID string, exitCode int, exitErr error)) *Manager { + return &Manager{ + sessions: map[string]*Session{}, + byID: map[string]*Session{}, + log: log.WithFields(zap.String("component", "login-pty")), + onExit: onExit, + } +} + +// Start spawns a PTY-backed process for the agent. Returns the session +// snapshot. Fails with ErrSessionAlreadyRunning if a session for this agent +// is already active. +func (m *Manager) Start(agentID string, cmd []string, cols, rows uint16) (*Session, error) { + if len(cmd) == 0 { + return nil, errors.New("empty command") + } + m.mu.Lock() + if existing, ok := m.sessions[agentID]; ok { + m.mu.Unlock() + _ = existing.Status() // touch + return existing, ErrSessionAlreadyRunning + } + + if cols == 0 { + cols = 80 + } + if rows == 0 { + rows = 24 + } + + sess := &Session{ + ID: uuid.NewString(), + AgentID: agentID, + Cmd: append([]string(nil), cmd...), + subscribers: map[chan<- []byte]struct{}{}, + log: m.log.WithFields(zap.String("agent", agentID)), + startedAt: time.Now(), + } + + if err := sess.start(cmd, cols, rows); err != nil { + m.mu.Unlock() + return nil, err + } + + m.sessions[agentID] = sess + m.byID[sess.ID] = sess + m.mu.Unlock() + + go m.supervise(sess) + return sess, nil +} + +// Get returns a session by agent ID (or nil). +func (m *Manager) Get(agentID string) *Session { + m.mu.Lock() + defer m.mu.Unlock() + return m.sessions[agentID] +} + +// GetByID returns a session by session ID (or nil). +func (m *Manager) GetByID(sessionID string) *Session { + m.mu.Lock() + defer m.mu.Unlock() + return m.byID[sessionID] +} + +// Stop terminates the agent's session if one is running. +func (m *Manager) Stop(agentID string) error { + m.mu.Lock() + sess, ok := m.sessions[agentID] + m.mu.Unlock() + if !ok { + return ErrSessionNotFound + } + sess.stop() + return nil +} + +// supervise watches the session's lifetime: waits on the process, enforces +// timeouts, then deletes the session from the manager and notifies onExit. +func (m *Manager) supervise(sess *Session) { + ctx, cancel := context.WithTimeout(context.Background(), HardTimeout) + defer cancel() + + exited := make(chan exitInfo, 1) + go func() { + err := sess.waitForCommand() + exited <- exitInfo{ + code: exitCodeFromError(err), + err: err, + } + }() + + idle := time.NewTimer(IdleTimeout) + defer idle.Stop() + + var info exitInfo +loop: + for { + select { + case info = <-exited: + break loop + case <-idle.C: + sess.log.Info("login session idle timeout — terminating") + sess.stop() + case <-ctx.Done(): + sess.log.Info("login session hard timeout — terminating") + sess.stop() + case <-sess.activityCh: + // Reset idle timer on output activity. + if !idle.Stop() { + select { + case <-idle.C: + default: + } + } + idle.Reset(IdleTimeout) + } + } + + // Wait for readLoop to drain remaining PTY output before flipping running=false + // or closing subscribers. Otherwise late Subscribe callers (or BufferedOutput + // readers) could observe an empty session even though the process produced + // output. The pty is closed by stop() in timeout paths and by the child + // exit in the natural path; either way readLoop returns shortly after. + // Cap the wait so a stuck PTY can't deadlock cleanup. + select { + case <-sess.readDone: + case <-time.After(2 * time.Second): + sess.log.Warn("readLoop did not exit in time during supervise cleanup") + } + + m.mu.Lock() + delete(m.sessions, sess.AgentID) + delete(m.byID, sess.ID) + m.mu.Unlock() + + sess.mu.Lock() + now := time.Now() + sess.finishedAt = &now + sess.exitCode = &info.code + sess.running = false + durationMs := now.Sub(sess.startedAt).Milliseconds() + sess.mu.Unlock() + + // Log the exit so we can tell apart "agent exited 0 silently" (e.g. + // already-authed) from "agent crashed". Suppress the noisy EOF error, + // which is just normal PTY closure. + exitErr := "" + if info.err != nil && info.err != io.EOF { + exitErr = info.err.Error() + } + sess.log.Info("login session ended", + zap.String("session_id", sess.ID), + zap.Int("exit_code", info.code), + zap.String("err", exitErr), + zap.Int64("duration_ms", durationMs), + ) + + sess.broadcastClose() + + if m.onExit != nil { + m.onExit(sess.AgentID, info.code, info.err) + } +} + +type exitInfo struct { + code int + err error +} + +func exitCodeFromError(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return -1 +} + +// Session is a single login PTY process plus its subscriber set. +// Exported fields are stable; everything else is internal. +type Session struct { + ID string + AgentID string + Cmd []string + + mu sync.Mutex + cmd *exec.Cmd + pty *os.File + running bool + startedAt time.Time + finishedAt *time.Time + exitCode *int + + subMu sync.Mutex + subscribers map[chan<- []byte]struct{} + subClosed bool // set under subMu by broadcastClose; once true, Subscribe closes the channel immediately + + bufMu sync.Mutex + buffer []byte + + activityCh chan struct{} // notifications to supervise loop on output + readDone chan struct{} // closed when readLoop exits (all PTY output drained) + + log *logger.Logger +} + +func (s *Session) start(cmd []string, cols, rows uint16) error { + s.activityCh = make(chan struct{}, 1) + s.readDone = make(chan struct{}) + c := exec.Command(cmd[0], cmd[1:]...) // #nosec G204 — command is hard-coded per agent + c.Env = buildLoginEnv() + + f, err := pty.StartWithSize(c, &pty.Winsize{Cols: cols, Rows: rows}) + if err != nil { + return fmt.Errorf("start pty: %w", err) + } + + s.cmd = c + s.pty = f + s.running = true + s.log.Info("login session started", + zap.String("session_id", s.ID), + zap.Strings("cmd", cmd), + zap.Int("pid", c.Process.Pid), + ) + go s.readLoop() + return nil +} + +func (s *Session) readLoop() { + defer close(s.readDone) + buf := make([]byte, 4096) + for { + n, err := s.pty.Read(buf) + if n > 0 { + data := make([]byte, n) + copy(data, buf[:n]) + s.appendBuffer(data) + s.broadcast(data) + select { + case s.activityCh <- struct{}{}: + default: + } + } + if err != nil { + if err != io.EOF { + s.log.Debug("pty read ended", zap.Error(err)) + } + return + } + } +} + +func (s *Session) appendBuffer(data []byte) { + s.bufMu.Lock() + defer s.bufMu.Unlock() + s.buffer = append(s.buffer, data...) + if len(s.buffer) > outputBufferSize { + s.buffer = s.buffer[len(s.buffer)-outputBufferSize:] + } +} + +// BufferedOutput returns a copy of the rolling output buffer for catching up +// new WS subscribers. +func (s *Session) BufferedOutput() []byte { + s.bufMu.Lock() + defer s.bufMu.Unlock() + if len(s.buffer) == 0 { + return nil + } + out := make([]byte, len(s.buffer)) + copy(out, s.buffer) + return out +} + +// Subscribe registers a channel for PTY output. The channel is non-blocking: +// if it fills up, chunks are dropped (the subscriber falls behind). +// +// If the session has already ended (broadcastClose ran), the channel is +// closed immediately so the caller's reader returns rather than blocking +// forever on a channel that nothing will ever send to or close. +// +// Both the close-check and the map insertion happen under subMu so a +// concurrent broadcastClose cannot race in between (which would otherwise +// leave the late subscriber registered in a fresh empty map with nothing +// to ever close it). +func (s *Session) Subscribe(ch chan<- []byte) { + s.subMu.Lock() + defer s.subMu.Unlock() + if s.subClosed { + close(ch) + return + } + s.subscribers[ch] = struct{}{} +} + +// Unsubscribe removes a channel from the subscriber set. +func (s *Session) Unsubscribe(ch chan<- []byte) { + s.subMu.Lock() + defer s.subMu.Unlock() + delete(s.subscribers, ch) +} + +func (s *Session) broadcast(data []byte) { + s.subMu.Lock() + defer s.subMu.Unlock() + for ch := range s.subscribers { + select { + case ch <- data: + default: + } + } +} + +// broadcastClose closes any remaining subscriber channels — used to wake WS +// loops on session exit. subClosed is set under subMu so any concurrent +// Subscribe taking subMu after this either observes subClosed and closes +// its own channel, or completed before us and is already in the snapshot. +func (s *Session) broadcastClose() { + s.subMu.Lock() + subs := s.subscribers + s.subscribers = map[chan<- []byte]struct{}{} + s.subClosed = true + s.subMu.Unlock() + for ch := range subs { + close(ch) + } +} + +// Write sends user input to the underlying process via the PTY. +func (s *Session) Write(p []byte) (int, error) { + s.mu.Lock() + pf := s.pty + running := s.running + s.mu.Unlock() + if !running || pf == nil { + return 0, ErrSessionNotRunning + } + return pf.Write(p) +} + +// Resize updates the PTY window size. +func (s *Session) Resize(cols, rows uint16) error { + s.mu.Lock() + pf := s.pty + running := s.running + s.mu.Unlock() + if !running || pf == nil { + return ErrSessionNotRunning + } + return pty.Setsize(pf, &pty.Winsize{Cols: cols, Rows: rows}) +} + +// Status snapshot for HTTP responses. +type Status struct { + ID string `json:"session_id"` + AgentID string `json:"agent_id"` + Cmd []string `json:"cmd"` + Running bool `json:"running"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` +} + +// Status returns a value snapshot of the session. +func (s *Session) Status() Status { + s.mu.Lock() + defer s.mu.Unlock() + st := Status{ + ID: s.ID, + AgentID: s.AgentID, + Cmd: append([]string(nil), s.Cmd...), + Running: s.running, + StartedAt: s.startedAt, + } + if s.finishedAt != nil { + f := *s.finishedAt + st.FinishedAt = &f + } + if s.exitCode != nil { + e := *s.exitCode + st.ExitCode = &e + } + return st +} + +// stop tears the PTY down idempotently. The `!s.running` guard is flipped +// *under the same lock* that we use to read pty/cmd, so concurrent callers +// (timeout case in supervise + external Manager.Stop, etc.) don't double- +// close the file descriptor on the way out. A double Close on *os.File is +// safe in isolation, but if the OS recycles the fd between the two Close() +// calls, the second close hits a different open file. +func (s *Session) stop() { + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return + } + s.running = false + pf := s.pty + cmd := s.cmd + s.mu.Unlock() + + if pf != nil { + _ = pf.Close() + } + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } +} + +func (s *Session) waitForCommand() error { + if s.cmd == nil { + return nil + } + return s.cmd.Wait() +} + +// buildLoginEnv composes the environment for the login subprocess. The user's +// HOME (where agents write credentials) is inherited from the kandev process. +func buildLoginEnv() []string { + env := os.Environ() + env = append(env, "TERM=xterm-256color", "LANG=C.UTF-8", "LC_ALL=C.UTF-8") + return env +} diff --git a/apps/backend/internal/agent/loginpty/manager_test.go b/apps/backend/internal/agent/loginpty/manager_test.go new file mode 100644 index 0000000000..2805a59ec0 --- /dev/null +++ b/apps/backend/internal/agent/loginpty/manager_test.go @@ -0,0 +1,159 @@ +package loginpty + +import ( + "bytes" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestManager(t *testing.T, onExit func(string, int, error)) *Manager { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + return NewManager(log, onExit) +} + +// drainOutput consumes the subscriber channel until it closes; returns all bytes received. +func drainOutput(sess *Session) []byte { + ch := make(chan []byte, 32) + sess.Subscribe(ch) + defer sess.Unsubscribe(ch) + + var buf bytes.Buffer + deadline := time.After(2 * time.Second) + for { + select { + case data, ok := <-ch: + if !ok { + return buf.Bytes() + } + buf.Write(data) + case <-deadline: + return buf.Bytes() + } + } +} + +func TestStart_RunsCommandAndExits(t *testing.T) { + exited := make(chan struct { + agent string + code int + }, 1) + mgr := newTestManager(t, func(agent string, code int, _ error) { + exited <- struct { + agent string + code int + }{agent, code} + }) + + sess, err := mgr.Start("test-agent", []string{"sh", "-c", "echo hello && exit 0"}, 80, 24) + if err != nil { + t.Fatalf("Start: %v", err) + } + if sess.ID == "" { + t.Fatal("expected session id") + } + + // Subscribe right away to catch the output (some may be missed if we race + // the readLoop; BufferedOutput() compensates after exit). + got := drainOutput(sess) + + select { + case info := <-exited: + if info.agent != "test-agent" { + t.Errorf("agent = %s", info.agent) + } + if info.code != 0 { + t.Errorf("code = %d, want 0", info.code) + } + case <-time.After(3 * time.Second): + t.Fatal("supervise loop did not fire onExit") + } + + // Output may have been received via the subscriber or buffered; check both. + combined := append([]byte(nil), got...) + combined = append(combined, sess.BufferedOutput()...) + if !bytes.Contains(combined, []byte("hello")) { + t.Errorf("expected 'hello' in output, got %q", combined) + } +} + +func TestStart_IdempotentPerAgent(t *testing.T) { + mgr := newTestManager(t, nil) + + first, err := mgr.Start("test-agent", []string{"sh", "-c", "sleep 5"}, 80, 24) + if err != nil { + t.Fatalf("first start: %v", err) + } + t.Cleanup(func() { _ = mgr.Stop("test-agent") }) + + second, err := mgr.Start("test-agent", []string{"sh", "-c", "sleep 5"}, 80, 24) + if !errors.Is(err, ErrSessionAlreadyRunning) { + t.Fatalf("err = %v, want ErrSessionAlreadyRunning", err) + } + if first.ID != second.ID { + t.Errorf("expected same session id, got %s and %s", first.ID, second.ID) + } +} + +func TestStart_EmptyCommand(t *testing.T) { + mgr := newTestManager(t, nil) + _, err := mgr.Start("test-agent", nil, 80, 24) + if err == nil { + t.Fatal("expected error for empty command") + } +} + +func TestStop_TerminatesRunningSession(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + mgr := newTestManager(t, func(_ string, _ int, _ error) { + wg.Done() + }) + + _, err := mgr.Start("test-agent", []string{"sh", "-c", "sleep 30"}, 80, 24) + if err != nil { + t.Fatalf("Start: %v", err) + } + + if err := mgr.Stop("test-agent"); err != nil { + t.Fatalf("Stop: %v", err) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("session did not exit after Stop") + } +} + +func TestWriteAndResize_WhenNotRunning(t *testing.T) { + mgr := newTestManager(t, nil) + sess, err := mgr.Start("test-agent", []string{"true"}, 80, 24) + if err != nil { + t.Fatalf("Start: %v", err) + } + // Wait for exit. + deadline := time.After(2 * time.Second) + for sess.Status().Running { + select { + case <-deadline: + t.Fatal("session never exited") + case <-time.After(20 * time.Millisecond): + } + } + if _, err := sess.Write([]byte("x")); !errors.Is(err, ErrSessionNotRunning) { + t.Errorf("Write err = %v, want ErrSessionNotRunning", err) + } + if err := sess.Resize(120, 40); !errors.Is(err, ErrSessionNotRunning) { + t.Errorf("Resize err = %v, want ErrSessionNotRunning", err) + } +} diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 0ee25e52e2..d8f5d2461c 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -23,6 +23,10 @@ func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryRespo } payload := make([]dto.AgentDiscoveryDTO, 0, len(results)) for _, result := range results { + var loginCmd *dto.LoginCommandDTO + if ag, ok := c.agentRegistry.Get(result.Name); ok { + loginCmd = buildLoginCommandDTO(ag) + } payload = append(payload, dto.AgentDiscoveryDTO{ Name: result.Name, SupportsMCP: result.SupportsMCP, @@ -30,6 +34,7 @@ func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryRespo InstallationPaths: result.InstallationPaths, Available: result.Available, MatchedPath: result.MatchedPath, + LoginCommand: loginCmd, }) } return &dto.ListDiscoveryResponse{Agents: payload, Total: len(payload)}, nil @@ -120,6 +125,8 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent } } + loginCommand := buildLoginCommandDTO(ag) + return dto.AvailableAgentDTO{ Name: ag.ID(), DisplayName: displayName, @@ -134,10 +141,28 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent ModelConfig: modelConfig, PermissionSettings: permissionSettings, PassthroughConfig: passthroughConfig, + LoginCommand: loginCommand, UpdatedAt: now, } } +// buildLoginCommandDTO surfaces the interactive login command for agents that +// implement LoginAgent. Nil for agents without an interactive login. +func buildLoginCommandDTO(ag agents.Agent) *dto.LoginCommandDTO { + loginAg, ok := ag.(agents.LoginAgent) + if !ok { + return nil + } + lc := loginAg.LoginCommand() + if lc == nil || len(lc.Cmd) == 0 { + return nil + } + return &dto.LoginCommandDTO{ + Cmd: lc.Cmd, + Description: lc.Description, + } +} + // buildModelConfigFromHostUtility reads cached ACP probe data for the agent // type and produces a ModelConfigDTO with models, modes, and status. Agents // not in the probe cache (e.g. the mock agent used in E2E tests, which diff --git a/apps/backend/internal/agent/settings/controller/agent_install.go b/apps/backend/internal/agent/settings/controller/agent_install.go new file mode 100644 index 0000000000..8b702ae768 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_install.go @@ -0,0 +1,62 @@ +package controller + +import ( + "errors" + "strings" + + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +// ErrInstallScriptEmpty is returned when the agent has no install script defined. +var ErrInstallScriptEmpty = errors.New("agent has no install script") + +// ErrJobStoreUnavailable is returned when SetJobBroadcaster hasn't been called +// — i.e. handlers haven't registered yet, so there's no WS hub to stream to. +var ErrJobStoreUnavailable = errors.New("install job store unavailable") + +// EnqueueInstall starts (or returns the existing) async install job for the +// named agent. The script is hard-coded by the agent type, so no user input is +// ever shelled. Clients subscribe to WS notifications (agent.install.started, +// agent.install.output, agent.install.finished) for live progress, or poll +// /agent-install/jobs/:id for a snapshot. +func (c *Controller) EnqueueInstall(name string) (*dto.InstallJobDTO, error) { + if c.jobStore == nil { + return nil, ErrJobStoreUnavailable + } + ag, ok := c.agentRegistry.Get(name) + if !ok { + return nil, ErrAgentNotFound + } + script := strings.TrimSpace(ag.InstallScript()) + if script == "" { + return nil, ErrInstallScriptEmpty + } + job := c.jobStore.Enqueue(name, script) + // Get() takes the store mutex, so it's race-free; calling job.snapshot() + // directly here would read job.Status without the lock while the spawned + // goroutine may be writing it. + if snap, ok := c.jobStore.Get(job.ID); ok { + return snap, nil + } + // Job was evicted between Enqueue and Get (extremely unlikely with the + // configured retention window). Fall back to the unguarded snapshot. + snap := job.snapshot() + return &snap, nil +} + +// ListInstallJobs returns a snapshot of every active or recently-finished +// install job. Used by the UI on page mount to recover in-flight installs. +func (c *Controller) ListInstallJobs() []dto.InstallJobDTO { + if c.jobStore == nil { + return nil + } + return c.jobStore.ListAll() +} + +// GetInstallJob returns a snapshot of one job by ID, or nil if not found. +func (c *Controller) GetInstallJob(id string) (*dto.InstallJobDTO, bool) { + if c.jobStore == nil { + return nil, false + } + return c.jobStore.Get(id) +} diff --git a/apps/backend/internal/agent/settings/controller/agent_install_test.go b/apps/backend/internal/agent/settings/controller/agent_install_test.go new file mode 100644 index 0000000000..f4248d7492 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_install_test.go @@ -0,0 +1,244 @@ +package controller + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/settings/dto" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// installScriptAgent extends testAgent so we can set a non-empty install script. +type installScriptAgent struct { + testAgent + script string +} + +func (a *installScriptAgent) InstallScript() string { return a.script } + +// captureBroadcaster captures all WS messages emitted during the test. +type captureBroadcaster struct { + mu sync.Mutex + msg []*ws.Message +} + +func (b *captureBroadcaster) Broadcast(m *ws.Message) { + b.mu.Lock() + defer b.mu.Unlock() + b.msg = append(b.msg, m) +} + +func (b *captureBroadcaster) actions() []string { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]string, len(b.msg)) + for i, m := range b.msg { + out[i] = m.Action + } + return out +} + +// withStubStreamingRunner swaps streamingInstallRunner for the duration of +// the test. The stub invokes onChunk synchronously to make ordering deterministic. +func withStubStreamingRunner(t *testing.T, fn func(ctx context.Context, script string, onChunk func(string)) error) { + t.Helper() + prev := streamingInstallRunner + streamingInstallRunner = fn + t.Cleanup(func() { streamingInstallRunner = prev }) +} + +func newInstallController(t *testing.T, ag agents.Agent) (*Controller, *captureBroadcaster) { + t.Helper() + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + hub := &captureBroadcaster{} + ctrl.SetJobBroadcaster(hub) + return ctrl, hub +} + +// waitForStatus polls until the job hits one of the terminal statuses or the +// deadline expires. +func waitForStatus(t *testing.T, ctrl *Controller, jobID string, want ...dto.InstallJobStatus) *dto.InstallJobDTO { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + snap, ok := ctrl.GetInstallJob(jobID) + if ok { + for _, w := range want { + if snap.Status == w { + return snap + } + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("job %s did not reach status %v in time", jobID, want) + return nil +} + +func TestEnqueueInstall_StreamsAndSucceeds(t *testing.T) { + ag := &installScriptAgent{ + testAgent: testAgent{id: "test-agent", name: "test-agent", enabled: true}, + script: "echo ok", + } + ctrl, hub := newInstallController(t, ag) + + withStubStreamingRunner(t, func(_ context.Context, _ string, onChunk func(string)) error { + onChunk("installing...\n") + onChunk("done\n") + return nil + }) + + snap, err := ctrl.EnqueueInstall("test-agent") + if err != nil { + t.Fatalf("EnqueueInstall() error = %v", err) + } + if snap.JobID == "" { + t.Fatal("expected job_id") + } + + final := waitForStatus(t, ctrl, snap.JobID, dto.InstallJobStatusSucceeded) + if !strings.Contains(final.Output, "installing...") || !strings.Contains(final.Output, "done") { + t.Errorf("output missing stream chunks, got %q", final.Output) + } + if final.ExitCode == nil || *final.ExitCode != 0 { + t.Errorf("ExitCode = %v, want 0", final.ExitCode) + } + + // Must have broadcast a started and a finished message; output messages + // land in between but exact count depends on the flush timing. + actions := hub.actions() + if len(actions) < 2 { + t.Fatalf("expected ≥2 broadcasts, got %v", actions) + } + if actions[0] != ws.ActionAgentInstallStarted { + t.Errorf("first action = %s, want %s", actions[0], ws.ActionAgentInstallStarted) + } + if actions[len(actions)-1] != ws.ActionAgentInstallFinished { + t.Errorf("last action = %s, want %s", actions[len(actions)-1], ws.ActionAgentInstallFinished) + } +} + +func TestEnqueueInstall_Failure(t *testing.T) { + ag := &installScriptAgent{ + testAgent: testAgent{id: "test-agent", name: "test-agent", enabled: true}, + script: "exit 1", + } + ctrl, _ := newInstallController(t, ag) + + withStubStreamingRunner(t, func(_ context.Context, _ string, onChunk func(string)) error { + onChunk("npm ERR! boom\n") + return errors.New("exit status 1") + }) + + snap, err := ctrl.EnqueueInstall("test-agent") + if err != nil { + t.Fatalf("EnqueueInstall() error = %v", err) + } + + final := waitForStatus(t, ctrl, snap.JobID, dto.InstallJobStatusFailed) + if final.Error == "" { + t.Error("Error empty on failed install") + } + if !strings.Contains(final.Output, "npm ERR!") { + t.Errorf("Output missing stderr, got %q", final.Output) + } +} + +func TestEnqueueInstall_IdempotentWhileRunning(t *testing.T) { + ag := &installScriptAgent{ + testAgent: testAgent{id: "test-agent", name: "test-agent", enabled: true}, + script: "sleep", + } + ctrl, _ := newInstallController(t, ag) + + // Block the runner so the first job stays in 'running' while we call + // EnqueueInstall a second time. + release := make(chan struct{}) + withStubStreamingRunner(t, func(ctx context.Context, _ string, _ func(string)) error { + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + + first, err := ctrl.EnqueueInstall("test-agent") + if err != nil { + t.Fatalf("first enqueue: %v", err) + } + second, err := ctrl.EnqueueInstall("test-agent") + if err != nil { + t.Fatalf("second enqueue: %v", err) + } + if first.JobID != second.JobID { + t.Errorf("expected same job_id, got %s and %s", first.JobID, second.JobID) + } + + // Release the runner and wait for the goroutine to finish before the test + // returns. Otherwise withStubStreamingRunner's restore cleanup races with + // the still-running goroutine's read of streamingInstallRunner. + close(release) + waitForStatus(t, ctrl, first.JobID, dto.InstallJobStatusSucceeded, dto.InstallJobStatusFailed) +} + +func TestEnqueueInstall_AgentNotFound(t *testing.T) { + ag := &installScriptAgent{ + testAgent: testAgent{id: "test-agent", name: "test-agent", enabled: true}, + script: "echo ok", + } + ctrl, _ := newInstallController(t, ag) + + _, err := ctrl.EnqueueInstall("missing") + if !errors.Is(err, ErrAgentNotFound) { + t.Fatalf("err = %v, want ErrAgentNotFound", err) + } +} + +func TestEnqueueInstall_EmptyScript(t *testing.T) { + ag := &installScriptAgent{ + testAgent: testAgent{id: "test-agent", name: "test-agent", enabled: true}, + script: " ", + } + ctrl, _ := newInstallController(t, ag) + + _, err := ctrl.EnqueueInstall("test-agent") + if !errors.Is(err, ErrInstallScriptEmpty) { + t.Fatalf("err = %v, want ErrInstallScriptEmpty", err) + } +} + +func TestEnqueueInstall_NoJobStore(t *testing.T) { + ag := &installScriptAgent{ + testAgent: testAgent{id: "test-agent", name: "test-agent", enabled: true}, + script: "echo ok", + } + // Construct without calling SetJobBroadcaster. + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + + _, err := ctrl.EnqueueInstall("test-agent") + if !errors.Is(err, ErrJobStoreUnavailable) { + t.Fatalf("err = %v, want ErrJobStoreUnavailable", err) + } +} + +func TestRingBuffer_DropsOldestOnLineBoundary(t *testing.T) { + rb := newRingBuffer(20) + _, _ = rb.Write([]byte("first line\n")) + _, _ = rb.Write([]byte("second line\n")) + _, _ = rb.Write([]byte("third\n")) + got := rb.String() + // "first line\n" must have been evicted; the buffer holds the tail starting + // after the next newline boundary. + if strings.Contains(got, "first") { + t.Errorf("ring buffer should have evicted 'first', got %q", got) + } + if !strings.Contains(got, "third") { + t.Errorf("ring buffer missing newest write, got %q", got) + } +} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 403c2aabfd..50b4a1ad7e 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/kandev/kandev/internal/agent/discovery" agentdto "github.com/kandev/kandev/internal/agent/dto" @@ -14,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/agent/settings/modelfetcher" "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -50,6 +52,8 @@ type Controller struct { mcpService *mcpconfig.Service modelCache *modelfetcher.Cache hostUtility *hostutility.Manager + jobStore *JobStore + hub JobBroadcaster logger *logger.Logger } @@ -88,3 +92,59 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, func (c *Controller) SetHostUtility(h *hostutility.Manager) { c.hostUtility = h } + +// SetJobBroadcaster initializes the install job store with a WS broadcaster +// for streaming install progress. Called once during handler registration. +// If unset (hub == nil), the streaming install API returns +// ErrJobStoreUnavailable — without this guard a nil hub would silently +// degrade to a non-broadcasting store and the UI would never see progress. +func (c *Controller) SetJobBroadcaster(hub JobBroadcaster) { + c.hub = hub + if hub == nil { + c.jobStore = nil + return + } + c.jobStore = NewJobStore(hub, c.logger.Zap(), func(agentName string) { + c.InvalidateDiscoveryCache() + // Kick a fresh capability probe immediately so the UI doesn't sit on + // stale "not_installed" until the next periodic poll. When the probe + // finishes, re-broadcast the updated availability so any open profile + // page transitions out of "Probing…" without a manual refresh. + if c.hostUtility != nil { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := c.hostUtility.Refresh(ctx, agentName); err != nil { + c.logger.Debug("post-install capability refresh failed", + zap.String("agent", agentName), zap.Error(err)) + } + c.BroadcastAvailableAgents() + }() + } + c.logger.Info("install succeeded", zap.String("agent", agentName)) + }) +} + +// BroadcastAvailableAgents fetches the current available-agents snapshot and +// pushes it over WS as `agent.available.updated`. Used after install + probe +// so the UI flips from "probing" to the resolved status without a refresh. +func (c *Controller) BroadcastAvailableAgents() { + if c.hub == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + resp, err := c.ListAvailableAgents(ctx) + if err != nil { + c.logger.Debug("broadcast available agents: list failed", zap.Error(err)) + return + } + msg, err := ws.NewNotification(ws.ActionAgentAvailableUpdated, map[string]any{ + "agents": resp.Agents, + "tools": resp.Tools, + }) + if err != nil { + return + } + c.hub.Broadcast(msg) +} diff --git a/apps/backend/internal/agent/settings/controller/install_job.go b/apps/backend/internal/agent/settings/controller/install_job.go new file mode 100644 index 0000000000..f171b82278 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/install_job.go @@ -0,0 +1,387 @@ +package controller + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "sync" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/agent/settings/dto" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// Install job lifecycle: +// +// queued → running → succeeded +// ↘ failed +// +// Jobs are kept in memory for jobRetention after they finish so the UI can +// pick up the final state on reload before they evict. + +const ( + jobOutputRingSize = 64 * 1024 + jobRetention = 5 * time.Minute + jobMaxParallel = 4 + jobHardTimeout = 5 * time.Minute + outputFlushPeriod = 50 * time.Millisecond + outputFlushMaxSize = 4 * 1024 +) + +// InstallJob tracks a single install attempt. +type InstallJob struct { + ID string + AgentName string + Status dto.InstallJobStatus + Output *ringBuffer + StartedAt time.Time + FinishedAt *time.Time + ExitCode *int + Error string + + cancel context.CancelFunc +} + +// streamingInstallRunner exec's the script under a context, streaming each line +// to the provided callback. Swappable in tests. +var streamingInstallRunner = defaultStreamingInstallRunner + +func defaultStreamingInstallRunner( + ctx context.Context, + script string, + onChunk func(chunk string), +) error { + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", script) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go pumpLines(stdout, onChunk, &wg, errCh) + go pumpLines(stderr, onChunk, &wg, errCh) + wg.Wait() + close(errCh) + + waitErr := cmd.Wait() + if waitErr != nil { + return waitErr + } + // If cmd.Wait succeeded but a scanner failed (e.g. token too long), + // surface the read error so the job is marked failed rather than + // silently succeeded with truncated output. + for scanErr := range errCh { + if scanErr != nil { + return scanErr + } + } + return nil +} + +func pumpLines(r io.Reader, onChunk func(string), wg *sync.WaitGroup, errCh chan<- error) { + defer wg.Done() + scanner := bufio.NewScanner(r) + // Lift the default 64KB limit — npm output can have long lines. + scanner.Buffer(make([]byte, 0, 64*1024), 1*1024*1024) + for scanner.Scan() { + onChunk(scanner.Text() + "\n") + } + if err := scanner.Err(); err != nil { + errCh <- err + } +} + +// JobStore owns active and recently-completed install jobs. +type JobStore struct { + mu sync.Mutex + jobs map[string]*InstallJob // by job_id + activeByAgt map[string]*InstallJob // by agent name (current/queued) + semaphore chan struct{} // bounded concurrency + hub JobBroadcaster + log *zap.Logger + onSuccess func(agentName string) // invoked when a job succeeds (used to invalidate discovery) +} + +// JobBroadcaster is the subset of the gateway Broadcaster we need. +type JobBroadcaster interface { + Broadcast(msg *ws.Message) +} + +// NewJobStore returns a fresh job store wired to the given broadcaster. +func NewJobStore(hub JobBroadcaster, log *zap.Logger, onSuccess func(string)) *JobStore { + return &JobStore{ + jobs: make(map[string]*InstallJob), + activeByAgt: make(map[string]*InstallJob), + semaphore: make(chan struct{}, jobMaxParallel), + hub: hub, + log: log, + onSuccess: onSuccess, + } +} + +// Enqueue starts (or returns an existing) install job for the named agent +// running the given hard-coded script. The script is supplied by the agent +// type, not by the request — no user-supplied shell input. +func (s *JobStore) Enqueue(agentName, script string) *InstallJob { + s.mu.Lock() + // Idempotent: a running/queued job for this agent reuses the same job_id. + if existing, ok := s.activeByAgt[agentName]; ok { + s.mu.Unlock() + return existing + } + job := &InstallJob{ + ID: uuid.NewString(), + AgentName: agentName, + Status: dto.InstallJobStatusQueued, + Output: newRingBuffer(jobOutputRingSize), + StartedAt: time.Now().UTC(), + } + s.jobs[job.ID] = job + s.activeByAgt[agentName] = job + s.mu.Unlock() + + s.broadcast(ws.ActionAgentInstallStarted, job.snapshot()) + go s.run(job, script) + return job +} + +// Get returns a snapshot of the job by ID. ok=false if not found. +func (s *JobStore) Get(jobID string) (*dto.InstallJobDTO, bool) { + s.mu.Lock() + defer s.mu.Unlock() + job, ok := s.jobs[jobID] + if !ok { + return nil, false + } + snap := job.snapshot() + return &snap, true +} + +// ListAll returns snapshots of every job currently retained (active + recently finished). +func (s *JobStore) ListAll() []dto.InstallJobDTO { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]dto.InstallJobDTO, 0, len(s.jobs)) + for _, j := range s.jobs { + out = append(out, j.snapshot()) + } + return out +} + +func (s *JobStore) run(job *InstallJob, script string) { + // Bound parallel installs: queued jobs wait here until a slot frees up. + s.semaphore <- struct{}{} + defer func() { <-s.semaphore }() + + ctx, cancel := context.WithTimeout(context.Background(), jobHardTimeout) + s.mu.Lock() + job.cancel = cancel + job.Status = dto.InstallJobStatusRunning + s.mu.Unlock() + defer cancel() + + // Re-broadcast started so the UI flips queued→running. + s.broadcast(ws.ActionAgentInstallStarted, job.snapshot()) + + flusher := newOutputFlusher(s, job) + defer flusher.flush() + + err := streamingInstallRunner(ctx, script, flusher.append) + flusher.flush() + + s.mu.Lock() + now := time.Now().UTC() + job.FinishedAt = &now + if err == nil { + job.Status = dto.InstallJobStatusSucceeded + exit := 0 + job.ExitCode = &exit + } else { + job.Status = dto.InstallJobStatusFailed + job.Error = formatJobError(ctx, err) + if exit, ok := exitCodeOf(err); ok { + job.ExitCode = &exit + } + } + // Free the per-agent slot before notifying so a retry/auto-rescan + // triggered from onSuccess or the WS broadcast starts a fresh job + // instead of being deduped against this finished one. + delete(s.activeByAgt, job.AgentName) + jobID := job.ID + s.mu.Unlock() + + if err == nil && s.onSuccess != nil { + s.onSuccess(job.AgentName) + } + + s.broadcast(ws.ActionAgentInstallFinished, job.snapshot()) + + // Evict the finished job after retention. + time.AfterFunc(jobRetention, func() { + s.mu.Lock() + delete(s.jobs, jobID) + s.mu.Unlock() + }) +} + +func (s *JobStore) broadcast(action string, payload dto.InstallJobDTO) { + if s.hub == nil { + return + } + msg, _ := ws.NewNotification(action, payload) + s.hub.Broadcast(msg) +} + +func formatJobError(ctx context.Context, err error) string { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fmt.Sprintf("install timed out after %s", jobHardTimeout) + } + return err.Error() +} + +func exitCodeOf(err error) (int, bool) { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), true + } + return 0, false +} + +// snapshot returns a value copy of the job under the store lock. +// Caller is responsible for holding s.mu *or* calling on a job not yet inserted. +func (j *InstallJob) snapshot() dto.InstallJobDTO { + snap := dto.InstallJobDTO{ + JobID: j.ID, + Name: j.AgentName, + Status: j.Status, + Output: j.Output.String(), + StartedAt: j.StartedAt, + Error: j.Error, + } + if j.FinishedAt != nil { + finishedCopy := *j.FinishedAt + snap.FinishedAt = &finishedCopy + } + if j.ExitCode != nil { + exitCopy := *j.ExitCode + snap.ExitCode = &exitCopy + } + return snap +} + +// outputFlusher batches output chunks into broadcasts to limit WS message rate. +type outputFlusher struct { + store *JobStore + job *InstallJob + mu sync.Mutex + buf bytes.Buffer + timer *time.Timer +} + +func newOutputFlusher(store *JobStore, job *InstallJob) *outputFlusher { + return &outputFlusher{store: store, job: job} +} + +func (f *outputFlusher) append(chunk string) { + if chunk == "" { + return + } + f.mu.Lock() + f.buf.WriteString(chunk) + // Always append to the persistent ring buffer immediately so snapshots are + // up-to-date; broadcasts are batched. ringBuffer.Write never errs. + _, _ = f.job.Output.Write([]byte(chunk)) + size := f.buf.Len() + if size >= outputFlushMaxSize { + f.mu.Unlock() + f.flush() + return + } + if f.timer == nil { + f.timer = time.AfterFunc(outputFlushPeriod, f.flush) + } + f.mu.Unlock() +} + +func (f *outputFlusher) flush() { + f.mu.Lock() + if f.timer != nil { + f.timer.Stop() + f.timer = nil + } + if f.buf.Len() == 0 { + f.mu.Unlock() + return + } + chunk := f.buf.String() + f.buf.Reset() + f.mu.Unlock() + + if f.store.hub == nil { + return + } + payload := struct { + JobID string `json:"job_id"` + AgentName string `json:"agent_name"` + Chunk string `json:"chunk"` + }{ + JobID: f.job.ID, + AgentName: f.job.AgentName, + Chunk: chunk, + } + msg, _ := ws.NewNotification(ws.ActionAgentInstallOutput, payload) + f.store.hub.Broadcast(msg) +} + +// ringBuffer is a fixed-size byte buffer that drops the oldest data when full. +// Concurrent Write + String is safe. +type ringBuffer struct { + mu sync.Mutex + data []byte + cap int +} + +func newRingBuffer(cap int) *ringBuffer { + return &ringBuffer{cap: cap, data: make([]byte, 0, cap)} +} + +func (r *ringBuffer) Write(b []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.data = append(r.data, b...) + if len(r.data) > r.cap { + excess := len(r.data) - r.cap + // Compact: drop oldest 'excess' bytes plus enough to land on a line + // boundary, so the UI doesn't see a half-line. + nlIdx := bytes.IndexByte(r.data[excess:], '\n') + if nlIdx >= 0 { + excess += nlIdx + 1 + } + if excess > len(r.data) { + excess = len(r.data) + } + r.data = append(r.data[:0], r.data[excess:]...) + } + return len(b), nil +} + +func (r *ringBuffer) String() string { + r.mu.Lock() + defer r.mu.Unlock() + return string(r.data) +} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud_test.go b/apps/backend/internal/agent/settings/controller/profile_crud_test.go index 8600de20d6..b3364e4592 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud_test.go @@ -8,14 +8,16 @@ import ( ) // TestSeedCLIFlags_FromCopilot verifies that a fresh Copilot profile gets -// the four curated CLI-flag suggestions with Enabled=false, so users see -// them in the UI but nothing changes subprocess behaviour until opt-in. +// the four curated CLI-flag suggestions seeded with the expected default +// state. `--allow-all-tools` is on by default so autonomous runs don't +// stall on per-tool-call permission prompts; the other --allow-all-* and +// --no-ask-user flags stay off as safe defaults until users opt in. func TestSeedCLIFlags_FromCopilot(t *testing.T) { ag := agents.NewCopilotACP() flags := seedCLIFlags(ag) wantFlags := map[string]bool{ - "--allow-all-tools": false, + "--allow-all-tools": true, "--allow-all-paths": false, "--allow-all-urls": false, "--no-ask-user": false, diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 027f87823e..f2eb6b9ec1 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -63,12 +63,21 @@ type ListAgentsResponse struct { } type AgentDiscoveryDTO struct { - Name string `json:"name"` - SupportsMCP bool `json:"supports_mcp"` - MCPConfigPath string `json:"mcp_config_path,omitempty"` - InstallationPaths []string `json:"installation_paths,omitempty"` - Available bool `json:"available"` - MatchedPath string `json:"matched_path,omitempty"` + Name string `json:"name"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath string `json:"mcp_config_path,omitempty"` + InstallationPaths []string `json:"installation_paths,omitempty"` + Available bool `json:"available"` + MatchedPath string `json:"matched_path,omitempty"` + LoginCommand *LoginCommandDTO `json:"login_command,omitempty"` +} + +// LoginCommandDTO describes an interactive login command surfaced to the UI. +// The frontend uses it to render a "Login" button that opens a PTY terminal +// running the named command. +type LoginCommandDTO struct { + Cmd []string `json:"cmd"` + Description string `json:"description,omitempty"` } type ListDiscoveryResponse struct { @@ -150,6 +159,7 @@ type AvailableAgentDTO struct { ModelConfig ModelConfigDTO `json:"model_config"` PermissionSettings map[string]PermissionSettingDTO `json:"permission_settings,omitempty"` PassthroughConfig *PassthroughConfigDTO `json:"passthrough_config,omitempty"` + LoginCommand *LoginCommandDTO `json:"login_command,omitempty"` UpdatedAt time.Time `json:"updated_at"` } @@ -159,6 +169,41 @@ type ListAvailableAgentsResponse struct { Total int `json:"total"` } +// InstallJobStatus represents the state of an install job. +type InstallJobStatus string + +const ( + InstallJobStatusQueued InstallJobStatus = "queued" + InstallJobStatusRunning InstallJobStatus = "running" + InstallJobStatusSucceeded InstallJobStatus = "succeeded" + InstallJobStatusFailed InstallJobStatus = "failed" +) + +// InstallJobDTO is the snapshot of an install job returned via HTTP and +// broadcast via WS. +type InstallJobDTO struct { + JobID string `json:"job_id"` + Name string `json:"agent_name"` + Status InstallJobStatus `json:"status"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// EnqueueInstallResponse is returned by POST /agent-install/:agentName. +// The install runs asynchronously; clients subscribe to WS notifications +// (agent.install.started/output/finished) or poll GET /agent-install/jobs. +type EnqueueInstallResponse struct { + JobID string `json:"job_id"` +} + +// ListInstallJobsResponse wraps the snapshot list for the jobs endpoint. +type ListInstallJobsResponse struct { + Jobs []InstallJobDTO `json:"jobs"` +} + type AgentProfileMcpConfigDTO struct { ProfileID string `json:"profile_id"` Enabled bool `json:"enabled"` diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 4fe3aaf27a..9fae20e683 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -39,6 +39,9 @@ func NewHandlers(ctrl *controller.Controller, hub Broadcaster, log *logger.Logge } func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, hub Broadcaster, log *logger.Logger) { + // Wire the install job store with the same broadcaster used by other + // agent-settings notifications so streaming install events reach the UI. + ctrl.SetJobBroadcaster(hub) handlers := NewHandlers(ctrl, hub, log) handlers.registerHTTP(router) } @@ -57,6 +60,9 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.GET("/agents/:id/logo", h.httpGetAgentLogo) api.GET("/agent-models/:agentName", h.httpGetAgentModels) api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) + api.POST("/agent-install/:agentName", h.httpInstallAgent) + api.GET("/agent-install/jobs", h.httpListInstallJobs) + api.GET("/agent-install/jobs/:id", h.httpGetInstallJob) api.PATCH("/agent-profiles/:id", h.httpUpdateProfile) api.DELETE("/agent-profiles/:id", h.httpDeleteProfile) api.GET("/agent-profiles/:id/mcp-config", h.httpGetProfileMcpConfig) @@ -85,6 +91,54 @@ func (h *Handlers) httpListAvailableAgents(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// httpInstallAgent enqueues an async install and returns the job snapshot. +// The install runs in a goroutine; clients track progress via WS +// (agent.install.started/output/finished) or by polling /agent-install/jobs/:id. +// +// Idempotent: a second POST for the same agent while a job is running returns +// the existing job_id rather than starting a duplicate. +func (h *Handlers) httpInstallAgent(c *gin.Context) { + name := strings.TrimSpace(c.Param("agentName")) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"}) + return + } + job, err := h.controller.EnqueueInstall(name) + if err != nil { + switch { + case errors.Is(err, controller.ErrAgentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + case errors.Is(err, controller.ErrInstallScriptEmpty): + c.JSON(http.StatusBadRequest, gin.H{"error": "agent has no install script"}) + case errors.Is(err, controller.ErrJobStoreUnavailable): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "install service not ready"}) + default: + h.logger.Error("failed to enqueue install", zap.String("agent", name), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enqueue install"}) + } + return + } + c.JSON(http.StatusAccepted, job) +} + +func (h *Handlers) httpListInstallJobs(c *gin.Context) { + c.JSON(http.StatusOK, dto.ListInstallJobsResponse{Jobs: h.controller.ListInstallJobs()}) +} + +func (h *Handlers) httpGetInstallJob(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "job id is required"}) + return + } + job, ok := h.controller.GetInstallJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "job not found"}) + return + } + c.JSON(http.StatusOK, job) +} + func (h *Handlers) broadcastAvailableAgentsAsync() { if h.hub == nil { return diff --git a/apps/backend/internal/health/checks.go b/apps/backend/internal/health/checks.go index b1c5639ce6..eeecb408a2 100644 --- a/apps/backend/internal/health/checks.go +++ b/apps/backend/internal/health/checks.go @@ -54,7 +54,7 @@ func (c *GitHubChecker) Check(_ context.Context) []Issue { Title: "GitHub integration unavailable", Message: "Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", Severity: SeverityWarning, - FixURL: "/settings/workspace/{workspaceId}/github", + FixURL: "/settings/integrations/github", FixLabel: "Configure GitHub", }} } @@ -65,7 +65,7 @@ func (c *GitHubChecker) Check(_ context.Context) []Issue { Title: "GitHub not authenticated", Message: "Run 'gh auth login' or add a GITHUB_TOKEN secret.", Severity: SeverityWarning, - FixURL: "/settings/workspace/{workspaceId}/github", + FixURL: "/settings/integrations/github", FixLabel: "Configure GitHub", }} } @@ -91,7 +91,7 @@ func (c *GitHubChecker) rateLimitIssues() []Issue { Title: fmt.Sprintf("GitHub API rate limit exhausted (%s)", status.Resource), Message: rateLimitMessage(status), Severity: SeverityWarning, - FixURL: "/settings/workspace/{workspaceId}/github", + FixURL: "/settings/integrations/github", FixLabel: "View status", }) } diff --git a/apps/backend/internal/health/checks_test.go b/apps/backend/internal/health/checks_test.go index 31f07dd2c5..8dcf7a5e3c 100644 --- a/apps/backend/internal/health/checks_test.go +++ b/apps/backend/internal/health/checks_test.go @@ -18,6 +18,11 @@ type mockGitHubProvider struct { func (m *mockGitHubProvider) IsAuthenticated() bool { return m.authenticated } func (m *mockGitHubProvider) AuthMethod() string { return m.authMethod } +// expectedGitHubFixURL is the live route in apps/web/app/settings/integrations/github. +// Kept here so a typo or accidental rename breaks a single test instead of +// shipping a 404 to users. +const expectedGitHubFixURL = "/settings/integrations/github" + func TestGitHubChecker_NilProvider(t *testing.T) { checker := NewGitHubChecker(nil) issues := checker.Check(context.Background()) @@ -33,6 +38,9 @@ func TestGitHubChecker_NilProvider(t *testing.T) { if issues[0].Category != "github" { t.Errorf("category = %q, want %q", issues[0].Category, "github") } + if issues[0].FixURL != expectedGitHubFixURL { + t.Errorf("FixURL = %q, want %q", issues[0].FixURL, expectedGitHubFixURL) + } } func TestGitHubChecker_NotAuthenticated(t *testing.T) { @@ -47,6 +55,9 @@ func TestGitHubChecker_NotAuthenticated(t *testing.T) { if issues[0].Severity != SeverityWarning { t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) } + if issues[0].FixURL != expectedGitHubFixURL { + t.Errorf("FixURL = %q, want %q", issues[0].FixURL, expectedGitHubFixURL) + } } func TestGitHubChecker_Authenticated(t *testing.T) { @@ -81,6 +92,9 @@ func TestGitHubChecker_AuthenticatedButRateLimited(t *testing.T) { if !strings.Contains(issues[0].Message, "resets in") { t.Errorf("message should include reset countdown, got %q", issues[0].Message) } + if issues[0].FixURL != expectedGitHubFixURL { + t.Errorf("FixURL = %q, want %q", issues[0].FixURL, expectedGitHubFixURL) + } } func TestGitHubChecker_AuthenticatedRateProviderEmpty(t *testing.T) { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index e817adc6c1..4cfa225272 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -160,6 +160,9 @@ const ( ActionTaskPlanReverted = "task.plan.reverted" ActionAgentUpdated = "agent.updated" ActionAgentAvailableUpdated = "agent.available.updated" + ActionAgentInstallStarted = "agent.install.started" + ActionAgentInstallOutput = "agent.install.output" + ActionAgentInstallFinished = "agent.install.finished" ActionWorkspaceCreated = "workspace.created" ActionWorkspaceUpdated = "workspace.updated" ActionWorkspaceDeleted = "workspace.deleted" diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 4b93d7f221..dd0f05b7ae 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { IconAlertTriangle, @@ -9,7 +9,7 @@ import { IconDownload, IconExternalLink, IconPlus, - IconSettings, + IconTerminal2, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; @@ -18,14 +18,20 @@ import { Separator } from "@kandev/ui/separator"; import { useAppStore } from "@/components/state-provider"; import { createCustomTUIAgent, + installAgent, listAgentDiscovery, listAgents, listAvailableAgents, + listInstallJobs, } from "@/lib/api"; +import type { InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { AgentLogo } from "@/components/agent-logo"; import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog"; +import { HostShellDialog } from "@/components/settings/host-shell-dialog"; +import { InstallAgentCard } from "@/components/settings/install-agent-card"; +import { InstalledAgentCard } from "@/components/settings/installed-agent-card"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; import type { AgentDiscovery, @@ -84,73 +90,33 @@ function CopyButton({ ); } -type AgentCardProps = { - agent: AgentDiscovery; - savedAgent: Agent | undefined; - displayName: string; -}; - -function AgentCard({ agent, savedAgent, displayName }: AgentCardProps) { - const configured = Boolean(savedAgent && savedAgent.profiles.length > 0); - const hasAgentRecord = Boolean(savedAgent); - return ( - - -
-
- -

{displayName}

- {agent.supports_mcp && MCP} - {configured && Configured} -
- {agent.matched_path && ( -

Detected at {agent.matched_path}

- )} -
- -
-
- ); -} - function InstallCard({ agent, copiedValue, onCopy, + job, + onInstall, }: { agent: AvailableAgent; copiedValue: string | null; onCopy: (text: string) => void; + job: InstallJob | undefined; + onInstall: (name: string) => void; }) { return ( - - -
- -

{agent.display_name}

-
- {agent.description && ( -

{agent.description}

- )} - {agent.install_script && ( + {agent.install_script} - )} -
-
+ ) : null + } + /> ); } @@ -230,6 +196,7 @@ type InstalledAgentsSectionProps = { rescanning: boolean; savedAgentsByName: Map; resolveDisplayName: (name: string) => string; + resolveCapabilityStatus: (name: string) => string | undefined; setTuiDialogOpen: (open: boolean) => void; handleRescan: () => Promise; }; @@ -240,9 +207,12 @@ function InstalledAgentsSection({ rescanning, savedAgentsByName, resolveDisplayName, + resolveCapabilityStatus, setTuiDialogOpen, handleRescan, }: InstalledAgentsSectionProps) { + const [shellOpen, setShellOpen] = useState(false); + return (
@@ -253,6 +223,16 @@ function InstalledAgentsSection({

+
+ { + // Rescan after the user closes the shell - they may have installed + // an agent CLI or fixed an auth issue inside the terminal. + void handleRescan(); + }} + /> {installedAgents.length === 0 && ( @@ -293,11 +282,13 @@ function InstalledAgentsSection({
{installedAgents.map((agent: AgentDiscovery) => ( - void handleRescan()} /> ))}
@@ -310,11 +301,15 @@ function SuggestInstallSection({ tools, copiedValue, onCopy, + installJobs, + onInstall, }: { notInstalledAgents: AvailableAgent[]; tools: ToolStatus[]; copiedValue: string | null; onCopy: (text: string) => void; + installJobs: Record; + onInstall: (name: string) => void; }) { const notInstalledTools = tools.filter((t) => !t.available); if (notInstalledAgents.length === 0 && notInstalledTools.length === 0) return null; @@ -325,13 +320,21 @@ function SuggestInstallSection({

Available to Install

- Install an agent CLI globally, then log in with the agent and click Rescan above. + Click Install to run the agent's install script on the kandev host. Progress streams + live; you can start multiple installs in parallel.

{notInstalledAgents.map((agent) => ( - + ))} {notInstalledTools.map((tool) => ( @@ -369,6 +372,73 @@ function AgentProfilesSection({ savedAgents }: AgentProfilesSectionProps) { ); } +/** + * Install state is held in the store (driven by WS events + * agent.install.{started,output,finished}). This hook: + * - Rehydrates jobs on mount so a page reload picks up in-flight installs. + * - Subscribes to agent.available.updated → calls onSuccess() to rescan. + * - Exposes handleInstall(name) which POSTs to enqueue (idempotent on the + * server: clicking again while running returns the same job_id). + */ +function useInstallAgent(onSuccess: () => Promise) { + const installJobs = useAppStore((state) => state.installJobs.byAgent); + const upsertInstallJob = useAppStore((state) => state.upsertInstallJob); + + useEffect(() => { + let cancelled = false; + listInstallJobs() + .then((resp) => { + if (cancelled) return; + // Upsert per-job rather than wholesale-replace: if a WS event + // already seeded an in-flight job with output chunks between page + // mount and this HTTP response, the snapshot from the server may + // be older, and a full replace would clobber the live output. + for (const job of resp.jobs) upsertInstallJob(job); + }) + .catch(() => { + /* page mount; ignore */ + }); + return () => { + cancelled = true; + }; + }, [upsertInstallJob]); + + // When any install finishes successfully, trigger the page-level rescan so + // the agent disappears from "Available to Install" and shows up under + // "Installed Agents". + useEffect(() => { + const succeeded = Object.values(installJobs).filter((j) => j.status === "succeeded"); + if (succeeded.length > 0) { + void onSuccess(); + } + // Intentionally only depends on the count of succeeded jobs to avoid + // re-firing on every output chunk during a running install. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [Object.values(installJobs).filter((j) => j.status === "succeeded").length]); + + const handleInstall = useCallback( + async (name: string) => { + try { + const job = await installAgent(name); + // The WS event will normally arrive first, but seed the store in case + // the WS round-trip is slower than the HTTP response. + upsertInstallJob(job); + } catch (err) { + upsertInstallJob({ + job_id: `local-error-${name}`, + agent_name: name, + status: "failed", + error: err instanceof Error ? err.message : String(err), + started_at: new Date().toISOString(), + }); + } + }, + [upsertInstallJob], + ); + + return { installJobs, handleInstall }; +} + function useAgentPageState() { const { items: discoveryAgents, loading: discoveryLoading } = useAgentDiscovery(); const savedAgents = useAppStore((state) => state.settingsAgents.items); @@ -394,6 +464,8 @@ function useAgentPageState() { ); const resolveDisplayName = (name: string) => availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? name; + const resolveCapabilityStatus = (name: string) => + availableAgents.find((item: AvailableAgent) => item.name === name)?.model_config?.status; const handleRescan = async () => { if (rescanning) return; @@ -410,6 +482,8 @@ function useAgentPageState() { } }; + const { installJobs, handleInstall } = useInstallAgent(handleRescan); + const handleCreateCustomTUI = async (data: { display_name: string; model?: string; @@ -442,8 +516,11 @@ function useAgentPageState() { tuiDialogOpen, setTuiDialogOpen, resolveDisplayName, + resolveCapabilityStatus, handleRescan, handleCreateCustomTUI, + installJobs, + handleInstall, }; } @@ -459,8 +536,11 @@ export default function AgentsSettingsPage() { tuiDialogOpen, setTuiDialogOpen, resolveDisplayName, + resolveCapabilityStatus, handleRescan, handleCreateCustomTUI, + installJobs, + handleInstall, } = useAgentPageState(); const { copiedValue, copy } = useCopyCommand(); @@ -481,6 +561,7 @@ export default function AgentsSettingsPage() { rescanning={rescanning} savedAgentsByName={savedAgentsByName} resolveDisplayName={resolveDisplayName} + resolveCapabilityStatus={resolveCapabilityStatus} setTuiDialogOpen={setTuiDialogOpen} handleRescan={handleRescan} /> @@ -490,6 +571,8 @@ export default function AgentsSettingsPage() { tools={tools} copiedValue={copiedValue} onCopy={copy} + installJobs={installJobs} + onInstall={handleInstall} /> diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 5082949d6f..c2fd5ccd99 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -1,6 +1,12 @@ import { SettingsLayoutClient } from "@/components/settings/settings-layout-client"; import { StateHydrator } from "@/components/state-hydrator"; -import { listAgents, listExecutors, listWorkspaces } from "@/lib/api"; +import { + listAgentDiscovery, + listAgents, + listAvailableAgents, + listExecutors, + listWorkspaces, +} from "@/lib/api"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; export default function SettingsLayout({ children }: { children: React.ReactNode }) { @@ -10,10 +16,15 @@ export default function SettingsLayout({ children }: { children: React.ReactNode async function SettingsLayoutServer({ children }: { children: React.ReactNode }) { let initialState = {}; try { - const [workspaces, executors, agents] = await Promise.all([ + // Fetch discovery + available agents alongside the DB-backed list so a + // hard refresh of /settings/agents/[name] (where no profile exists yet) + // can still render the agent from the discovered set. + const [workspaces, executors, agents, discovery, available] = await Promise.all([ listWorkspaces({ cache: "no-store" }), listExecutors({ cache: "no-store" }), listAgents({ cache: "no-store" }), + listAgentDiscovery({ cache: "no-store" }), + listAvailableAgents({ cache: "no-store" }), ]); initialState = { workspaces: { @@ -38,6 +49,17 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode }) settingsAgents: { items: agents.agents, }, + agentDiscovery: { + items: discovery.agents, + loading: false, + loaded: true, + }, + availableAgents: { + items: available.agents, + tools: available.tools ?? [], + loading: false, + loaded: true, + }, settingsData: { executorsLoaded: true, agentsLoaded: true, diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 238a629a3f..90abc4a313 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -3,22 +3,30 @@ import { useState, useCallback } from "react"; import { IconCheck, + IconChevronDown, + IconChevronRight, + IconInfoCircle, IconX, IconRefresh, IconKey, IconTrash, IconEye, IconEyeOff, + IconTerminal2, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; import { Input } from "@kandev/ui/input"; import { Spinner } from "@kandev/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useAppStore } from "@/components/state-provider"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useToast } from "@/components/toast-provider"; import { configureGitHubToken, clearGitHubToken } from "@/lib/api/domains/github-api"; import type { AuthDiagnostics } from "@/lib/types/github"; import { GitHubRateLimitDisplay } from "./github-rate-limit"; +import { HostShellDialog } from "@/components/settings/host-shell-dialog"; function DiagnosticsOutput({ diagnostics }: { diagnostics: AuthDiagnostics }) { return ( @@ -123,10 +131,183 @@ function TokenConfigForm({ onSuccess }: { onSuccess: () => void }) { ); } +type StatusForNotConnected = { + diagnostics?: AuthDiagnostics; +}; + +function NotConnectedView({ + status, + refresh, + ghAuthOpen, + setGhAuthOpen, +}: { + status: StatusForNotConnected | null; + refresh: () => void; + ghAuthOpen: boolean; + setGhAuthOpen: (open: boolean) => void; +}) { + // gh installed → CLI sign-in is the recommended path (browser-driven OAuth, + // no PAT to manage). When it's missing we drop to PAT-only. + const ghInstalled = useAppStore((state) => + state.availableAgents.tools.some((t) => t.name === "gh" && t.available), + ); + const [tokenOpen, setTokenOpen] = useState(false); + const [diagOpen, setDiagOpen] = useState(false); + + return ( +
+
+ + Not connected to GitHub + + + + + + +

+ Kandev also accepts a GITHUB_TOKEN environment variable on startup. The + UI options below cover the common cases. +

+
+
+
+ + {ghInstalled ? ( + setGhAuthOpen(true)} /> + ) : ( + + )} + + + + + + + {/* TokenConfigForm already renders the "Create a Personal Access + Token with repo + read:org scopes" hint below its input. */} + + + + + {status?.diagnostics && ( + + )} + + +
+ ); +} + +function DiagnosticsDisclosure({ + diagnostics, + open, + onOpenChange, +}: { + diagnostics: AuthDiagnostics; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + + + + + + ); +} + +function PrimaryCLISignIn({ onClick }: { onClick: () => void }) { + return ( +
+
+

Sign in with the GitHub CLI

+

+ Recommended. Opens a terminal that runs{" "} + gh auth login with browser-based + authentication. +

+
+ +
+ ); +} + +function CLIUnavailableHint() { + return ( +
+

+ The gh CLI is not installed on this host - a + personal access token is the only sign-in option available here. To install it later, see{" "} + + cli.github.com + + . +

+
+ ); +} + export function GitHubStatusCard() { const { status, loaded, loading, refresh } = useGitHubStatus(); const { toast } = useToast(); const [clearing, setClearing] = useState(false); + const [ghAuthOpen, setGhAuthOpen] = useState(false); const handleClearToken = useCallback(async () => { setClearing(true); @@ -152,38 +333,12 @@ export function GitHubStatusCard() { if (!status || !status.authenticated) { return ( -
-
- - Not connected to GitHub - -
- - {/* Token configuration form */} -
-

Configure GitHub Token

- -
- - {/* Alternative methods */} -
-

Other authentication methods:

-
    -
  • - Environment variable — set{" "} - GITHUB_TOKEN when starting Kandev -
  • -
  • - GitHub CLI — run{" "} - gh auth login -
  • -
-
- {status?.diagnostics && } -
+ ); } diff --git a/apps/web/components/settings/agent-login-dialog.tsx b/apps/web/components/settings/agent-login-dialog.tsx new file mode 100644 index 0000000000..fead4db017 --- /dev/null +++ b/apps/web/components/settings/agent-login-dialog.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useCallback } from "react"; +import { startAgentLogin } from "@/lib/api"; +import { PtyTerminalDialog, type StartPtySession } from "@/components/settings/pty-terminal-dialog"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + agentName: string; + /** Optional human-readable description rendered above the terminal. */ + description?: string; + /** Argv of the login command. Surfaced in the dialog so the user can see + * (and re-run) the actual command after Ctrl+C drops them into a shell. */ + command?: string[]; + /** Called when the user clicks Done. Used to trigger a capability rescan. */ + onLoginSuccess?: () => void; +}; + +/** + * Opens a PTY-backed terminal running an agent's login command on the kandev + * host. Thin wrapper over - the only thing login-specific + * is the start endpoint. + */ +export function AgentLoginDialog({ + open, + onOpenChange, + agentName, + description, + command, + onLoginSuccess, +}: Props) { + const startSession: StartPtySession = useCallback( + (size, options) => startAgentLogin(agentName, size, options), + [agentName], + ); + + return ( + + ); +} diff --git a/apps/web/components/settings/host-shell-dialog.tsx b/apps/web/components/settings/host-shell-dialog.tsx new file mode 100644 index 0000000000..c17837d7bf --- /dev/null +++ b/apps/web/components/settings/host-shell-dialog.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { startHostShell } from "@/lib/api"; +import { PtyTerminalDialog } from "@/components/settings/pty-terminal-dialog"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Optional callback when the user clicks Done (e.g. trigger a rescan). */ + onClose?: () => void; + /** + * If set, this text is sent into the shell on connect - useful for + * pre-filling a recovery command from an auth banner. + */ + initialInput?: string; +}; + +/** + * Opens a PTY-backed terminal running the user's shell on the kandev host. + * Used for ad-hoc setup commands that don't fit the install/login flows. + */ +export function HostShellDialog({ open, onOpenChange, onClose, initialInput }: Props) { + return ( + + ); +} diff --git a/apps/web/components/settings/install-agent-card.tsx b/apps/web/components/settings/install-agent-card.tsx new file mode 100644 index 0000000000..6ebf2a9383 --- /dev/null +++ b/apps/web/components/settings/install-agent-card.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { IconDownload, IconLoader2 } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { AgentLogo } from "@/components/agent-logo"; +import type { InstallJob } from "@/lib/api"; +import type { AvailableAgent } from "@/lib/types/http"; + +type InstallStatus = InstallJob["status"] | "idle"; + +function installButtonContent(status: InstallStatus): { + icon: "spinner" | "download"; + label: string; +} { + switch (status) { + case "queued": + return { icon: "spinner", label: "Queued…" }; + case "running": + return { icon: "spinner", label: "Installing…" }; + case "failed": + return { icon: "download", label: "Retry" }; + default: + return { icon: "download", label: "Install" }; + } +} + +function InstallButton({ + agentName, + status, + onInstall, +}: { + agentName: string; + status: InstallStatus; + onInstall: (name: string) => void; +}) { + const isInFlight = status === "queued" || status === "running"; + const btn = installButtonContent(status); + return ( + + ); +} + +/** + * Card shown under "Available to Install" on the Agents settings page. While a + * job is queued/running it shows a live log streamed via the agent.install.* + * WS events. On failure it surfaces the install script's output + error. + */ +export function InstallAgentCard({ + agent, + job, + scriptSlot, + onInstall, +}: { + agent: AvailableAgent; + job: InstallJob | undefined; + /** The copy-and-script row, rendered above the Install button by the parent. */ + scriptSlot?: React.ReactNode; + onInstall: (name: string) => void; +}) { + const status: InstallStatus = job?.status ?? "idle"; + const failed = status === "failed"; + const showLog = Boolean(job?.output) && (status === "queued" || status === "running" || failed); + + return ( + + +
+ +

{agent.display_name}

+
+ {agent.description && ( +

{agent.description}

+ )} + {scriptSlot} + {agent.install_script && ( + + )} + {showLog && ( +
+            {job?.output}
+          
+ )} + {failed && job?.error && ( +

+ {job.error} +

+ )} +
+
+ ); +} diff --git a/apps/web/components/settings/installed-agent-card.tsx b/apps/web/components/settings/installed-agent-card.tsx new file mode 100644 index 0000000000..8dbbd33bbb --- /dev/null +++ b/apps/web/components/settings/installed-agent-card.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { IconLoader2, IconLock, IconSettings } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { AgentLogo } from "@/components/agent-logo"; +import { AgentLoginDialog } from "@/components/settings/agent-login-dialog"; +import { HostShellDialog } from "@/components/settings/host-shell-dialog"; +import type { Agent, AgentDiscovery } from "@/lib/types/http"; + +type Props = { + agent: AgentDiscovery; + savedAgent: Agent | undefined; + displayName: string; + /** Capability status from the host utility probe ("ok", "auth_required", etc.). */ + capabilityStatus?: string; + /** + * Called when the auth/shell dialog closes so the page can refresh + * discovery + availability. Without this the yellow lock stays put even + * after a successful sign-in, making the recovery flow look broken. + */ + onAuthComplete?: () => void; +}; + +/** + * Card rendered under "Installed Agents" - links to the agent profile + * editor and surfaces a yellow lock icon when the capability probe reports + * `auth_required`. Clicking the lock opens a PTY login dialog if the agent + * type has a registered LoginCommand. + */ +export function InstalledAgentCard({ + agent, + savedAgent, + displayName, + capabilityStatus, + onAuthComplete, +}: Props) { + const configured = Boolean(savedAgent && savedAgent.profiles.length > 0); + const hasAgentRecord = Boolean(savedAgent); + const [loginOpen, setLoginOpen] = useState(false); + const [shellOpen, setShellOpen] = useState(false); + const authRequired = capabilityStatus === "auth_required"; + const probing = capabilityStatus === "probing"; + const loginAvailable = Boolean(agent.login_command); + + // Either we have a registered login command (open the dedicated login PTY) + // or we don't (open a plain host shell so the user can explore via + // ` --help`, run their own auth recipe, etc.). + const handleAuthClick = () => { + if (loginAvailable) setLoginOpen(true); + else setShellOpen(true); + }; + + return ( + + +
+
+ +

{displayName}

+ {agent.supports_mcp && MCP} + {configured && Configured} + {probing && ( + + + + + + + Checking agent capabilities and authentication... + + )} + {authRequired && ( + + + + + + {loginAvailable + ? "Authentication required - click to open login terminal" + : "Authentication required - click to open a shell and sign in"} + + + )} +
+ {/* Reserve two lines so cards align regardless of path length. */} +

+ {agent.matched_path ? `Detected at ${agent.matched_path}` : ""} +

+
+ + +
+
+ ); +} + +function AuthDialogs({ + agent, + loginOpen, + setLoginOpen, + shellOpen, + setShellOpen, + loginAvailable, + onAuthComplete, +}: { + agent: AgentDiscovery; + loginOpen: boolean; + setLoginOpen: (open: boolean) => void; + shellOpen: boolean; + setShellOpen: (open: boolean) => void; + loginAvailable: boolean; + onAuthComplete?: () => void; +}) { + if (loginAvailable) { + return ( + + ); + } + return ; +} diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index 908d6b0954..4c3a32b04f 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -1,12 +1,7 @@ "use client"; -import { - IconRefresh, - IconAlertCircle, - IconLock, - IconPackageOff, - IconTerminal2, -} from "@tabler/icons-react"; +import { IconAlertCircle, IconRefresh, IconTerminal2 } from "@tabler/icons-react"; +import { NoAuthPanel, ProbingPanel } from "@/components/settings/profile-status-panels"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -317,79 +312,6 @@ function CommandsButton({ commands }: { commands: CommandEntry[] }) { ); } -function NoAuthPanel({ - agentName, - status, - isLoading, - onRefresh, - error, - rawError, -}: { - agentName: string; - status: "auth_required" | "not_installed"; - isLoading: boolean; - onRefresh: () => Promise; - error: string | null; - rawError: string | null; -}) { - const isAuth = status === "auth_required"; - const Icon = isAuth ? IconLock : IconPackageOff; - const title = isAuth ? "No auth — login required" : "Not installed"; - const hint = isAuth ? ( - <> - Run {agentName} login in your - terminal, then click Refresh. - - ) : ( - <>Install the agent CLI, then click Refresh. - ); - const detail = error || rawError; - return ( -
- -
-
-

{title}

- {detail && ( - - - - - -

{detail}

-
-
- )} -
-

{hint}

-
- -
- ); -} - function CapabilitiesRow({ profile, models, @@ -436,6 +358,9 @@ function CapabilitiesRow({ } const status = modelConfig.status; + if (status === "probing") { + return ; + } if (status === "auth_required" || status === "not_installed") { return ( + +

+ Probing agent capabilities… this usually takes a few seconds. +

+
+ ); +} + +function noAuthHint({ isAuth, canLogin }: { isAuth: boolean; canLogin: boolean }): React.ReactNode { + if (!isAuth) { + return "Install the agent CLI, then click Refresh."; + } + if (canLogin) { + return "Click Open terminal to sign in, then refresh."; + } + return "Click Open terminal to launch a shell, run the agent's sign-in command, then refresh."; +} + +function NoAuthActions({ + showTerminal, + onOpenTerminal, + onRefresh, + isLoading, +}: { + showTerminal: boolean; + onOpenTerminal: () => void; + onRefresh: () => Promise; + isLoading: boolean; +}) { + return ( +
+ {showTerminal && ( + + )} + +
+ ); +} + +export function NoAuthPanel({ + agentName, + status, + isLoading, + onRefresh, + error, + rawError, +}: { + agentName: string; + status: "auth_required" | "not_installed"; + isLoading: boolean; + onRefresh: () => Promise; + error: string | null; + rawError: string | null; +}) { + const isAuth = status === "auth_required"; + const Icon = isAuth ? IconLock : IconPackageOff; + const title = isAuth ? "No auth - login required" : "Not installed"; + const detail = error || rawError; + const [loginOpen, setLoginOpen] = useState(false); + const [shellOpen, setShellOpen] = useState(false); + + // Drives the "Open terminal" button. When the agent registers a + // LoginCommand we open the dedicated login dialog (pre-fills the command); + // otherwise we drop the user into a plain host shell so they can explore + // (e.g. ` --help`) and run whatever sign-in flow the CLI documents. + const loginCommand = useAppStore((state) => + state.availableAgents.items.find((a) => a.name === agentName), + )?.login_command; + const canLogin = isAuth && Boolean(loginCommand); + const showTerminal = isAuth; + const hint = noAuthHint({ isAuth, canLogin }); + const handleOpenTerminal = () => { + if (canLogin) setLoginOpen(true); + else setShellOpen(true); + }; + + return ( +
+ +
+
+

{title}

+ {detail && ( + + + + + +

{detail}

+
+
+ )} +
+

{hint}

+
+ + {canLogin && ( + { + void onRefresh(); + }} + /> + )} + {showTerminal && !canLogin && ( + { + void onRefresh(); + }} + /> + )} +
+ ); +} diff --git a/apps/web/components/settings/pty-terminal-dialog.tsx b/apps/web/components/settings/pty-terminal-dialog.tsx new file mode 100644 index 0000000000..6a15eb95ac --- /dev/null +++ b/apps/web/components/settings/pty-terminal-dialog.tsx @@ -0,0 +1,359 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import "@xterm/xterm/css/xterm.css"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Button } from "@kandev/ui/button"; +import { + agentLoginStreamUrl, + resizeAgentLogin, + stopAgentLogin, + type AgentLoginSession, +} from "@/lib/api"; +import type { ApiRequestOptions } from "@/lib/api"; + +type SessionStatus = "connecting" | "running" | "exited" | "error"; + +type LoginStateSetters = { + setStatus: (s: SessionStatus) => void; + setError: (e: string | null) => void; + setExitCode: (c: number | null) => void; +}; + +/** + * Caller-supplied start function. Returns the same session shape regardless + * of which underlying endpoint (agent-login vs host-shell) is being driven - + * once a session exists, stop/resize/stream all share the same session-ID + * routes on the backend. + */ +export type StartPtySession = ( + size: { cols: number; rows: number }, + options?: ApiRequestOptions, +) => Promise; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + /** data-testid prefix for the terminal container element. */ + testIdPrefix?: string; + startSession: StartPtySession; + /** Invoked when the user clicks Done. */ + onDone?: () => void; + /** + * If set, this text is sent to the shell once the WS connects. Used by + * auth flows that want to pre-fill a command like `claude auth login`. + * No trailing newline appended - the user can review and press Enter. + */ + initialInput?: string; + /** + * argv of the actual command running in the PTY (e.g. ["codex", "login", + * "--device-auth"]). Shown above the terminal so the user can see/copy + * it - useful after Ctrl+C drops them into a shell and they want to retry. + */ + command?: string[]; +}; + +function createTerminal(container: HTMLDivElement): { term: Terminal; fit: FitAddon } { + const term = new Terminal({ + convertEol: true, + cursorBlink: true, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Courier New", monospace', + fontSize: 13, + theme: { background: "#0b0b0c" }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + // Make http(s) URLs clickable - agents like codex print OAuth URLs that + // span multiple wrapped lines, and the user shouldn't have to copy/paste. + term.loadAddon( + new WebLinksAddon((event, uri) => { + // Open in a new tab. Holding the modifier (Cmd/Ctrl) is xterm's default + // gesture, but addon-web-links doesn't require it - any click works. + event.preventDefault(); + window.open(uri, "_blank", "noopener,noreferrer"); + }), + ); + term.open(container); + fit.fit(); + return { term, fit }; +} + +function wireResize( + container: HTMLDivElement, + fitRef: React.RefObject, + termRef: React.RefObject, + sessionIDRef: React.RefObject, + wsRef: React.RefObject, +): ResizeObserver { + const obs = new ResizeObserver(() => { + if (!fitRef.current || !termRef.current) return; + try { + fitRef.current.fit(); + } catch { + return; + } + const id = sessionIDRef.current; + if (!id) return; + const cols = termRef.current.cols; + const rows = termRef.current.rows; + void resizeAgentLogin(id, { cols, rows }).catch(() => {}); + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "resize", cols, rows })); + } + }); + obs.observe(container); + return obs; +} + +function makeWsMessageHandler(term: Terminal, setters: LoginStateSetters) { + return (ev: MessageEvent) => { + if (typeof ev.data === "string") { + try { + const msg = JSON.parse(ev.data) as { type: string; exit_code?: number }; + if (msg.type === "exit") { + setters.setStatus("exited"); + setters.setExitCode(msg.exit_code ?? null); + } + } catch { + // ignore non-JSON text frames + } + return; + } + term.write(new Uint8Array(ev.data as ArrayBuffer)); + }; +} + +function openSessionWebSocket( + sessionID: string, + term: Terminal, + setters: LoginStateSetters, + initialInput: string | undefined, +): WebSocket { + const ws = new WebSocket(agentLoginStreamUrl(sessionID)); + ws.binaryType = "arraybuffer"; + ws.onmessage = makeWsMessageHandler(term, setters); + ws.onerror = () => { + setters.setError("Connection error"); + setters.setStatus("error"); + }; + if (initialInput) { + ws.addEventListener( + "open", + () => { + ws.send(new TextEncoder().encode(initialInput)); + }, + { once: true }, + ); + } + return ws; +} + +type MountArgs = { + container: HTMLDivElement; + startSession: StartPtySession; + setters: LoginStateSetters; + termRef: React.RefObject; + fitRef: React.RefObject; + wsRef: React.RefObject; + sessionIDRef: React.RefObject; + initialInput?: string; +}; + +function mountSession(args: MountArgs): () => void { + const term = createTerminal(args.container); + args.termRef.current = term.term; + args.fitRef.current = term.fit; + let cancelled = false; + // AbortController cancels an in-flight start POST so React StrictMode's + // double-mount can't leave a half-spawned session behind. + const startAbort = new AbortController(); + + void (async () => { + try { + const sess = await args.startSession( + { cols: term.term.cols, rows: term.term.rows }, + { init: { signal: startAbort.signal } }, + ); + if (cancelled) { + await stopAgentLogin(sess.session_id); + return; + } + args.sessionIDRef.current = sess.session_id; + args.wsRef.current = openSessionWebSocket( + sess.session_id, + term.term, + args.setters, + args.initialInput, + ); + args.setters.setStatus("running"); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + if (!cancelled) { + args.setters.setError(err instanceof Error ? err.message : String(err)); + args.setters.setStatus("error"); + } + } + })(); + + const resizeObs = wireResize( + args.container, + args.fitRef, + args.termRef, + args.sessionIDRef, + args.wsRef, + ); + const dataDisp = term.term.onData((data) => { + if (args.wsRef.current && args.wsRef.current.readyState === WebSocket.OPEN) { + args.wsRef.current.send(new TextEncoder().encode(data)); + } + }); + + return () => { + cancelled = true; + startAbort.abort(); + resizeObs.disconnect(); + dataDisp.dispose(); + if (args.sessionIDRef.current) { + void stopAgentLogin(args.sessionIDRef.current).catch(() => {}); + args.sessionIDRef.current = null; + } + if (args.wsRef.current) { + args.wsRef.current.close(); + args.wsRef.current = null; + } + term.term.dispose(); + args.termRef.current = null; + args.fitRef.current = null; + }; +} + +function PtySessionView({ + startSession, + testIdPrefix, + initialInput, + onDone, +}: { + startSession: StartPtySession; + testIdPrefix?: string; + initialInput?: string; + onDone: () => void; +}) { + const termContainerRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const wsRef = useRef(null); + const sessionIDRef = useRef(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState("connecting"); + const [exitCode, setExitCode] = useState(null); + + useEffect(() => { + const container = termContainerRef.current; + if (!container) return; + return mountSession({ + container, + startSession, + setters: { setStatus, setError, setExitCode }, + termRef, + fitRef, + wsRef, + sessionIDRef, + initialInput, + }); + }, [startSession, initialInput]); + + return ( + <> +
+ {status === "connecting" && ( +

Starting session…

+ )} + {status === "exited" && ( +

+ Session ended{exitCode != null ? ` (exit ${exitCode})` : ""}. +

+ )} + {status === "error" && error &&

{error}

} + + + + + ); +} + +/** + * Generic dialog that hosts a PTY-backed terminal. Mounts the inner + * `` only while `open` is true - closing the dialog unmounts + * it, which fires cleanup (stop session, dispose terminal). + */ +export function PtyTerminalDialog({ + open, + onOpenChange, + title, + description, + testIdPrefix, + startSession, + onDone, + initialInput, + command, +}: Props) { + const handleDone = () => { + onDone?.(); + onOpenChange(false); + }; + + const cmdLine = command && command.length > 0 ? command.join(" ") : null; + + return ( + + + + {title} + {description && {description}} + + {cmdLine && ( +
+ $ + + {cmdLine} + +
+ )} + {open && ( + + )} +
+
+ ); +} diff --git a/apps/web/components/task/chat/messages/action-message.tsx b/apps/web/components/task/chat/messages/action-message.tsx index a291fdc491..0eb2b21119 100644 --- a/apps/web/components/task/chat/messages/action-message.tsx +++ b/apps/web/components/task/chat/messages/action-message.tsx @@ -19,6 +19,7 @@ import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { deleteTask } from "@/lib/api/domains/kanban-api"; import { AuthMethodsPanel, GenericAuthPanel } from "./auth-methods-panel"; +import { HostShellDialog } from "@/components/settings/host-shell-dialog"; import type { Message, TaskSessionState } from "@/lib/types/http"; import type { MessageAction, RecoveryAuthMethod } from "@/components/task/chat/types"; @@ -82,14 +83,23 @@ export const ActionMessage = memo(function ActionMessage({ }); function ActionMessageDetails({ metadata }: { metadata: ActionMeta | undefined }) { - const store = useAppStoreApi(); - const openTerminalWithCommand = useCallback( - (command: string) => store.getState().openBottomTerminalWithCommand(command), - [store], - ); - const openBottomTerminal = useCallback(() => { - if (!store.getState().bottomTerminal.isOpen) store.getState().toggleBottomTerminal(); - }, [store]); + const [hostShellOpen, setHostShellOpen] = useState(false); + const [hostShellCommand, setHostShellCommand] = useState(undefined); + + // Auth recovery uses the kandev host shell (where the agent CLIs are + // installed), not the task environment shell - the task env often isn't + // ready when an auth error fires (no workspace path yet), and the user's + // agent auth state lives in their home dir on the host anyway. + const openHostShellWithCommand = useCallback((command: string) => { + // Trailing newline runs the command immediately. Drop it if you'd rather + // let the user review first. + setHostShellCommand(command + "\n"); + setHostShellOpen(true); + }, []); + const openHostShell = useCallback(() => { + setHostShellCommand(undefined); + setHostShellOpen(true); + }, []); if (!metadata) return null; return ( @@ -102,12 +112,17 @@ function ActionMessageDetails({ metadata }: { metadata: ActionMeta | undefined } {metadata.is_auth_error && metadata.auth_methods && metadata.auth_methods.length > 0 && ( )} {metadata.is_auth_error && (!metadata.auth_methods || metadata.auth_methods.length === 0) && ( - + )} + ); } diff --git a/apps/web/e2e/tests/settings/agent-install-streaming.spec.ts b/apps/web/e2e/tests/settings/agent-install-streaming.spec.ts new file mode 100644 index 0000000000..5c20925ed5 --- /dev/null +++ b/apps/web/e2e/tests/settings/agent-install-streaming.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from "../../fixtures/test-base"; + +/** + * End-to-end coverage for the streaming agent install flow: + * + * POST /api/v1/agent-install/ enqueues a job, returns job_id + * WS /api/v1/ws broadcasts agent.install.* + * GET /api/v1/agent-install/jobs/:id snapshot for late joiners + * + * mock-agent has a deterministic InstallScript (echo + && chain) so this + * test exercises the real exec.Cmd path without depending on npm. + */ +test.describe("agent install streaming", () => { + test("POST returns a job_id, WS streams started+output+finished, GET reflects success", async ({ + backend, + }) => { + test.setTimeout(15_000); + + const ws = await openGatewayWS(backend.baseUrl); + const events: Array<{ action: string; payload: unknown }> = []; + ws.addEventListener("message", (ev) => { + try { + const msg = JSON.parse(ev.data as string); + if (typeof msg.action === "string" && msg.action.startsWith("agent.install.")) { + events.push({ action: msg.action, payload: msg.payload }); + } + } catch { + // ignore + } + }); + + const enqueueRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { + method: "POST", + }); + expect(enqueueRes.status).toBe(202); + const job = (await enqueueRes.json()) as { job_id: string; status: string }; + expect(job.job_id).toBeTruthy(); + + // Wait for the finished event (or timeout). + await waitFor( + () => events.some((e) => e.action === "agent.install.finished"), + 8_000, + "agent.install.finished", + ); + + ws.close(); + + const actions = events.map((e) => e.action); + expect(actions).toContain("agent.install.started"); + expect(actions).toContain("agent.install.finished"); + + const finishedEvent = events.find((e) => e.action === "agent.install.finished"); + expect(finishedEvent).toBeTruthy(); + const finishedPayload = finishedEvent!.payload as { + job_id: string; + status: string; + output?: string; + }; + expect(finishedPayload.job_id).toBe(job.job_id); + expect(finishedPayload.status).toBe("succeeded"); + expect(finishedPayload.output ?? "").toContain("mock-install: done"); + + // GET snapshot reflects success. + const snapRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/jobs/${job.job_id}`); + expect(snapRes.status).toBe(200); + const snap = (await snapRes.json()) as { status: string }; + expect(snap.status).toBe("succeeded"); + }); + + test("POST while a job is running returns the same job_id (idempotent)", async ({ backend }) => { + test.setTimeout(15_000); + + // Note: mock-agent's script is fast (~10ms), so the second POST usually + // races to land before the first finishes. We accept either outcome: + // same job_id (still running) OR different job_id with first already in + // retention. The contract under test is: no duplicate concurrent runs. + const first = (await ( + await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { method: "POST" }) + ).json()) as { job_id: string }; + + const second = (await ( + await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { method: "POST" }) + ).json()) as { job_id: string }; + + // If both raced before finish they must share IDs; if first already + // exited then second's ID is allowed to differ. + if (first.job_id !== second.job_id) { + const firstSnap = await fetch( + `${backend.baseUrl}/api/v1/agent-install/jobs/${first.job_id}`, + ).then((r) => r.json() as Promise<{ status: string }>); + expect(["succeeded", "failed"]).toContain(firstSnap.status); + } + }); + + test("GET /agent-install/jobs lists recent jobs for rehydration", async ({ backend }) => { + test.setTimeout(15_000); + + // Trigger an install so there's at least one job in retention. + const enqueueRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { + method: "POST", + }); + const job = (await enqueueRes.json()) as { job_id: string }; + + // Wait for it to finish (mock script is fast). + let finalStatus = ""; + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const snap = (await ( + await fetch(`${backend.baseUrl}/api/v1/agent-install/jobs/${job.job_id}`) + ).json()) as { status: string }; + if (snap.status === "succeeded" || snap.status === "failed") { + finalStatus = snap.status; + break; + } + await new Promise((r) => setTimeout(r, 50)); + } + expect(["succeeded", "failed"]).toContain(finalStatus); + + const listRes = await fetch(`${backend.baseUrl}/api/v1/agent-install/jobs`); + expect(listRes.status).toBe(200); + const list = (await listRes.json()) as { + jobs: Array<{ job_id: string; agent_name: string; status: string }>; + }; + expect(Array.isArray(list.jobs)).toBe(true); + const found = list.jobs.find((j) => j.job_id === job.job_id); + expect(found).toBeTruthy(); + expect(found!.agent_name).toBe("mock-agent"); + }); + + test("after install probe completes, agent.available.updated is broadcast", async ({ + backend, + }) => { + test.setTimeout(20_000); + + // Open the gateway WS *before* triggering the install so we don't miss + // the broadcast. + const ws = await openGatewayWS(backend.baseUrl); + let availableUpdates = 0; + ws.addEventListener("message", (ev) => { + try { + const msg = JSON.parse(ev.data as string); + if (msg.action === "agent.available.updated") availableUpdates++; + } catch { + // ignore + } + }); + + await fetch(`${backend.baseUrl}/api/v1/agent-install/mock-agent`, { method: "POST" }); + + // The controller fires a `Refresh` after install and then broadcasts + // `agent.available.updated`. The mock-agent's host-utility probe may + // succeed or fail (it isn't a real ACP agent under e2e), but the + // broadcast fires either way. + await waitFor(() => availableUpdates >= 1, 15_000, "agent.available.updated"); + + ws.close(); + }); +}); + +/** Open a WebSocket to the gateway and resolve when the connection is up. */ +async function openGatewayWS(baseUrl: string): Promise { + const wsUrl = baseUrl.replace(/^http/, "ws") + "/ws"; + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error("ws error")), { once: true }); + }); + return ws; +} + +async function waitFor(check: () => boolean, timeoutMs: number, label: string) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error(`Timed out waiting for ${label}`); +} diff --git a/apps/web/e2e/tests/settings/agent-login-pty.spec.ts b/apps/web/e2e/tests/settings/agent-login-pty.spec.ts new file mode 100644 index 0000000000..9bfd88f715 --- /dev/null +++ b/apps/web/e2e/tests/settings/agent-login-pty.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from "../../fixtures/test-base"; + +/** + * End-to-end coverage for the login-PTY flow: + * + * POST /api/v1/agent-login//start → spawns PTY, returns session_id + * WS /api/v1/agent-login//stream → bi-directional binary I/O + * POST /api/v1/agent-login//stop → terminates and sends exit msg + * + * mock-agent's LoginCommand is `cat`, which echoes whatever we send back. + * The PTY echo flag also enables terminal-style local echo, so a single + * write of "ping\n" is reflected at least once in the readback. + */ +test.describe("agent login PTY", () => { + test("start → echo input → stop", async ({ backend }) => { + test.setTimeout(15_000); + + const startRes = await fetch(`${backend.baseUrl}/api/v1/agent-login/agents/mock-agent/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cols: 80, rows: 24 }), + }); + expect(startRes.status).toBe(200); + const session = (await startRes.json()) as { + session_id: string; + running: boolean; + cmd: string[]; + }; + expect(session.session_id).toBeTruthy(); + expect(session.running).toBe(true); + expect(session.cmd).toContain("cat"); + + const wsUrl = + backend.baseUrl.replace(/^http/, "ws") + + `/api/v1/agent-login/sessions/${session.session_id}/stream`; + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + + let received = ""; + let exitedCleanly = false; + let exitCode: number | null = null; + + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error("ws error")), { once: true }); + }); + + ws.addEventListener("message", (ev) => { + if (typeof ev.data === "string") { + try { + const msg = JSON.parse(ev.data); + if (msg.type === "exit") { + exitedCleanly = true; + exitCode = msg.exit_code ?? null; + } + } catch { + // ignore non-JSON text + } + return; + } + received += new TextDecoder().decode(new Uint8Array(ev.data as ArrayBuffer)); + }); + + // Send input. + ws.send(new TextEncoder().encode("ping\n")); + + // Wait for echo. PTY in cooked mode echoes input back at least once. + await waitFor(() => received.includes("ping"), 5_000, "echo"); + + // Stop the session via HTTP — sends SIGTERM to cat, which exits 0. + const stopRes = await fetch( + `${backend.baseUrl}/api/v1/agent-login/sessions/${session.session_id}/stop`, + { method: "POST" }, + ); + expect(stopRes.status).toBe(200); + + // Server should send a final "exit" text frame. + await waitFor(() => exitedCleanly, 5_000, "exit message"); + ws.close(); + + // exit_code may be 0 (clean exit on EOF) or non-zero (killed by signal). + // Both are acceptable terminations — we just care that the server told us. + expect(exitCode).not.toBeNull(); + }); + + test("start for an unknown agent returns 404", async ({ backend }) => { + test.setTimeout(5_000); + const res = await fetch(`${backend.baseUrl}/api/v1/agent-login/agents/no-such-agent/start`, { + method: "POST", + }); + expect(res.status).toBe(404); + }); + + test("login start wraps the agent in a SIGINT-trapping shell (shell-drop wrapper)", async ({ + backend, + }) => { + test.setTimeout(5_000); + + // The wrapper is what lets Ctrl+C kill the agent without ending the + // PTY session - sh traps SIGINT then execs $SHELL after the agent + // exits. Whether the shell actually appears at the prompt is too + // shell-specific to assert (zsh vs bash vs dash), but the wrapper + // being in place is a structural property we *can* assert: it shows + // up in the session's resolved argv. + const startRes = await fetch(`${backend.baseUrl}/api/v1/agent-login/agents/mock-agent/start`, { + method: "POST", + body: JSON.stringify({ cols: 80, rows: 24 }), + }); + const session = (await startRes.json()) as { session_id: string; cmd: string[] }; + + // Expect the wrapper shape: sh -c '