feat: streaming agent install, PTY login terminal, docker container UX (#869)
This commit is contained in:
+27
-10
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user