Files
kandev/apps/backend/internal/agent/lifecycle/container.go
T
João Salavisa dccff23cc0 feat(backend): per-launch authentication for agentctl (#666)
* feat(backend): per-launch authentication for agentctl

Add nonce-based handshake protocol to secure all agentctl HTTP/WS
endpoints. On launch, the parent (backend) passes a one-time bootstrap
nonce via AGENTCTL_BOOTSTRAP_NONCE env var. agentctl generates its own
auth token, enables Bearer auth immediately, and returns the token to
the parent via POST /auth/handshake (validated by nonce).

Standalone, Docker, and Sprites executors all use the same handshake
flow. Removes the old AGENTCTL_AUTH_TOKEN env var approach. For
Docker/Sprites, the retrieved token is persisted encrypted in
SecretStore (AES-256-GCM) for crash recovery.

Security properties:
- Auth enabled from boot (no unprotected window)
- Token never in env vars (only the burned nonce)
- Nonce is one-shot (replay impossible)
- Constant-time token comparison (timing-safe)

* fix: check fmt.Sscanf return value in integration test

* fix: address review feedback - constant-time nonce comparison, safe ID truncation

* fix(backend): exempt MCP routes from agentctl auth middleware

The agent subprocess connects to agentctl's MCP endpoints (/sse, /message,
/mcp) to use kandev tools (create_task, ask_user_question, etc.). These
endpoints run on the same HTTP server as the authenticated API, but the
agent process doesn't possess the auth token — it was only exchanged
between the backend and agentctl during the handshake.

Exempt /sse, /message, and /mcp from the Bearer auth middleware since the
agent process runs in the same trust boundary as agentctl.

* refactor(backend): remove query param token fallback and document SetAuthToken constraint

Remove the ?token= query parameter fallback from extractBearerToken —
tokens in URLs leak via access logs, referer headers, and browser history.
All WS callers already use Authorization headers via wsAuthHeaders().

Document SetAuthToken's concurrency constraint: it must not be called
concurrently with HTTP requests on the same client.

* fix(backend): enforce mutex in ControlClient/Launcher auth, fix Start() lock scope

- Add sync.Mutex to ControlClient; protect SetAuthToken and AuthToken() reads
- Guard Launcher.AuthToken() with l.mu (written under lock in Start, read unprotected)
- Extract buildAndStartProcess and performHandshake from Start() (was 106 lines, over 80-line limit)
- Release l.mu before blocking health/handshake calls so monitorExit() can close
  l.exited — previously the fast-fail path in waitForHealthy was unreachable
- Update stale executor_backend.go comment (standalone no longer uses env var auth)

Co-authored-by: João Salavisa <undefined@users.noreply.github.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: João Salavisa <undefined@users.noreply.github.com>
2026-04-21 14:00:49 +01:00

434 lines
14 KiB
Go

// Package lifecycle manages agent instance lifecycles including tracking,
// state transitions, and cleanup.
package lifecycle
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/docker"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/common/logger"
)
// ContainerConfig holds configuration for launching a Docker container
type ContainerConfig struct {
AgentConfig agents.Agent
WorkspacePath string
TaskID string
TaskDescription string
Model string
SessionID string
Credentials map[string]string
ProfileInfo *AgentProfileInfo
InstanceID string
MainRepoGitDir string // Path to main repo's .git directory (for worktrees)
McpServers []McpServerConfig
McpMode string
BootstrapNonce string // one-time nonce for agentctl handshake (set internally)
}
// ContainerManager handles Docker container lifecycle operations
type ContainerManager struct {
dockerClient *docker.Client
commandBuilder *CommandBuilder
logger *logger.Logger
networkName string
}
// NewContainerManager creates a new ContainerManager
func NewContainerManager(dockerClient *docker.Client, networkName string, log *logger.Logger) *ContainerManager {
return &ContainerManager{
dockerClient: dockerClient,
commandBuilder: NewCommandBuilder(),
logger: log.WithFields(zap.String("component", "container-manager")),
networkName: networkName,
}
}
// LaunchResult holds the result of a successful container launch.
type LaunchResult struct {
ContainerID string
Client *agentctl.Client
AuthToken string // auth token retrieved via handshake (for encrypted storage)
}
// LaunchContainer creates and starts a Docker container for an agent.
// It uses a bootstrap nonce to perform a secure handshake with agentctl:
// the nonce is passed via env var, agentctl generates its own token,
// and the backend retrieves it via POST /auth/handshake.
func (cm *ContainerManager) LaunchContainer(ctx context.Context, config ContainerConfig) (*LaunchResult, error) {
// Generate bootstrap nonce (NOT the auth token — agentctl generates that)
nonce, err := generateBootstrapNonce()
if err != nil {
return nil, fmt.Errorf("failed to generate bootstrap nonce: %w", err)
}
config.BootstrapNonce = nonce
containerID, containerIP, err := cm.createAndStartContainer(ctx, config)
if err != nil {
return nil, err
}
// Create ControlClient (no auth token yet — handshake hasn't happened)
ctl := agentctl.NewControlClient(containerIP, AgentCtlPort, cm.logger)
// Wait for agentctl to be healthy
if err := cm.waitForHealth(ctx, ctl); err != nil {
_ = cm.dockerClient.RemoveContainer(ctx, containerID, true)
return nil, fmt.Errorf("agentctl health check failed: %w", err)
}
// Perform handshake: nonce → token
authToken, err := ctl.Handshake(ctx, nonce)
if err != nil {
_ = cm.dockerClient.RemoveContainer(ctx, containerID, true)
return nil, fmt.Errorf("agentctl handshake failed: %w", err)
}
// Create instance and client
client, err := cm.createInstanceAndClient(ctx, ctl, config, containerID, containerIP)
if err != nil {
return nil, err
}
cm.logger.Info("docker container launched with handshake auth",
zap.String("container_id", containerID),
zap.String("container_ip", containerIP),
zap.String("instance_id", config.InstanceID))
return &LaunchResult{
ContainerID: containerID,
Client: client,
AuthToken: authToken,
}, nil
}
// createAndStartContainer builds, creates, and starts a Docker container.
func (cm *ContainerManager) createAndStartContainer(
ctx context.Context, config ContainerConfig,
) (string, string, error) {
containerCfg, err := cm.buildContainerConfig(config)
if err != nil {
return "", "", fmt.Errorf("failed to build container config: %w", err)
}
containerID, err := cm.dockerClient.CreateContainer(ctx, containerCfg)
if err != nil {
return "", "", fmt.Errorf("failed to create container: %w", err)
}
if err := cm.dockerClient.StartContainer(ctx, containerID); err != nil {
_ = cm.dockerClient.RemoveContainer(ctx, containerID, true)
return "", "", fmt.Errorf("failed to start container: %w", err)
}
containerIP, err := cm.dockerClient.GetContainerIP(ctx, containerID)
if err != nil {
cm.logger.Warn("failed to get container IP, trying localhost",
zap.String("container_id", containerID), zap.Error(err))
containerIP = "127.0.0.1"
}
return containerID, containerIP, nil
}
// createInstanceAndClient creates an agent instance in the container and returns the client.
func (cm *ContainerManager) createInstanceAndClient(
ctx context.Context,
ctl *agentctl.ControlClient,
config ContainerConfig,
containerID, containerIP string,
) (*agentctl.Client, error) {
agentType := ""
if config.AgentConfig != nil {
agentType = config.AgentConfig.ID()
}
disableAskQuestion := agents.IsPassthroughOnly(config.AgentConfig)
assumeMcpSse := false
if config.AgentConfig != nil {
if rt := config.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
}
}
createReq := &agentctl.CreateInstanceRequest{
ID: config.InstanceID,
WorkspacePath: "/workspace",
AgentCommand: "",
AgentType: agentType,
Env: config.Credentials,
AutoStart: false,
McpServers: config.McpServers,
SessionID: config.SessionID,
DisableAskQuestion: disableAskQuestion,
AssumeMcpSse: assumeMcpSse,
McpMode: config.McpMode,
}
resp, err := ctl.CreateInstance(ctx, createReq)
if err != nil {
_ = cm.dockerClient.RemoveContainer(ctx, containerID, true)
return nil, fmt.Errorf("failed to create instance in container: %w", err)
}
// ControlClient already has the auth token set via Handshake —
// read it back for the per-instance Client.
client := agentctl.NewClient(containerIP, resp.Port, cm.logger,
agentctl.WithExecutionID(config.InstanceID),
agentctl.WithSessionID(config.SessionID),
agentctl.WithAuthToken(ctl.AuthToken()))
return client, nil
}
// waitForHealth waits for agentctl to be healthy with retries
func (cm *ContainerManager) waitForHealth(ctx context.Context, ctl *agentctl.ControlClient) error {
const maxRetries = 30
const retryDelay = 500 * time.Millisecond
for i := 0; i < maxRetries; i++ {
if err := ctl.Health(ctx); err == nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
time.Sleep(retryDelay)
}
return fmt.Errorf("agentctl not healthy after %d retries", maxRetries)
}
// StopContainer stops and removes a Docker container
func (cm *ContainerManager) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error {
if containerID == "" {
return nil
}
if err := cm.dockerClient.StopContainer(ctx, containerID, timeout); err != nil {
cm.logger.Warn("failed to stop container gracefully, forcing removal",
zap.String("container_id", containerID),
zap.Error(err))
}
if err := cm.dockerClient.RemoveContainer(ctx, containerID, true); err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
cm.logger.Info("container stopped and removed",
zap.String("container_id", containerID))
return nil
}
// buildContainerConfig builds the Docker container configuration
func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker.ContainerConfig, error) {
ag := config.AgentConfig
rt := ag.Runtime()
// Build image name with tag
imageName := rt.Image
if rt.Tag != "" {
imageName = fmt.Sprintf("%s:%s", rt.Image, rt.Tag)
}
// Build command using Agent's BuildCommand
cmdOpts := agents.CommandOptions{
Model: config.Model,
SessionID: config.SessionID,
PermissionValues: make(map[string]bool),
}
// Get profile settings if available
if config.ProfileInfo != nil {
cmdOpts.AutoApprove = config.ProfileInfo.AutoApprove
cmdOpts.PermissionValues["auto_approve"] = config.ProfileInfo.AutoApprove
cmdOpts.PermissionValues["allow_indexing"] = config.ProfileInfo.AllowIndexing
cmdOpts.PermissionValues["dangerously_skip_permissions"] = config.ProfileInfo.DangerouslySkipPermissions
}
cmd := ag.BuildCommand(cmdOpts)
// Expand mounts
mounts := cm.expandMounts(rt.Mounts, config.WorkspacePath, ag)
// Add main repo .git directory mount for worktrees
if config.MainRepoGitDir != "" {
mounts = append(mounts, docker.MountConfig{
Source: config.MainRepoGitDir,
Target: config.MainRepoGitDir, // Same path inside container
ReadOnly: false,
})
cm.logger.Debug("added main repo .git directory mount for worktree",
zap.String("path", config.MainRepoGitDir))
}
// Build environment variables
env := cm.buildEnvVars(config)
// Calculate resource limits
memoryBytes := rt.ResourceLimits.MemoryMB * 1024 * 1024
cpuQuota := int64(rt.ResourceLimits.CPUCores * 100000) // Docker CPU quota
containerName := fmt.Sprintf("kandev-agent-%s", config.InstanceID[:8])
containerCfg := docker.ContainerConfig{
Name: containerName,
Image: imageName,
Cmd: cmd.Args(),
Env: env,
WorkingDir: rt.WorkingDir,
Mounts: mounts,
NetworkMode: cm.networkName,
Memory: memoryBytes,
CPUQuota: cpuQuota,
Labels: map[string]string{
"kandev.managed": "true",
"kandev.instance_id": config.InstanceID,
"kandev.task_id": config.TaskID,
"kandev.session_id": config.SessionID,
},
AutoRemove: false, // We manage cleanup ourselves
}
if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" {
containerCfg.Labels["kandev.profile_id"] = config.ProfileInfo.ProfileID
}
return containerCfg, nil
}
// expandMounts expands mount templates with actual paths
func (cm *ContainerManager) expandMounts(templates []agents.MountTemplate, workspacePath string, ag agents.Agent) []docker.MountConfig {
mounts := make([]docker.MountConfig, 0, len(templates)+1) // +1 for potential session dir
for _, mt := range templates {
// Skip workspace mounts if no workspace path is provided
if strings.Contains(mt.Source, "{workspace}") && workspacePath == "" {
cm.logger.Debug("skipping workspace mount - no workspace path provided",
zap.String("target", mt.Target))
continue
}
source := cm.expandMountSource(mt.Source, workspacePath)
mounts = append(mounts, docker.MountConfig{
Source: source,
Target: mt.Target,
ReadOnly: mt.ReadOnly,
})
}
// Add session directory mount from SessionConfig
sessionDirSource := cm.commandBuilder.ExpandSessionDir(ag)
sessionDirTarget := cm.commandBuilder.GetSessionDirTarget(ag)
if sessionDirSource != "" && sessionDirTarget != "" {
mounts = append(mounts, docker.MountConfig{
Source: sessionDirSource,
Target: sessionDirTarget,
ReadOnly: false,
})
cm.logger.Debug("added session directory mount",
zap.String("source", sessionDirSource),
zap.String("target", sessionDirTarget))
}
return mounts
}
// expandMountSource expands template variables in mount source paths
func (cm *ContainerManager) expandMountSource(source, workspacePath string) string {
result := source
result = strings.ReplaceAll(result, "{workspace}", workspacePath)
// Expand {home} to user's home directory
if strings.Contains(result, "{home}") {
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "/tmp"
}
result = strings.ReplaceAll(result, "{home}", homeDir)
}
return result
}
// buildEnvVars builds environment variables for the container
func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string {
ag := config.AgentConfig
rt := ag.Runtime()
env := make([]string, 0)
// Add default env from agent config
for k, v := range rt.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Add standard kandev env vars
env = append(env,
fmt.Sprintf("KANDEV_TASK_ID=%s", config.TaskID),
fmt.Sprintf("KANDEV_INSTANCE_ID=%s", config.InstanceID),
)
// Pass protocol to agentctl inside the container
if rt.Protocol != "" {
env = append(env, fmt.Sprintf("AGENTCTL_PROTOCOL=%s", rt.Protocol))
}
// Configure Git to trust the workspace directory
env = append(env,
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=safe.directory",
"GIT_CONFIG_VALUE_0=*",
)
// Inject credentials from the provided credentials map
for k, v := range config.Credentials {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Add profile-specific label if available
if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" {
env = append(env, fmt.Sprintf("KANDEV_AGENT_PROFILE_ID=%s", config.ProfileInfo.ProfileID))
}
// Inject bootstrap nonce for agentctl handshake (NOT the auth token)
if config.BootstrapNonce != "" {
env = append(env, "AGENTCTL_BOOTSTRAP_NONCE="+config.BootstrapNonce)
}
return env
}
// generateBootstrapNonce creates a cryptographically random 32-byte hex-encoded nonce.
func generateBootstrapNonce() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate bootstrap nonce: %w", err)
}
return hex.EncodeToString(b), nil
}
// ListManagedContainers returns all containers managed by kandev
func (cm *ContainerManager) ListManagedContainers(ctx context.Context) ([]docker.ContainerInfo, error) {
return cm.dockerClient.ListContainers(ctx, map[string]string{
"kandev.managed": "true",
})
}
// GetContainerInfo returns information about a specific container
func (cm *ContainerManager) GetContainerInfo(ctx context.Context, containerID string) (*docker.ContainerInfo, error) {
return cm.dockerClient.GetContainerInfo(ctx, containerID)
}
// RemoveContainer removes a container
func (cm *ContainerManager) RemoveContainer(ctx context.Context, containerID string, force bool) error {
return cm.dockerClient.RemoveContainer(ctx, containerID, force)
}