feat: streaming agent install, PTY login terminal, docker container UX (#869)

This commit is contained in:
Carlos Florêncio
2026-05-11 17:52:05 +01:00
committed by GitHub
parent 6139dce03e
commit f97ac8bbc1
48 changed files with 4049 additions and 218 deletions
+27 -10
View File
@@ -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
+14 -2
View File
@@ -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
+15
View File
@@ -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)")
+1
View File
@@ -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),
@@ -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 {
@@ -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
}
@@ -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 {
@@ -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 {
@@ -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,
@@ -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)
@@ -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
}
+15 -1
View File
@@ -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
@@ -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 <cmd...>` 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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
@@ -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,
@@ -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"`
@@ -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
+3 -3
View File
@@ -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",
})
}
@@ -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) {
+3
View File
@@ -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"
+141 -58
View File
@@ -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 (
<Card>
<CardContent className="py-4 flex flex-col gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<AgentLogo agentName={agent.name} size={20} className="shrink-0" />
<h4 className="font-medium">{displayName}</h4>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
{configured && <Badge variant="outline">Configured</Badge>}
</div>
{agent.matched_path && (
<p className="text-xs text-muted-foreground">Detected at {agent.matched_path}</p>
)}
</div>
<Button size="sm" className="cursor-pointer" asChild>
<Link
href={
hasAgentRecord
? `/settings/agents/${encodeURIComponent(agent.name)}?mode=create`
: `/settings/agents/${encodeURIComponent(agent.name)}`
}
>
<IconSettings className="h-4 w-4 mr-2" />
{hasAgentRecord ? "Create new profile" : "Setup Profile"}
</Link>
</Button>
</CardContent>
</Card>
);
}
function InstallCard({
agent,
copiedValue,
onCopy,
job,
onInstall,
}: {
agent: AvailableAgent;
copiedValue: string | null;
onCopy: (text: string) => void;
job: InstallJob | undefined;
onInstall: (name: string) => void;
}) {
return (
<Card className="border-dashed">
<CardContent className="py-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<AgentLogo agentName={agent.name} size={20} className="shrink-0" />
<h4 className="font-medium">{agent.display_name}</h4>
</div>
{agent.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{agent.description}</p>
)}
{agent.install_script && (
<InstallAgentCard
agent={agent}
job={job}
onInstall={onInstall}
scriptSlot={
agent.install_script ? (
<div className="flex items-center gap-1 rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
<code className="flex-1 truncate">{agent.install_script}</code>
<CopyButton text={agent.install_script} copiedValue={copiedValue} onCopy={onCopy} />
</div>
)}
</CardContent>
</Card>
) : null
}
/>
);
}
@@ -230,6 +196,7 @@ type InstalledAgentsSectionProps = {
rescanning: boolean;
savedAgentsByName: Map<string, Agent>;
resolveDisplayName: (name: string) => string;
resolveCapabilityStatus: (name: string) => string | undefined;
setTuiDialogOpen: (open: boolean) => void;
handleRescan: () => Promise<void>;
};
@@ -240,9 +207,12 @@ function InstalledAgentsSection({
rescanning,
savedAgentsByName,
resolveDisplayName,
resolveCapabilityStatus,
setTuiDialogOpen,
handleRescan,
}: InstalledAgentsSectionProps) {
const [shellOpen, setShellOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
@@ -253,6 +223,16 @@ function InstalledAgentsSection({
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShellOpen(true)}
className="cursor-pointer"
data-testid="open-host-shell"
>
<IconTerminal2 className="h-4 w-4 mr-2" />
Terminal
</Button>
<Button
variant="outline"
size="sm"
@@ -273,6 +253,15 @@ function InstalledAgentsSection({
</Button>
</div>
</div>
<HostShellDialog
open={shellOpen}
onOpenChange={setShellOpen}
onClose={() => {
// 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 && (
<Card>
@@ -293,11 +282,13 @@ function InstalledAgentsSection({
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{installedAgents.map((agent: AgentDiscovery) => (
<AgentCard
<InstalledAgentCard
key={agent.name}
agent={agent}
savedAgent={savedAgentsByName.get(agent.name)}
displayName={resolveDisplayName(agent.name)}
capabilityStatus={resolveCapabilityStatus(agent.name)}
onAuthComplete={() => void handleRescan()}
/>
))}
</div>
@@ -310,11 +301,15 @@ function SuggestInstallSection({
tools,
copiedValue,
onCopy,
installJobs,
onInstall,
}: {
notInstalledAgents: AvailableAgent[];
tools: ToolStatus[];
copiedValue: string | null;
onCopy: (text: string) => void;
installJobs: Record<string, InstallJob>;
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({
<div>
<h3 className="text-lg font-semibold">Available to Install</h3>
<p className="text-sm text-muted-foreground">
Install an agent CLI globally, then log in with the agent and click Rescan above.
Click Install to run the agent&apos;s install script on the kandev host. Progress streams
live; you can start multiple installs in parallel.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{notInstalledAgents.map((agent) => (
<InstallCard key={agent.name} agent={agent} copiedValue={copiedValue} onCopy={onCopy} />
<InstallCard
key={agent.name}
agent={agent}
copiedValue={copiedValue}
onCopy={onCopy}
job={installJobs[agent.name]}
onInstall={onInstall}
/>
))}
{notInstalledTools.map((tool) => (
<ToolInstallCard key={tool.name} tool={tool} copiedValue={copiedValue} onCopy={onCopy} />
@@ -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<void>) {
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}
/>
<AgentProfilesSection savedAgents={savedAgents} />
+24 -2
View File
@@ -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,
+187 -32
View File
@@ -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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconX className="h-4 w-4 text-red-500" />
<span className="text-sm font-medium">Not connected to GitHub</span>
<Button variant="ghost" size="sm" onClick={refresh} className="cursor-pointer h-6 px-2">
<IconRefresh className="h-3.5 w-3.5" />
Refresh
</Button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-auto text-muted-foreground hover:text-foreground cursor-help"
aria-label="Authentication methods"
>
<IconInfoCircle className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-xs">
Kandev also accepts a <code>GITHUB_TOKEN</code> environment variable on startup. The
UI options below cover the common cases.
</p>
</TooltipContent>
</Tooltip>
</div>
{ghInstalled ? (
<PrimaryCLISignIn onClick={() => setGhAuthOpen(true)} />
) : (
<CLIUnavailableHint />
)}
<Collapsible open={tokenOpen} onOpenChange={setTokenOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer"
>
{tokenOpen ? (
<IconChevronDown className="h-3 w-3" />
) : (
<IconChevronRight className="h-3 w-3" />
)}
{ghInstalled ? "Use a personal access token instead" : "Configure GitHub token"}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
{/* TokenConfigForm already renders the "Create a Personal Access
Token with repo + read:org scopes" hint below its input. */}
<TokenConfigForm onSuccess={refresh} />
</CollapsibleContent>
</Collapsible>
{status?.diagnostics && (
<DiagnosticsDisclosure
diagnostics={status.diagnostics}
open={diagOpen}
onOpenChange={setDiagOpen}
/>
)}
<HostShellDialog
open={ghAuthOpen}
onOpenChange={setGhAuthOpen}
initialInput={"gh auth login\n"}
onClose={refresh}
/>
</div>
);
}
function DiagnosticsDisclosure({
diagnostics,
open,
onOpenChange,
}: {
diagnostics: AuthDiagnostics;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer"
>
{open ? (
<IconChevronDown className="h-3 w-3" />
) : (
<IconChevronRight className="h-3 w-3" />
)}
Diagnostics (<code className="bg-muted px-1 rounded">{diagnostics.command}</code>, exit{" "}
{diagnostics.exit_code})
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<DiagnosticsOutput diagnostics={diagnostics} />
</CollapsibleContent>
</Collapsible>
);
}
function PrimaryCLISignIn({ onClick }: { onClick: () => void }) {
return (
<div className="rounded-md border border-primary/40 bg-primary/5 p-4 space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">Sign in with the GitHub CLI</p>
<p className="text-xs text-muted-foreground">
Recommended. Opens a terminal that runs{" "}
<code className="bg-muted px-1 rounded">gh auth login</code> with browser-based
authentication.
</p>
</div>
<Button
size="default"
onClick={onClick}
className="cursor-pointer"
data-testid="github-gh-auth-login"
>
<IconTerminal2 className="h-4 w-4 mr-2" />
Open terminal and sign in
</Button>
</div>
);
}
function CLIUnavailableHint() {
return (
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
<p>
The <code className="bg-muted px-1 rounded">gh</code> CLI is not installed on this host - a
personal access token is the only sign-in option available here. To install it later, see{" "}
<a
href="https://cli.github.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground cursor-pointer"
>
cli.github.com
</a>
.
</p>
</div>
);
}
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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconX className="h-4 w-4 text-red-500" />
<span className="text-sm font-medium">Not connected to GitHub</span>
<Button variant="ghost" size="sm" onClick={refresh} className="cursor-pointer h-6 px-2">
<IconRefresh className="h-3.5 w-3.5" />
Refresh
</Button>
</div>
{/* Token configuration form */}
<div className="space-y-2">
<p className="text-sm font-medium">Configure GitHub Token</p>
<TokenConfigForm onSuccess={refresh} />
</div>
{/* Alternative methods */}
<div className="text-xs text-muted-foreground space-y-1.5 border-t pt-3">
<p>Other authentication methods:</p>
<ul className="list-disc list-inside space-y-1 pl-1">
<li>
<strong>Environment variable</strong> — set{" "}
<code className="bg-muted px-1 rounded">GITHUB_TOKEN</code> when starting Kandev
</li>
<li>
<strong>GitHub CLI</strong> — run{" "}
<code className="bg-muted px-1 rounded">gh auth login</code>
</li>
</ul>
</div>
{status?.diagnostics && <DiagnosticsOutput diagnostics={status.diagnostics} />}
</div>
<NotConnectedView
status={status}
refresh={refresh}
ghAuthOpen={ghAuthOpen}
setGhAuthOpen={setGhAuthOpen}
/>
);
}
@@ -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 <PtyTerminalDialog> - 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 (
<PtyTerminalDialog
open={open}
onOpenChange={onOpenChange}
title={`Sign in: ${agentName}`}
description={description}
command={command}
testIdPrefix="agent-login"
startSession={startSession}
onDone={onLoginSuccess}
/>
);
}
@@ -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 (
<PtyTerminalDialog
open={open}
onOpenChange={onOpenChange}
title="Host terminal"
description="Runs a shell on the kandev host. Use it to install or configure agents manually."
testIdPrefix="host-shell"
startSession={startHostShell}
onDone={onClose}
initialInput={initialInput}
/>
);
}
@@ -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 (
<Button
size="sm"
onClick={() => onInstall(agentName)}
disabled={isInFlight}
className="cursor-pointer"
data-testid={`install-button-${agentName}`}
>
{btn.icon === "spinner" ? (
<IconLoader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<IconDownload className="h-4 w-4 mr-2" />
)}
{btn.label}
</Button>
);
}
/**
* 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 (
<Card className="border-dashed" data-testid={`install-card-${agent.name}`}>
<CardContent className="py-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<AgentLogo agentName={agent.name} size={20} className="shrink-0" />
<h4 className="font-medium">{agent.display_name}</h4>
</div>
{agent.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{agent.description}</p>
)}
{scriptSlot}
{agent.install_script && (
<InstallButton agentName={agent.name} status={status} onInstall={onInstall} />
)}
{showLog && (
<pre
data-testid={`install-log-${agent.name}`}
className={
"max-h-40 overflow-auto whitespace-pre-wrap rounded-md px-2 py-1.5 font-mono text-xs " +
(failed ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground")
}
>
{job?.output}
</pre>
)}
{failed && job?.error && (
<p className="text-xs text-destructive" data-testid={`install-error-${agent.name}`}>
{job.error}
</p>
)}
</CardContent>
</Card>
);
}
@@ -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
// `<agent> --help`, run their own auth recipe, etc.).
const handleAuthClick = () => {
if (loginAvailable) setLoginOpen(true);
else setShellOpen(true);
};
return (
<Card className="flex flex-col">
<CardContent className="py-4 flex flex-col gap-3 flex-1">
<div className="space-y-1">
<div className="flex items-center gap-2">
<AgentLogo agentName={agent.name} size={20} className="shrink-0" />
<h4 className="font-medium">{displayName}</h4>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
{configured && <Badge variant="outline">Configured</Badge>}
{probing && (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid={`probing-icon-${agent.name}`}
className="ml-auto flex items-center text-muted-foreground cursor-help"
aria-label="Checking authentication"
>
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
</span>
</TooltipTrigger>
<TooltipContent>Checking agent capabilities and authentication...</TooltipContent>
</Tooltip>
)}
{authRequired && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleAuthClick}
data-testid={`auth-icon-${agent.name}`}
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-amber-500 cursor-pointer hover:bg-amber-500/10"
aria-label="Authentication required"
>
<IconLock className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
{loginAvailable
? "Authentication required - click to open login terminal"
: "Authentication required - click to open a shell and sign in"}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Reserve two lines so cards align regardless of path length. */}
<p
className="text-xs text-muted-foreground line-clamp-2 min-h-[2rem]"
title={agent.matched_path ?? undefined}
>
{agent.matched_path ? `Detected at ${agent.matched_path}` : ""}
</p>
</div>
<Button size="sm" className="cursor-pointer mt-auto" asChild>
<Link
href={
hasAgentRecord
? `/settings/agents/${encodeURIComponent(agent.name)}?mode=create`
: `/settings/agents/${encodeURIComponent(agent.name)}`
}
>
<IconSettings className="h-4 w-4 mr-2" />
{hasAgentRecord ? "Create new profile" : "Setup Profile"}
</Link>
</Button>
<AuthDialogs
agent={agent}
loginOpen={loginOpen}
setLoginOpen={setLoginOpen}
shellOpen={shellOpen}
setShellOpen={setShellOpen}
loginAvailable={loginAvailable}
onAuthComplete={onAuthComplete}
/>
</CardContent>
</Card>
);
}
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 (
<AgentLoginDialog
open={loginOpen}
onOpenChange={setLoginOpen}
agentName={agent.name}
description={agent.login_command?.description}
command={agent.login_command?.cmd}
onLoginSuccess={onAuthComplete}
/>
);
}
return <HostShellDialog open={shellOpen} onOpenChange={setShellOpen} onClose={onAuthComplete} />;
}
@@ -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<void>;
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 <code className="font-mono bg-muted px-1 py-0.5 rounded">{agentName} login</code> in your
terminal, then click Refresh.
</>
) : (
<>Install the agent CLI, then click Refresh.</>
);
const detail = error || rawError;
return (
<div
data-testid="profile-no-auth-panel"
data-status={status}
className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3"
>
<Icon className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{title}</p>
{detail && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground cursor-help"
data-testid="profile-no-auth-details"
>
<IconAlertCircle className="h-3 w-3" />
details
</button>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<p className="whitespace-pre-wrap break-words text-xs">{detail}</p>
</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="cursor-pointer shrink-0"
data-testid="profile-no-auth-refresh"
>
<IconRefresh className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
);
}
function CapabilitiesRow({
profile,
models,
@@ -436,6 +358,9 @@ function CapabilitiesRow({
}
const status = modelConfig.status;
if (status === "probing") {
return <ProbingPanel />;
}
if (status === "auth_required" || status === "not_installed") {
return (
<NoAuthPanel
@@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import {
IconAlertCircle,
IconLoader2,
IconLock,
IconPackageOff,
IconRefresh,
IconTerminal2,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { AgentLoginDialog } from "@/components/settings/agent-login-dialog";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
/**
* Rendered between install completion and the capability probe finishing.
* Auto-refresh runs on the backend; the UI just signals "still working" so
* users don't mistake the in-flight probe for a failed install.
*/
export function ProbingPanel() {
return (
<div
data-testid="profile-probing-panel"
data-status="probing"
className="flex items-center gap-3 rounded-md border border-muted bg-muted/40 p-3"
>
<IconLoader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Probing agent capabilities… this usually takes a few seconds.
</p>
</div>
);
}
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<void>;
isLoading: boolean;
}) {
return (
<div className="flex shrink-0 items-center gap-2">
{showTerminal && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenTerminal}
className="cursor-pointer"
data-testid="profile-no-auth-open-terminal"
>
<IconTerminal2 className="mr-2 h-4 w-4" />
Open terminal
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="cursor-pointer"
data-testid="profile-no-auth-refresh"
>
<IconRefresh className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
);
}
export function NoAuthPanel({
agentName,
status,
isLoading,
onRefresh,
error,
rawError,
}: {
agentName: string;
status: "auth_required" | "not_installed";
isLoading: boolean;
onRefresh: () => Promise<void>;
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. `<agent> --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 (
<div
data-testid="profile-no-auth-panel"
data-status={status}
className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3"
>
<Icon className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{title}</p>
{detail && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground cursor-help"
data-testid="profile-no-auth-details"
>
<IconAlertCircle className="h-3 w-3" />
details
</button>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<p className="whitespace-pre-wrap break-words text-xs">{detail}</p>
</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
<NoAuthActions
showTerminal={showTerminal}
onOpenTerminal={handleOpenTerminal}
onRefresh={onRefresh}
isLoading={isLoading}
/>
{canLogin && (
<AgentLoginDialog
open={loginOpen}
onOpenChange={setLoginOpen}
agentName={agentName}
description={loginCommand?.description}
command={loginCommand?.cmd}
onLoginSuccess={() => {
void onRefresh();
}}
/>
)}
{showTerminal && !canLogin && (
<HostShellDialog
open={shellOpen}
onOpenChange={setShellOpen}
onClose={() => {
void onRefresh();
}}
/>
)}
</div>
);
}
@@ -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<AgentLoginSession>;
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<FitAddon | null>,
termRef: React.RefObject<Terminal | null>,
sessionIDRef: React.RefObject<string | null>,
wsRef: React.RefObject<WebSocket | null>,
): 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<Terminal | null>;
fitRef: React.RefObject<FitAddon | null>;
wsRef: React.RefObject<WebSocket | null>;
sessionIDRef: React.RefObject<string | null>;
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<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const sessionIDRef = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<SessionStatus>("connecting");
const [exitCode, setExitCode] = useState<number | null>(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 (
<>
<div
ref={termContainerRef}
data-testid={`${testIdPrefix ?? "pty"}-terminal`}
className="h-[420px] rounded-md bg-[#0b0b0c] p-2 overflow-hidden"
/>
{status === "connecting" && (
<p className="text-xs text-muted-foreground">Starting session…</p>
)}
{status === "exited" && (
<p className="text-xs text-muted-foreground">
Session ended{exitCode != null ? ` (exit ${exitCode})` : ""}.
</p>
)}
{status === "error" && error && <p className="text-xs text-destructive">{error}</p>}
<DialogFooter>
<Button
type="button"
onClick={onDone}
className="cursor-pointer"
data-testid={`${testIdPrefix ?? "pty"}-done`}
>
Done
</Button>
</DialogFooter>
</>
);
}
/**
* Generic dialog that hosts a PTY-backed terminal. Mounts the inner
* `<PtySessionView>` 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[820px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
{cmdLine && (
<div
data-testid={`${testIdPrefix ?? "pty"}-command`}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1.5 font-mono text-xs"
>
<span className="text-muted-foreground">$</span>
<code className="flex-1 truncate" title={cmdLine}>
{cmdLine}
</code>
</div>
)}
{open && (
<PtySessionView
startSession={startSession}
testIdPrefix={testIdPrefix}
initialInput={initialInput}
onDone={handleDone}
/>
)}
</DialogContent>
</Dialog>
);
}
@@ -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<string | undefined>(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 && (
<AuthMethodsPanel
methods={metadata.auth_methods}
onOpenTerminal={openTerminalWithCommand}
onOpenTerminal={openHostShellWithCommand}
/>
)}
{metadata.is_auth_error && (!metadata.auth_methods || metadata.auth_methods.length === 0) && (
<GenericAuthPanel onOpenTerminal={openBottomTerminal} />
<GenericAuthPanel onOpenTerminal={openHostShell} />
)}
<HostShellDialog
open={hostShellOpen}
onOpenChange={setHostShellOpen}
initialInput={hostShellCommand}
/>
</>
);
}
@@ -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/<name> 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<WebSocket> {
const wsUrl = baseUrl.replace(/^http/, "ws") + "/ws";
const ws = new WebSocket(wsUrl);
await new Promise<void>((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}`);
}
@@ -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/<name>/start → spawns PTY, returns session_id
* WS /api/v1/agent-login/<id>/stream → bi-directional binary I/O
* POST /api/v1/agent-login/<id>/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<void>((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 '<script with trap INT and exec $SHELL>' <marker> <agent argv...>
expect(session.cmd[0]).toBe("sh");
expect(session.cmd[1]).toBe("-c");
expect(session.cmd[2]).toContain("trap");
expect(session.cmd[2]).toContain("INT");
expect(session.cmd[2]).toContain("exec");
expect(session.cmd).toContain("cat"); // mock-agent's actual login command
await fetch(`${backend.baseUrl}/api/v1/agent-login/sessions/${session.session_id}/stop`, {
method: "POST",
});
});
test("mock-agent surfaces login_command on /agents/available", async ({ backend }) => {
test.setTimeout(5_000);
const res = await fetch(`${backend.baseUrl}/api/v1/agents/available`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
agents: Array<{ name: string; login_command?: { cmd: string[]; description?: string } }>;
};
const mock = body.agents.find((a) => a.name === "mock-agent");
expect(mock).toBeTruthy();
expect(mock!.login_command).toBeTruthy();
expect(mock!.login_command!.cmd).toContain("cat");
});
});
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}`);
}
@@ -0,0 +1,109 @@
import { test, expect } from "../../fixtures/test-base";
/**
* End-to-end coverage for the host shell PTY - the dialog opened by the
* "Terminal" button on the Agents settings page (and by the auth-recovery
* banner when an agent has no registered LoginCommand).
*
* POST /api/v1/host-shell/start spawns $SHELL under a PTY
* WS /api/v1/agent-login/sessions/:id/stream same routes as the
* login PTY (shared manager)
* POST /api/v1/agent-login/sessions/:id/stop terminates the session
*/
test.describe("host shell PTY", () => {
test("start → run a command → stop", async ({ backend }) => {
test.setTimeout(15_000);
const startRes = await fetch(`${backend.baseUrl}/api/v1/host-shell/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;
agent_id: string;
cmd: string[];
running: boolean;
};
expect(session.session_id).toBeTruthy();
expect(session.running).toBe(true);
// The agent_id is the synthetic key the manager uses for shell sessions.
expect(session.agent_id).toBe("_host_shell");
// Cmd should be some shell binary - exact path varies by host.
expect(session.cmd.length).toBeGreaterThan(0);
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;
ws.addEventListener("message", (ev) => {
if (typeof ev.data === "string") {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "exit") exitedCleanly = true;
} catch {
// ignore
}
return;
}
received += new TextDecoder().decode(new Uint8Array(ev.data as ArrayBuffer));
});
await new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true });
ws.addEventListener("error", () => reject(new Error("ws error")), { once: true });
});
// Give the shell a moment to print its prompt, then run a marker.
await new Promise((r) => setTimeout(r, 300));
ws.send(new TextEncoder().encode("echo HELLO_FROM_HOST_SHELL\n"));
await waitFor(() => received.includes("HELLO_FROM_HOST_SHELL"), 8_000, "shell echo");
const stopRes = await fetch(
`${backend.baseUrl}/api/v1/agent-login/sessions/${session.session_id}/stop`,
{ method: "POST" },
);
expect(stopRes.status).toBe(200);
await waitFor(() => exitedCleanly, 5_000, "exit message");
ws.close();
});
test("a second start while one is running returns the same session (idempotent)", async ({
backend,
}) => {
test.setTimeout(10_000);
const first = (await (
await fetch(`${backend.baseUrl}/api/v1/host-shell/start`, {
method: "POST",
body: JSON.stringify({ cols: 80, rows: 24 }),
})
).json()) as { session_id: string };
const second = (await (
await fetch(`${backend.baseUrl}/api/v1/host-shell/start`, {
method: "POST",
body: JSON.stringify({ cols: 80, rows: 24 }),
})
).json()) as { session_id: string };
expect(second.session_id).toBe(first.session_id);
await fetch(`${backend.baseUrl}/api/v1/agent-login/sessions/${first.session_id}/stop`, {
method: "POST",
});
});
});
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}`);
}
+109
View File
@@ -203,6 +203,115 @@ export async function previewAgentCommand(
});
}
export type InstallJobStatus = "queued" | "running" | "succeeded" | "failed";
export type InstallJob = {
job_id: string;
agent_name: string;
status: InstallJobStatus;
output?: string;
error?: string;
exit_code?: number;
started_at: string;
finished_at?: string;
};
/** Enqueue an install. Returns the job snapshot (status=queued or running). */
export async function installAgent(
agentName: string,
options?: ApiRequestOptions,
): Promise<InstallJob> {
return fetchJson<InstallJob>(`/api/v1/agent-install/${agentName}`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function listInstallJobs(
options?: ApiRequestOptions,
): Promise<{ jobs: InstallJob[] }> {
return fetchJson<{ jobs: InstallJob[] }>("/api/v1/agent-install/jobs", options);
}
export async function getInstallJob(
jobId: string,
options?: ApiRequestOptions,
): Promise<InstallJob> {
return fetchJson<InstallJob>(`/api/v1/agent-install/jobs/${jobId}`, options);
}
export type AgentLoginSession = {
session_id: string;
agent_id: string;
cmd: string[];
running: boolean;
started_at: string;
finished_at?: string;
exit_code?: number;
};
export async function startAgentLogin(
agentName: string,
size: { cols: number; rows: number },
options?: ApiRequestOptions,
): Promise<AgentLoginSession> {
return fetchJson<AgentLoginSession>(`/api/v1/agent-login/agents/${agentName}/start`, {
...options,
init: {
method: "POST",
body: JSON.stringify(size),
...(options?.init ?? {}),
},
});
}
export async function stopAgentLogin(sessionID: string): Promise<void> {
await fetchJson<{ ok: boolean }>(`/api/v1/agent-login/sessions/${sessionID}/stop`, {
init: { method: "POST" },
});
}
export async function resizeAgentLogin(
sessionID: string,
size: { cols: number; rows: number },
): Promise<void> {
await fetchJson<{ ok: boolean }>(`/api/v1/agent-login/sessions/${sessionID}/resize`, {
init: { method: "POST", body: JSON.stringify(size) },
});
}
/**
* Build the bi-directional WS URL for streaming a login session.
* Derives the host from the backend config (NOT window.location) so dev mode
* — where the browser is on :37429 and the API is on :38429 — routes to the
* Go backend, not the Next dev server.
*/
export function agentLoginStreamUrl(sessionID: string): string {
const { apiBaseUrl } = getBackendConfig();
const url = new URL(apiBaseUrl);
const proto = url.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${url.host}/api/v1/agent-login/sessions/${sessionID}/stream`;
}
/**
* Start a plain host shell PTY (spawns $SHELL, or bash/sh fallback). Reuses
* the same session manager as agent-login, so stop/resize/stream all use the
* same session-ID-based endpoints.
*/
export async function startHostShell(
size: { cols: number; rows: number },
options?: ApiRequestOptions,
): Promise<AgentLoginSession> {
return fetchJson<AgentLoginSession>("/api/v1/host-shell/start", {
...options,
init: {
method: "POST",
body: JSON.stringify(size),
...(options?.init ?? {}),
},
});
}
export async function createCustomTUIAgent(
payload: { display_name: string; model?: string; command: string; description?: string },
options?: ApiRequestOptions,
@@ -7,6 +7,7 @@ export const defaultSettingsState: SettingsSliceState = {
agentDiscovery: { items: [], loading: false, loaded: false },
availableAgents: { items: [], tools: [], loading: false, loaded: false },
agentProfiles: { items: [], version: 0 },
installJobs: { byAgent: {} },
editors: { items: [], loaded: false, loading: false },
prompts: { items: [], loaded: false, loading: false },
secrets: { items: [], loaded: false, loading: false },
@@ -50,6 +51,63 @@ type ImmerSet = Parameters<
StateCreator<SettingsSlice, [["zustand/immer", never]], [], SettingsSlice>
>[0];
// installJobStartedAtMs parses started_at to epoch ms so we compare on time,
// not lexicographically. RFC3339 strings with variable fractional seconds
// would otherwise misorder ("2026-05-11T10:00:00Z" sorts after
// "2026-05-11T10:00:00.1Z" despite being older).
function installJobStartedAtMs(job: { started_at: string }): number {
return Date.parse(job.started_at);
}
function createInstallJobActions(
set: ImmerSet,
): Pick<
SettingsSlice,
"setInstallJobs" | "upsertInstallJob" | "appendInstallOutput" | "clearInstallJob"
> {
return {
setInstallJobs: (jobs) =>
set((draft) => {
const byAgent: Record<string, (typeof jobs)[number]> = {};
for (const job of jobs) {
// If two jobs target the same agent (a current run + a stale
// finished snapshot in retention), prefer the newest start.
const existing = byAgent[job.agent_name];
if (!existing || installJobStartedAtMs(job) > installJobStartedAtMs(existing)) {
byAgent[job.agent_name] = job;
}
}
draft.installJobs.byAgent = byAgent;
}),
upsertInstallJob: (job) =>
set((draft) => {
const current = draft.installJobs.byAgent[job.agent_name];
// Drop stale events from a previous job_id (e.g. after retry).
if (
current &&
current.job_id !== job.job_id &&
installJobStartedAtMs(current) > installJobStartedAtMs(job)
) {
return;
}
draft.installJobs.byAgent[job.agent_name] = job;
}),
appendInstallOutput: (agentName, chunk) =>
set((draft) => {
const current = draft.installJobs.byAgent[agentName];
if (!current) return;
const next = (current.output ?? "") + chunk;
// Cap at 64KB; drop oldest chars on overflow so the live tail stays current.
const max = 64 * 1024;
current.output = next.length > max ? next.slice(next.length - max) : next;
}),
clearInstallJob: (agentName) =>
set((draft) => {
delete draft.installJobs.byAgent[agentName];
}),
};
}
function createCoreActions(
set: ImmerSet,
): Pick<
@@ -219,5 +277,6 @@ export const createSettingsSlice: StateCreator<
> = (set) => ({
...defaultSettingsState,
...createCoreActions(set),
...createInstallJobActions(set),
...createSecretAndSpriteActions(set),
});
@@ -72,6 +72,28 @@ export type AgentProfilesState = {
version: number;
};
export type InstallJobStatus = "queued" | "running" | "succeeded" | "failed";
export type InstallJob = {
job_id: string;
agent_name: string;
status: InstallJobStatus;
output?: string;
error?: string;
exit_code?: number;
started_at: string;
finished_at?: string;
};
/**
* Tracks active and recently-finished install jobs by agent_name. Rehydrated
* on page mount from GET /agent-install/jobs and kept live via WS events
* (agent.install.started/output/finished).
*/
export type InstallJobsState = {
byAgent: Record<string, InstallJob>;
};
export type EditorsState = {
items: EditorOption[];
loaded: boolean;
@@ -142,6 +164,7 @@ export type SettingsSliceState = {
agentDiscovery: AgentDiscoveryState;
availableAgents: AvailableAgentsState;
agentProfiles: AgentProfilesState;
installJobs: InstallJobsState;
editors: EditorsState;
prompts: PromptsState;
secrets: SecretsState;
@@ -162,6 +185,10 @@ export type SettingsSliceActions = {
) => void;
setAvailableAgentsLoading: (loading: boolean) => void;
setAgentProfiles: (profiles: AgentProfilesState["items"]) => void;
setInstallJobs: (jobs: InstallJob[]) => void;
upsertInstallJob: (job: InstallJob) => void;
appendInstallOutput: (agentName: string, chunk: string) => void;
clearInstallJob: (agentName: string) => void;
setEditors: (editors: EditorsState["items"]) => void;
setEditorsLoading: (loading: boolean) => void;
setPrompts: (prompts: PromptsState["items"]) => void;
+5
View File
@@ -172,6 +172,7 @@ export type AppState = {
agentDiscovery: (typeof defaultSettingsState)["agentDiscovery"];
availableAgents: (typeof defaultSettingsState)["availableAgents"];
agentProfiles: (typeof defaultSettingsState)["agentProfiles"];
installJobs: (typeof defaultSettingsState)["installJobs"];
editors: (typeof defaultSettingsState)["editors"];
prompts: (typeof defaultSettingsState)["prompts"];
secrets: (typeof defaultSettingsState)["secrets"];
@@ -314,6 +315,10 @@ export type AppState = {
) => void;
setAvailableAgentsLoading: (loading: boolean) => void;
setAgentProfiles: (profiles: AgentProfilesState["items"]) => void;
setInstallJobs: (jobs: import("@/lib/state/slices/settings/types").InstallJob[]) => void;
upsertInstallJob: (job: import("@/lib/state/slices/settings/types").InstallJob) => void;
appendInstallOutput: (agentName: string, chunk: string) => void;
clearInstallJob: (agentName: string) => void;
setRepositories: (workspaceId: string, repositories: Repository[]) => void;
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void;
setRepositoryBranches: (
+23
View File
@@ -11,6 +11,9 @@ export type BackendMessageType =
| "task.plan.reverted"
| "agent.updated"
| "agent.available.updated"
| "agent.install.started"
| "agent.install.output"
| "agent.install.finished"
| "terminal.output"
| "diff.update"
| "session.git.event"
@@ -145,6 +148,23 @@ export type AgentAvailableUpdatedPayload = {
tools?: ToolStatus[];
};
export type AgentInstallJobPayload = {
job_id: string;
agent_name: string;
status: "queued" | "running" | "succeeded" | "failed";
output?: string;
error?: string;
exit_code?: number;
started_at: string;
finished_at?: string;
};
export type AgentInstallOutputPayload = {
job_id: string;
agent_name: string;
chunk: string;
};
export type TerminalOutputPayload = {
terminalId: string;
data: string;
@@ -530,6 +550,9 @@ export type BackendMessageMap = {
"agent.available.updated",
AgentAvailableUpdatedPayload
>;
"agent.install.started": BackendMessage<"agent.install.started", AgentInstallJobPayload>;
"agent.install.output": BackendMessage<"agent.install.output", AgentInstallOutputPayload>;
"agent.install.finished": BackendMessage<"agent.install.finished", AgentInstallJobPayload>;
"terminal.output": BackendMessage<"terminal.output", TerminalOutputPayload>;
"diff.update": BackendMessage<"diff.update", DiffUpdatePayload>;
"session.git.event": BackendMessage<"session.git.event", GitEventPayload>;
+7
View File
@@ -87,6 +87,7 @@ export type AgentDiscovery = {
installation_paths: string[];
available: boolean;
matched_path?: string | null;
login_command?: LoginCommand;
};
export type AgentCapabilities = {
@@ -183,6 +184,11 @@ export type ToolStatus = {
info_url?: string;
};
export type LoginCommand = {
cmd: string[];
description?: string;
};
export type AvailableAgent = {
name: string;
display_name: string;
@@ -197,6 +203,7 @@ export type AvailableAgent = {
model_config: ModelConfig;
permission_settings?: Record<string, PermissionSetting>;
passthrough_config?: PassthroughConfig;
login_command?: LoginCommand;
updated_at: string;
};
+14
View File
@@ -96,6 +96,20 @@ export function registerAgentsHandlers(store: StoreApi<AppState>): WsHandlers {
},
}));
},
"agent.install.started": (message) => {
// Payload is the full job snapshot (queued → running transitions both emit this).
store.getState().upsertInstallJob(message.payload);
},
"agent.install.output": (message) => {
const { agent_name, chunk } = message.payload as {
agent_name: string;
chunk: string;
};
store.getState().appendInstallOutput(agent_name, chunk);
},
"agent.install.finished": (message) => {
store.getState().upsertInstallJob(message.payload);
},
"agent.updated": (message) => {
store.setState((state) => ({
...state,
+14
View File
@@ -76,6 +76,20 @@ kubectl port-forward svc/kandev 38429:38429
No extra configuration is needed. The frontend automatically uses `window.location.origin` to reach the API, which works with any domain, reverse proxy, or ingress setup.
## Installing Agent CLIs
The kandev image ships with `git`, `gh` (GitHub CLI), `node`, and `npm`, but **does not bundle the coding-agent CLIs** (`claude-code`, `codex`, `auggie`, etc.) — agent choice is per-user, and bundling all of them would bloat the image significantly.
To install an agent inside the running pod, open **Settings → Agents** in the UI and click **Install** on the agent card under "Available to Install". The backend runs the agent's hard-coded install script (`npm install -g <pkg>`) and rescans on success.
The image sets `NPM_CONFIG_PREFIX=/data/.npm-global` so user-installed npm globals land on the PV and **survive pod restarts and image upgrades**. The same persistence applies if you `kubectl exec` and install manually:
```bash
kubectl exec -it deployment/kandev -- npm install -g @anthropic-ai/claude-code
```
After installing, log in with the agent's own auth (e.g. `claude login`), then click **Rescan** on the agents page.
## Configuration
Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper). Set these in `k8s/configmap.yaml` or as environment variables in the deployment.