Files
kandev/apps/backend/internal/agent/lifecycle/executor_standalone.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

199 lines
6.1 KiB
Go

package lifecycle
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/executor"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
)
// StandaloneExecutor implements Runtime for standalone agentctl execution.
// In this mode, a single agentctl control server manages multiple agent instances.
type StandaloneExecutor struct {
ctl *agentctl.ControlClient
host string
port int
authToken string // per-launch auth token from launcher
logger *logger.Logger
interactiveRunner *process.InteractiveRunner
}
// NewStandaloneExecutor creates a new standalone runtime.
func NewStandaloneExecutor(ctl *agentctl.ControlClient, host string, port int, log *logger.Logger) *StandaloneExecutor {
return &StandaloneExecutor{
ctl: ctl,
host: host,
port: port,
logger: log.WithFields(zap.String("runtime", "standalone")),
}
}
// SetAuthToken sets the per-launch auth token for authenticating instance clients.
func (r *StandaloneExecutor) SetAuthToken(token string) {
r.authToken = token
}
func (r *StandaloneExecutor) Name() executor.Name {
return executor.NameStandalone
}
func (r *StandaloneExecutor) HealthCheck(ctx context.Context) error {
return r.ctl.Health(ctx)
}
func (r *StandaloneExecutor) waitForReady(ctx context.Context) error {
if err := r.ctl.Health(ctx); err == nil {
return nil
}
waitCtx := ctx
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
waitCtx, cancel = context.WithTimeout(ctx, 10*time.Second)
defer cancel()
}
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-waitCtx.Done():
return fmt.Errorf("agentctl not ready: %w", waitCtx.Err())
case <-ticker.C:
if err := r.ctl.Health(waitCtx); err == nil {
return nil
}
}
}
}
func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
if err := r.waitForReady(ctx); err != nil {
return nil, err
}
// Build environment variables
env := req.Env
if env == nil {
env = make(map[string]string)
}
env["KANDEV_TASK_ID"] = req.TaskID
env["KANDEV_SESSION_ID"] = req.SessionID
// Create instance via control API
// Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client.
agentType := ""
if req.AgentConfig != nil {
agentType = req.AgentConfig.ID()
}
disableAskQuestion := agents.IsPassthroughOnly(req.AgentConfig)
assumeMcpSse := false
if req.AgentConfig != nil {
if rt := req.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
}
}
createReq := &agentctl.CreateInstanceRequest{
ID: req.InstanceID,
WorkspacePath: req.WorkspacePath,
AgentCommand: "", // Agent command set via Configure endpoint
Protocol: req.Protocol,
AgentType: agentType,
Env: env,
AutoStart: false,
McpServers: req.McpServers,
SessionID: req.SessionID,
TaskID: req.TaskID,
DisableAskQuestion: disableAskQuestion,
AssumeMcpSse: assumeMcpSse,
McpMode: req.McpMode,
}
r.logger.Info("CreateInstance: sending request to agentctl",
zap.String("instance_id", req.InstanceID),
zap.String("req_protocol", req.Protocol),
zap.String("createReq_protocol", createReq.Protocol))
resp, err := r.ctl.CreateInstance(ctx, createReq)
if err != nil {
return nil, fmt.Errorf("failed to create standalone instance: %w", err)
}
// Create agentctl client pointing to the instance port
client := agentctl.NewClient(r.host, resp.Port, r.logger,
agentctl.WithExecutionID(req.InstanceID),
agentctl.WithSessionID(req.SessionID),
agentctl.WithAuthToken(r.authToken))
// Extract runtime-specific values from metadata
worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID)
worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch)
// Build metadata
metadata := make(map[string]interface{})
metadata["standalone_port"] = resp.Port
if worktreeID != "" {
metadata["worktree_id"] = worktreeID
metadata["worktree_path"] = req.WorkspacePath
metadata["worktree_branch"] = worktreeBranch
}
r.logger.Debug("standalone instance created",
zap.String("instance_id", req.InstanceID),
zap.Int("port", resp.Port),
zap.String("workspace", req.WorkspacePath))
return &ExecutorInstance{
InstanceID: req.InstanceID,
TaskID: req.TaskID,
SessionID: req.SessionID,
RuntimeName: string(r.Name()),
Client: client,
StandaloneInstanceID: resp.ID,
StandalonePort: resp.Port,
WorkspacePath: req.WorkspacePath,
Metadata: metadata,
}, nil
}
func (r *StandaloneExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error {
if instance.StandaloneInstanceID == "" {
return nil // No standalone instance to stop
}
if err := r.ctl.DeleteInstance(ctx, instance.StandaloneInstanceID); err != nil {
return fmt.Errorf("failed to stop standalone instance: %w", err)
}
return nil
}
func (r *StandaloneExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) {
// Standalone instances are not persisted - they are transient processes
// managed by agentctl. Session resume will restart them as needed.
return nil, nil
}
// SetInteractiveRunner sets the interactive runner for passthrough mode.
func (r *StandaloneExecutor) SetInteractiveRunner(runner *process.InteractiveRunner) {
r.interactiveRunner = runner
}
// GetInteractiveRunner returns the interactive runner for passthrough mode.
func (r *StandaloneExecutor) GetInteractiveRunner() *process.InteractiveRunner {
return r.interactiveRunner
}
func (r *StandaloneExecutor) RequiresCloneURL() bool { return false }
func (r *StandaloneExecutor) ShouldApplyPreferredShell() bool { return true }
func (r *StandaloneExecutor) IsAlwaysResumable() bool { return false }