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>
This commit is contained in:
João Salavisa
2026-04-21 14:00:49 +01:00
committed by GitHub
parent 88f69df702
commit dccff23cc0
27 changed files with 1198 additions and 91 deletions
+2
View File
@@ -31,6 +31,8 @@ func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logge
zap.Int("actual_port", actualPort))
cfg.Agent.StandalonePort = actualPort
}
// Store the per-launch auth token so downstream clients can authenticate
cfg.Agent.StandaloneAuthToken = l.AuthToken()
return cleanup, nil
}
+3
View File
@@ -39,6 +39,7 @@ func provideLifecycleManager(
cfg.Agent.StandaloneHost,
cfg.Agent.StandalonePort,
log,
agentctl.WithControlAuthToken(cfg.Agent.StandaloneAuthToken),
)
standaloneExec := lifecycle.NewStandaloneExecutor(
controlClient,
@@ -46,6 +47,7 @@ func provideLifecycleManager(
cfg.Agent.StandalonePort,
log,
)
standaloneExec.SetAuthToken(cfg.Agent.StandaloneAuthToken)
// Create InteractiveRunner for passthrough mode (no WorkspaceTracker, uses callbacks)
interactiveRunner := process.NewInteractiveRunner(nil, log, 2*1024*1024) // 2MB buffer
@@ -105,6 +107,7 @@ func provideLifecycleManager(
preparerRegistry.Register(agentexecutor.NameDocker, lifecycle.NewDockerPreparer(log))
preparerRegistry.Register(agentexecutor.NameSprites, lifecycle.NewSpritesPreparer(log))
lifecycleMgr.SetPreparerRegistry(preparerRegistry)
lifecycleMgr.SetSecretStore(secretStore)
// MCP handler is set later in main.go after MCP handlers are registered
// via lifecycleMgr.SetMCPHandler(gateway.Dispatcher)
+3 -1
View File
@@ -376,8 +376,10 @@ func startGatewayAndServe(
// Long-lived per-agent-type agentctl instances for boot-time capability
// probes, on-demand refresh via settings, and sessionless utility prompts
// (e.g. "enhance prompt" before a task/session exists).
hostControlClient := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log)
hostControlClient := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log,
agentctlclient.WithControlAuthToken(cfg.Agent.StandaloneAuthToken))
hostUtilityMgr := hostutility.NewManager(agentRegistry, cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, hostControlClient, log)
hostUtilityMgr.SetAuthToken(cfg.Agent.StandaloneAuthToken)
// Wire the host utility manager into the settings controller so
// /api/v1/agent-models/:agentName reads live capability data.
agentSettingsController.SetHostUtility(hostUtilityMgr)
@@ -39,6 +39,7 @@ type Manager struct {
controlHost string
controlPort int
controlClient *agentctlclient.ControlClient
authToken string // per-launch auth token for instance clients
log *logger.Logger
parentTmpDir string
@@ -76,6 +77,11 @@ func NewManager(
}
}
// SetAuthToken sets the per-launch auth token for authenticating instance clients.
func (m *Manager) SetAuthToken(token string) {
m.authToken = token
}
// Start boots one warm instance per ACP-capable inference agent and runs an
// initial probe against each in parallel. Individual agent failures are
// captured in the cache but do not abort the other agents.
@@ -253,7 +259,8 @@ func (m *Manager) createInstance(ctx context.Context, agentType string) (*instan
return nil, fmt.Errorf("create instance: %w", err)
}
client := agentctlclient.NewClient(m.controlHost, resp.Port, m.log)
client := agentctlclient.NewClient(m.controlHost, resp.Port, m.log,
agentctlclient.WithAuthToken(m.authToken))
// Wait a moment for the instance HTTP server to come up.
healthCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
@@ -4,6 +4,8 @@ package lifecycle
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strings"
@@ -31,6 +33,7 @@ type ContainerConfig struct {
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
@@ -51,46 +54,100 @@ func NewContainerManager(dockerClient *docker.Client, networkName string, log *l
}
}
// 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.
// Returns the container ID and agentctl client pointing to the instance.
func (cm *ContainerManager) LaunchContainer(ctx context.Context, config ContainerConfig) (string, *agentctl.Client, error) {
// Build container config
containerCfg, err := cm.buildContainerConfig(config)
// 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 build container config: %w", err)
return nil, fmt.Errorf("failed to generate bootstrap nonce: %w", err)
}
config.BootstrapNonce = nonce
// Create the container
containerID, err := cm.dockerClient.CreateContainer(ctx, containerCfg)
containerID, containerIP, err := cm.createAndStartContainer(ctx, config)
if err != nil {
return "", nil, fmt.Errorf("failed to create container: %w", err)
return nil, err
}
// Start the container
if err := cm.dockerClient.StartContainer(ctx, containerID); err != nil {
_ = cm.dockerClient.RemoveContainer(ctx, containerID, true)
return "", nil, fmt.Errorf("failed to start container: %w", err)
}
// Get container IP for agentctl communication
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"
}
// Create ControlClient to communicate with the container's control server
// 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)
return nil, fmt.Errorf("agentctl health check failed: %w", err)
}
// Create an instance via the control API (same flow as standalone mode)
// 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()
@@ -106,7 +163,7 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe
createReq := &agentctl.CreateInstanceRequest{
ID: config.InstanceID,
WorkspacePath: "/workspace",
AgentCommand: "", // Agent command set via Configure endpoint later
AgentCommand: "",
AgentType: agentType,
Env: config.Credentials,
AutoStart: false,
@@ -120,21 +177,17 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe
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)
return nil, fmt.Errorf("failed to create instance in container: %w", err)
}
// Create agentctl client pointing to the instance port
agentctlClient := agentctl.NewClient(containerIP, resp.Port, cm.logger,
// 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.WithSessionID(config.SessionID),
agentctl.WithAuthToken(ctl.AuthToken()))
cm.logger.Info("docker container launched",
zap.String("container_id", containerID),
zap.String("container_ip", containerIP),
zap.String("instance_id", config.InstanceID),
zap.Int("instance_port", resp.Port))
return containerID, agentctlClient, nil
return client, nil
}
// waitForHealth waits for agentctl to be healthy with retries
@@ -345,9 +398,23 @@ func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string {
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{
@@ -209,6 +209,12 @@ type ExecutorInstance struct {
WorkspacePath string
Metadata map[string]interface{}
StopReason string
// AuthToken is the agentctl auth token retrieved via handshake.
// Populated by Docker executor for encrypted storage in SecretStore.
// Empty for standalone (launcher-owned token wired via cfg.Agent.StandaloneAuthToken)
// and Sprites (no agentctl auth).
AuthToken string
}
// ToAgentExecution converts a ExecutorInstance to an AgentExecution.
@@ -138,14 +138,14 @@ func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreate
McpServers: req.McpServers,
}
// Use ContainerManager to launch container
containerID, client, err := containerMgr.LaunchContainer(ctx, containerCfg)
// Use ContainerManager to launch container (includes nonce handshake)
result, err := containerMgr.LaunchContainer(ctx, containerCfg)
if err != nil {
return nil, fmt.Errorf("failed to launch container: %w", err)
}
// Get container IP for logging
containerIP, _ := dockerClient.GetContainerIP(ctx, containerID)
containerIP, _ := dockerClient.GetContainerIP(ctx, result.ContainerID)
// Build metadata
metadata := make(map[string]interface{})
@@ -157,7 +157,7 @@ func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreate
r.logger.Info("docker instance created",
zap.String("instance_id", req.InstanceID),
zap.String("container_id", containerID),
zap.String("container_id", result.ContainerID),
zap.String("container_ip", containerIP))
return &ExecutorInstance{
@@ -165,11 +165,12 @@ func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreate
TaskID: req.TaskID,
SessionID: req.SessionID,
RuntimeName: string(r.Name()),
Client: client,
ContainerID: containerID,
Client: result.Client,
ContainerID: result.ContainerID,
ContainerIP: containerIP,
WorkspacePath: "/workspace", // Docker mounts workspace to /workspace
Metadata: metadata,
AuthToken: result.AuthToken,
}, nil
}
@@ -370,6 +370,8 @@ func (r *SpritesExecutor) stepApplyNetworkPolicy(
}
// buildInstanceResult constructs the final ExecutorInstance from sprite creation results.
// Note: Sprites do not use agentctl auth tokens because each sprite is an isolated VM,
// and reconnect scenarios cannot retrieve the original token from the running agentctl.
func (r *SpritesExecutor) buildInstanceResult(
req *ExecutorCreateRequest,
spriteName string,
@@ -20,6 +20,7 @@ type StandaloneExecutor struct {
ctl *agentctl.ControlClient
host string
port int
authToken string // per-launch auth token from launcher
logger *logger.Logger
interactiveRunner *process.InteractiveRunner
}
@@ -34,6 +35,11 @@ func NewStandaloneExecutor(ctl *agentctl.ControlClient, host string, port int, l
}
}
// 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
}
@@ -125,7 +131,8 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr
// 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.WithSessionID(req.SessionID),
agentctl.WithAuthToken(r.authToken))
// Extract runtime-specific values from metadata
worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID)
@@ -16,6 +16,7 @@ import (
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/secrets"
"github.com/kandev/kandev/internal/worktree"
)
@@ -93,6 +94,10 @@ type Manager struct {
// pollAggregator routes hub session-mode events to agentctl. See
// manager_subscription.go.
pollAggregator *workspacePollAggregator
// secretStore encrypts/decrypts runtime auth tokens (e.g., agentctl handshake tokens).
// Used to persist tokens across backend restarts for remote executor recovery.
secretStore secrets.SecretStore
}
// NewManager creates a new lifecycle manager.
@@ -261,6 +266,11 @@ func (m *Manager) SetPreparerRegistry(registry *PreparerRegistry) {
m.preparerRegistry = registry
}
// SetSecretStore sets the secret store for encrypting runtime auth tokens.
func (m *Manager) SetSecretStore(store secrets.SecretStore) {
m.secretStore = store
}
// DockerClientProvider returns a function that lazily resolves the Docker client
// from the Docker executor in the registry. Returns nil if Docker is unavailable.
func (m *Manager) DockerClientProvider() func() *docker.Client {
@@ -0,0 +1,145 @@
package lifecycle
import (
"context"
"fmt"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/secrets"
)
// inMemorySecretStore implements secrets.SecretStore for testing.
type inMemorySecretStore struct {
store map[string]*secrets.SecretWithValue
err error
}
var _ secrets.SecretStore = (*inMemorySecretStore)(nil)
func newInMemorySecretStore() *inMemorySecretStore {
return &inMemorySecretStore{store: make(map[string]*secrets.SecretWithValue)}
}
func (s *inMemorySecretStore) Create(_ context.Context, secret *secrets.SecretWithValue) error {
if s.err != nil {
return s.err
}
if secret.ID == "" {
secret.ID = fmt.Sprintf("secret-%d", len(s.store)+1)
}
s.store[secret.ID] = secret
return nil
}
func (s *inMemorySecretStore) Get(_ context.Context, id string) (*secrets.Secret, error) {
if sw, ok := s.store[id]; ok {
return &sw.Secret, nil
}
return nil, fmt.Errorf("not found")
}
func (s *inMemorySecretStore) Reveal(_ context.Context, id string) (string, error) {
if sw, ok := s.store[id]; ok {
return sw.Value, nil
}
return "", fmt.Errorf("not found")
}
func (s *inMemorySecretStore) Update(_ context.Context, _ string, _ *secrets.UpdateSecretRequest) error {
return nil
}
func (s *inMemorySecretStore) Delete(_ context.Context, _ string) error { return nil }
func (s *inMemorySecretStore) List(_ context.Context) ([]*secrets.SecretListItem, error) {
return nil, nil
}
func (s *inMemorySecretStore) Close() error { return nil }
func TestPersistAuthToken(t *testing.T) {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
t.Run("stores token and sets metadata", func(t *testing.T) {
store := newInMemorySecretStore()
m := &Manager{logger: log, secretStore: store}
instance := &ExecutorInstance{
InstanceID: "exec-123456789012",
AuthToken: "handshake-token-abc",
}
execution := &AgentExecution{
Metadata: make(map[string]interface{}),
}
m.persistAuthToken(context.Background(), instance, execution)
// Verify secret was stored
if len(store.store) != 1 {
t.Fatalf("expected 1 secret, got %d", len(store.store))
}
// Verify metadata has the secret ID
secretID, ok := execution.Metadata[MetadataKeyAuthTokenSecret].(string)
if !ok || secretID == "" {
t.Fatalf("expected secret ID in metadata, got %v", execution.Metadata[MetadataKeyAuthTokenSecret])
}
// Verify we can retrieve the token
revealed, err := store.Reveal(context.Background(), secretID)
if err != nil {
t.Fatalf("failed to reveal: %v", err)
}
if revealed != "handshake-token-abc" {
t.Fatalf("expected handshake-token-abc, got %q", revealed)
}
})
t.Run("no-op when auth token is empty", func(t *testing.T) {
store := newInMemorySecretStore()
m := &Manager{logger: log, secretStore: store}
instance := &ExecutorInstance{InstanceID: "exec-123456789012"}
execution := &AgentExecution{Metadata: make(map[string]interface{})}
m.persistAuthToken(context.Background(), instance, execution)
if len(store.store) != 0 {
t.Fatal("expected no secrets stored")
}
if _, ok := execution.Metadata[MetadataKeyAuthTokenSecret]; ok {
t.Fatal("expected no metadata key")
}
})
t.Run("no-op when secret store is nil", func(t *testing.T) {
m := &Manager{logger: log}
instance := &ExecutorInstance{
InstanceID: "exec-123456789012",
AuthToken: "some-token",
}
execution := &AgentExecution{Metadata: make(map[string]interface{})}
// Should not panic
m.persistAuthToken(context.Background(), instance, execution)
})
t.Run("handles nil metadata in execution", func(t *testing.T) {
store := newInMemorySecretStore()
m := &Manager{logger: log, secretStore: store}
instance := &ExecutorInstance{
InstanceID: "exec-123456789012",
AuthToken: "token-xyz",
}
execution := &AgentExecution{}
m.persistAuthToken(context.Background(), instance, execution)
if execution.Metadata == nil {
t.Fatal("expected metadata to be initialized")
}
if _, ok := execution.Metadata[MetadataKeyAuthTokenSecret]; !ok {
t.Fatal("expected secret ID in metadata")
}
})
}
@@ -11,6 +11,7 @@ import (
"github.com/kandev/kandev/internal/agentctl/tracing"
"github.com/kandev/kandev/internal/common/appctx"
"github.com/kandev/kandev/internal/secrets"
)
// ErrSessionWorkspaceNotReady indicates the task session exists but does not yet
@@ -289,6 +290,9 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
execution := runtimeInstance.ToAgentExecution(req)
execution.RuntimeName = string(rt.Name())
// Persist agentctl auth token from handshake (Docker/remote executors)
m.persistAuthToken(ctx, runtimeInstance, execution)
// Set the ACP session ID for session resumption
if info.ACPSessionID != "" {
execution.ACPSessionID = info.ACPSessionID
@@ -316,3 +320,44 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
return execution, nil
}
// MetadataKeyAuthTokenSecret is the metadata key for the encrypted agentctl auth token secret ID.
const MetadataKeyAuthTokenSecret = "env_secret_id_AGENTCTL_AUTH_TOKEN"
// persistAuthToken stores the agentctl handshake auth token in SecretStore
// and saves the secret ID in the execution's metadata for recovery after restart.
func (m *Manager) persistAuthToken(ctx context.Context, instance *ExecutorInstance, execution *AgentExecution) {
if instance.AuthToken == "" || m.secretStore == nil {
return
}
secret := &secrets.SecretWithValue{
Secret: secrets.Secret{
Name: fmt.Sprintf("agentctl-auth-%s", truncateID(instance.InstanceID, 12)),
},
Value: instance.AuthToken,
}
if err := m.secretStore.Create(ctx, secret); err != nil {
m.logger.Error("failed to persist agentctl auth token",
zap.String("instance_id", instance.InstanceID),
zap.Error(err))
return
}
if execution.Metadata == nil {
execution.Metadata = make(map[string]interface{})
}
execution.Metadata[MetadataKeyAuthTokenSecret] = secret.ID
m.logger.Debug("persisted agentctl auth token in secret store",
zap.String("instance_id", instance.InstanceID),
zap.String("secret_id", secret.ID))
}
// truncateID safely truncates an ID string to maxLen characters.
func truncateID(id string, maxLen int) string {
if len(id) <= maxLen {
return id
}
return id[:maxLen]
}
@@ -273,7 +273,7 @@ type MCPHandler interface {
func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent), mcpHandler MCPHandler, onDisconnect func(err error)) error {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/agent/stream"
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, c.wsAuthHeaders())
if err != nil {
return fmt.Errorf("failed to connect to updates stream: %w", err)
}
@@ -29,6 +29,7 @@ type Client struct {
logger *logger.Logger
executionID string
sessionID string
authToken string // shared secret for Bearer auth
// Optional trace context for session-scoped spans in background goroutines.
// When set, stream read loops use this as parent context for tracing instead of context.Background().
@@ -64,6 +65,13 @@ func WithSessionID(id string) ClientOption {
}
}
// WithAuthToken sets the Bearer token for authenticating requests to agentctl.
func WithAuthToken(token string) ClientOption {
return func(c *Client) {
c.authToken = token
}
}
// SetTraceContext sets the trace context used as parent for spans created in
// background goroutines (stream read loops). Thread-safe: can be called after construction.
func (c *Client) SetTraceContext(ctx context.Context) {
@@ -113,9 +121,40 @@ func NewClient(host string, port int, log *logger.Logger, opts ...ClientOption)
for _, opt := range opts {
opt(c)
}
// Install auth transport after options are applied so WithAuthToken takes effect.
if c.authToken != "" {
c.httpClient.Transport = &authTransport{token: c.authToken, base: c.httpClient.Transport}
c.longRunningHTTPClient.Transport = &authTransport{token: c.authToken, base: c.longRunningHTTPClient.Transport}
}
return c
}
// authTransport is an http.RoundTripper that injects an Authorization header.
type authTransport struct {
token string
base http.RoundTripper
}
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+t.token)
base := t.base
if base == nil {
base = http.DefaultTransport
}
return base.RoundTrip(r)
}
// wsAuthHeaders returns HTTP headers for WebSocket dial calls.
func (c *Client) wsAuthHeaders() http.Header {
if c.authToken == "" {
return nil
}
h := http.Header{}
h.Set("Authorization", "Bearer "+c.authToken)
return h
}
// Health checks if agentctl is healthy
func (c *Client) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
@@ -70,7 +70,7 @@ func isTransientConnError(err error) bool {
// The caller is responsible for closing the returned connection.
func (c *Client) StreamShellTerminal(ctx context.Context, terminalID string) (*websocket.Conn, error) {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/shell/terminal/" + terminalID + "/stream"
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, c.wsAuthHeaders())
if err != nil {
return nil, fmt.Errorf("failed to connect to shell terminal stream: %w", err)
}
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/kandev/kandev/internal/common/logger"
@@ -18,9 +19,11 @@ import (
// It manages creation and deletion of agent instances.
// Used by both Docker and standalone runtimes.
type ControlClient struct {
mu sync.Mutex
baseURL string
httpClient *http.Client
logger *logger.Logger
authToken string
}
// McpServerConfig holds configuration for an MCP server.
@@ -69,15 +72,97 @@ type InstanceInfo struct {
CreatedAt time.Time `json:"created_at"`
}
// ControlClientOption configures optional ControlClient settings.
type ControlClientOption func(*ControlClient)
// WithControlAuthToken sets the Bearer token for authenticating control requests.
func WithControlAuthToken(token string) ControlClientOption {
return func(c *ControlClient) {
c.authToken = token
}
}
// NewControlClient creates a new ControlClient for the agentctl control server.
func NewControlClient(host string, port int, log *logger.Logger) *ControlClient {
return &ControlClient{
func NewControlClient(host string, port int, log *logger.Logger, opts ...ControlClientOption) *ControlClient {
c := &ControlClient{
baseURL: fmt.Sprintf("http://%s:%d", host, port),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
logger: log.WithFields(zap.String("component", "agentctl-control")),
}
for _, opt := range opts {
opt(c)
}
if c.authToken != "" {
c.httpClient.Transport = &authTransport{token: c.authToken}
}
return c
}
// AuthToken returns the current auth token. Used to propagate the token
// from the ControlClient to per-instance Clients after a handshake.
func (c *ControlClient) AuthToken() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.authToken
}
// SetAuthToken sets the Bearer token for future requests.
// Used after a successful Handshake to authenticate subsequent calls.
func (c *ControlClient) SetAuthToken(token string) {
c.mu.Lock()
defer c.mu.Unlock()
c.authToken = token
c.httpClient.Transport = &authTransport{token: token}
}
// Handshake performs the bootstrap handshake with agentctl.
// It sends the one-time nonce and receives the self-generated auth token.
// On success, the token is automatically set for future requests.
func (c *ControlClient) Handshake(ctx context.Context, nonce string) (string, error) {
body, err := json.Marshal(map[string]string{"nonce": nonce})
if err != nil {
return "", fmt.Errorf("failed to marshal handshake request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/auth/handshake", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("handshake failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
var errResp struct {
Error string `json:"error"`
}
if decErr := json.NewDecoder(resp.Body).Decode(&errResp); decErr == nil && errResp.Error != "" {
return "", fmt.Errorf("handshake rejected: %s (status %d)", errResp.Error, resp.StatusCode)
}
return "", fmt.Errorf("handshake failed: status %d", resp.StatusCode)
}
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode handshake response: %w", err)
}
if result.Token == "" {
return "", fmt.Errorf("handshake returned empty token")
}
// Automatically set the token for future requests
c.SetAuthToken(result.Token)
c.logger.Info("bootstrap handshake completed")
return result.Token, nil
}
// Health checks if the agentctl control server is healthy.
@@ -6,6 +6,8 @@ package launcher
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
@@ -16,6 +18,7 @@ import (
"sync"
"time"
agentctlclient "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
@@ -38,6 +41,10 @@ type Launcher struct {
// When the backend dies (even SIGKILL), the kernel closes this FD,
// breaking the pipe. agentctl detects the break and self-terminates.
parentPipe *os.File
// authToken is retrieved via handshake after agentctl starts.
// agentctl generates its own token; the launcher retrieves it using the bootstrap nonce.
authToken string
}
// Config holds configuration for the launcher.
@@ -74,6 +81,23 @@ func (l *Launcher) Port() int {
return l.port
}
// AuthToken returns the auth token retrieved via handshake.
// Used by the backend to authenticate requests to agentctl.
func (l *Launcher) AuthToken() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.authToken
}
// generateNonce creates a cryptographically random 32-byte hex-encoded nonce.
func generateNonce() (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
}
// agentctlBinaryName returns the platform-appropriate binary name.
func agentctlBinaryName() string {
if runtime.GOOS == "windows" {
@@ -120,47 +144,71 @@ func findAgentctlBinary() string {
// Start spawns the agentctl subprocess and waits for it to become healthy.
func (l *Launcher) Start(ctx context.Context) error {
l.mu.Lock()
defer l.mu.Unlock()
if l.cmd != nil {
l.mu.Unlock()
return fmt.Errorf("agentctl already running")
}
// Ensure port is available (diagnoses and cleans up stale processes if needed)
if err := l.ensurePortAvailable(); err != nil {
l.mu.Unlock()
return fmt.Errorf("port %d not available: %w", l.port, err)
}
nonce, err := generateNonce()
if err != nil {
l.mu.Unlock()
return err
}
if err := l.buildAndStartProcess(nonce); err != nil {
l.mu.Unlock()
return err
}
// Release the lock before blocking so monitorExit() can close l.exited,
// enabling the fast-fail path in waitForHealthy if agentctl crashes.
l.mu.Unlock()
if err := l.waitForHealthy(ctx); err != nil {
if killErr := l.cmd.Process.Kill(); killErr != nil {
l.logger.Warn("failed to kill agentctl process after failed health check", zap.Error(killErr))
}
l.closeParentPipe()
return fmt.Errorf("agentctl failed to become healthy: %w", err)
}
token, err := l.performHandshake(ctx, nonce)
if err != nil {
return err
}
l.mu.Lock()
l.authToken = token
l.mu.Unlock()
l.logger.Info("agentctl is healthy and authenticated")
return nil
}
// buildAndStartProcess sets up and launches the agentctl subprocess.
func (l *Launcher) buildAndStartProcess(nonce string) error {
l.logger.Info("starting agentctl subprocess",
zap.String("binary", l.binaryPath),
zap.Int("port", l.port),
zap.String("host", l.host))
// Build command with flags
// Note: We use exec.Command (not CommandContext) because we want to control
// shutdown ourselves via Stop(). CommandContext sends SIGKILL on context
// cancellation which prevents graceful shutdown.
l.cmd = exec.Command(l.binaryPath,
fmt.Sprintf("-port=%d", l.port),
)
// Use exec.Command (not CommandContext) so we control shutdown via Stop().
// CommandContext sends SIGKILL on cancellation, preventing graceful shutdown.
l.cmd = exec.Command(l.binaryPath, fmt.Sprintf("-port=%d", l.port))
// Inherit environment from parent process
l.cmd.Env = os.Environ()
// Set process attributes:
// - Pdeathsig on Linux: kernel sends SIGTERM to child when parent dies.
// Inject bootstrap nonce; agentctl generates its own auth token.
l.cmd.Env = append(os.Environ(), "AGENTCTL_BOOTSTRAP_NONCE="+nonce)
l.cmd.SysProcAttr = buildSysProcAttr()
// Parent liveness pipe (Unix only): agentctl inherits the read-end via
// ExtraFiles and blocks on it. When the parent dies the kernel closes the
// write-end, signaling agentctl to shut down. On Windows this is a no-op
// because ExtraFiles is not supported; cleanup relies on gracefulStop.
pipeWrite, err := setupLivenessPipe(l.cmd)
if err != nil {
return err
}
// Capture stdout and stderr
stdout, err := l.cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
@@ -170,41 +218,38 @@ func (l *Launcher) Start(ctx context.Context) error {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Start the process
if err := l.cmd.Start(); err != nil {
closePipeOnStartFailure(pipeWrite, l.cmd)
return fmt.Errorf("failed to start agentctl: %w", err)
}
// Close read-end in parent (child inherited it). Keep write-end open —
// it closes automatically when this process exits, signaling agentctl.
// On Windows these are no-ops (no liveness pipe); parentPipe stays nil.
closeChildPipeEnd(l.cmd)
l.parentPipe = pipeWrite
l.logger.Info("agentctl process started", zap.Int("pid", l.cmd.Process.Pid))
// Pipe stdout/stderr to logger in background
go l.pipeOutput("stdout", bufio.NewScanner(stdout))
go l.pipeOutput("stderr", bufio.NewScanner(stderr))
// Monitor process exit in background
go l.monitorExit()
// Wait for health check to pass
if err := l.waitForHealthy(ctx); err != nil {
// Kill the process if health check fails
if killErr := l.cmd.Process.Kill(); killErr != nil {
l.logger.Warn("failed to kill agentctl process after failed health check", zap.Error(killErr))
}
l.closeParentPipeLocked()
return fmt.Errorf("agentctl failed to become healthy: %w", err)
}
l.logger.Info("agentctl is healthy and ready")
return nil
}
// performHandshake retrieves agentctl's self-generated auth token using the nonce.
// On failure it kills the process and closes the liveness pipe.
func (l *Launcher) performHandshake(ctx context.Context, nonce string) (string, error) {
ctl := agentctlclient.NewControlClient(l.host, l.port, l.logger)
token, err := ctl.Handshake(ctx, nonce)
if err != nil {
if killErr := l.cmd.Process.Kill(); killErr != nil {
l.logger.Warn("failed to kill agentctl after handshake failure", zap.Error(killErr))
}
l.closeParentPipe()
return "", fmt.Errorf("agentctl handshake failed: %w", err)
}
return token, nil
}
// Stop gracefully shuts down the agentctl subprocess.
func (l *Launcher) Stop(ctx context.Context) error {
l.mu.Lock()
@@ -46,7 +46,7 @@ func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamC
c.mu.Unlock()
wsURL := "ws" + c.baseURL[4:] + "/api/v1/workspace/stream"
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, c.wsAuthHeaders())
if err != nil {
return nil, fmt.Errorf("failed to connect to workspace stream: %w", err)
}
@@ -0,0 +1,61 @@
package api
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// bearerTokenAuth returns a gin middleware that validates a Bearer token
// on every request except the exempted paths (e.g., /health).
// If expectedToken is empty, authentication is disabled (no-op middleware).
func bearerTokenAuth(expectedToken string, exemptPaths ...string) gin.HandlerFunc {
if expectedToken == "" {
return func(c *gin.Context) { c.Next() }
}
exempt := make(map[string]bool, len(exemptPaths))
for _, p := range exemptPaths {
exempt[p] = true
}
return func(c *gin.Context) {
if exempt[c.Request.URL.Path] {
c.Next()
return
}
token := extractBearerToken(c.Request)
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing or invalid Authorization header",
})
return
}
if !tokenEqual(token, expectedToken) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid auth token",
})
return
}
c.Next()
}
}
// extractBearerToken extracts the token from the Authorization header.
func extractBearerToken(r *http.Request) string {
const prefix = "Bearer "
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, prefix) {
return auth[len(prefix):]
}
return ""
}
// tokenEqual compares two tokens in constant time to prevent timing attacks.
func tokenEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
@@ -0,0 +1,125 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func setupRouter(token string, exemptPaths ...string) *gin.Engine {
r := gin.New()
r.Use(bearerTokenAuth(token, exemptPaths...))
r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
r.GET("/api/v1/status", func(c *gin.Context) { c.JSON(200, gin.H{"status": "running"}) })
r.GET("/api/v1/data", func(c *gin.Context) { c.JSON(200, gin.H{"data": "secret"}) })
return r
}
func TestBearerTokenAuth_EmptyToken_DisablesAuth(t *testing.T) {
r := setupRouter("")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/status", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestBearerTokenAuth_ValidToken(t *testing.T) {
r := setupRouter("test-secret-token")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/status", nil)
req.Header.Set("Authorization", "Bearer test-secret-token")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestBearerTokenAuth_MissingToken(t *testing.T) {
r := setupRouter("test-secret-token")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/status", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestBearerTokenAuth_InvalidToken(t *testing.T) {
r := setupRouter("test-secret-token")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/status", nil)
req.Header.Set("Authorization", "Bearer wrong-token")
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestBearerTokenAuth_ExemptPath(t *testing.T) {
r := setupRouter("test-secret-token", "/health")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/health", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (health should be exempt)", w.Code)
}
}
func TestBearerTokenAuth_NonExemptPathRequiresAuth(t *testing.T) {
r := setupRouter("test-secret-token", "/health")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/data", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestBearerTokenAuth_QueryParamIgnored(t *testing.T) {
r := setupRouter("test-secret-token")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/status?token=test-secret-token", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 — query param tokens must be ignored, got %d", w.Code)
}
}
func TestBearerTokenAuth_MalformedAuthHeader(t *testing.T) {
r := setupRouter("test-secret-token")
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/v1/status", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for non-Bearer auth, got %d", w.Code)
}
}
func TestTokenEqual_ConstantTime(t *testing.T) {
if !tokenEqual("abc", "abc") {
t.Error("expected equal tokens to match")
}
if tokenEqual("abc", "xyz") {
t.Error("expected different tokens to not match")
}
if tokenEqual("abc", "ab") {
t.Error("expected different length tokens to not match")
}
}
@@ -36,6 +36,7 @@ func NewControlServer(cfg *config.Config, instMgr *instance.Manager, log *logger
}
cs.router.Use(httpmw.RequestLogger(cs.logger, "agentctl-control"))
cs.router.Use(bearerTokenAuth(cfg.AuthToken, "/health", "/auth/handshake"))
cs.setupRoutes()
return cs
@@ -50,6 +51,9 @@ func (m *ControlServer) setupRoutes() {
// Health check
m.router.GET("/health", m.handleHealth)
// Bootstrap handshake — nonce-authenticated, returns the self-generated auth token
m.router.POST("/auth/handshake", m.handleHandshake)
// Instance management API - same endpoints regardless of mode
api := m.router.Group("/api/v1")
api.POST("/instances", m.handleCreateInstance)
@@ -65,6 +69,29 @@ func (m *ControlServer) handleHealth(c *gin.Context) {
})
}
// handleHandshake validates a bootstrap nonce and returns the self-generated auth token.
// This endpoint is only available when agentctl was started with AGENTCTL_BOOTSTRAP_NONCE.
// The nonce is one-shot: after a successful handshake, further attempts are rejected.
func (m *ControlServer) handleHandshake(c *gin.Context) {
var req struct {
Nonce string `json:"nonce" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing or invalid nonce"})
return
}
token := m.cfg.ConsumeNonce(req.Nonce)
if token == "" {
m.logger.Warn("handshake failed: invalid or already-consumed nonce")
c.JSON(http.StatusForbidden, gin.H{"error": "invalid or already-consumed nonce"})
return
}
m.logger.Info("bootstrap handshake completed, auth token issued")
c.JSON(http.StatusOK, gin.H{"token": token})
}
func (m *ControlServer) handleCreateInstance(c *gin.Context) {
var req instance.CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -0,0 +1,145 @@
package api
import (
"context"
"fmt"
"net/http/httptest"
"strings"
"testing"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/common/logger"
)
// TestHandshakeIntegration_FullFlow exercises the complete client↔server
// handshake flow: nonce-based auth → token retrieval → authenticated requests.
// No Docker, no agent binaries — runs in CI as a regular test.
func TestHandshakeIntegration_FullFlow(t *testing.T) {
// 1. Set up a ControlServer with a bootstrap nonce (simulating Docker/Sprites mode)
cfg := &config.Config{
AuthToken: "test-generated-token-xyz",
BootstrapNonce: "integration-test-nonce-abc123",
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cs := NewControlServer(cfg, nil, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
// Parse host:port from test server URL
host, port := parseHostPort(t, server.URL)
// 2. Create a ControlClient with NO auth token (simulating fresh backend)
client := agentctl.NewControlClient(host, port, log)
ctx := context.Background()
// 3. Health check should work without auth
if err := client.Health(ctx); err != nil {
t.Fatalf("health check should work without auth: %v", err)
}
// 4. Authenticated endpoint should be REJECTED (no token yet)
_, err := client.ListInstances(ctx)
if err == nil {
t.Fatal("expected error for unauthenticated request, got nil")
}
// 5. Handshake with correct nonce — should return token
token, err := client.Handshake(ctx, "integration-test-nonce-abc123")
if err != nil {
t.Fatalf("handshake failed: %v", err)
}
if token == "" {
t.Fatal("handshake returned empty token")
}
// 6. After handshake, authenticated requests should WORK
// (ListInstances will return an error because instMgr is nil,
// but it should NOT be a 401 — it should get past auth)
_, err = client.ListInstances(ctx)
if err != nil {
// If the error mentions "unauthorized" or "auth", auth is still failing
if containsAuthError(err.Error()) {
t.Fatalf("request after handshake still rejected by auth: %v", err)
}
// Any other error (e.g., nil instMgr panic recovery) is fine —
// it means auth passed and we hit the handler
}
// 7. Second handshake with same nonce should FAIL (burned)
_, err = client.Handshake(ctx, "integration-test-nonce-abc123")
if err == nil {
t.Fatal("second handshake should fail (nonce burned)")
}
// 8. Handshake with wrong nonce should also fail
_, err = client.Handshake(ctx, "wrong-nonce")
if err == nil {
t.Fatal("handshake with wrong nonce should fail")
}
}
// TestHandshakeIntegration_StandaloneMode verifies that when AuthToken is set
// directly (standalone mode, no nonce), normal Bearer auth works.
func TestHandshakeIntegration_StandaloneMode(t *testing.T) {
cfg := &config.Config{
AuthToken: "standalone-secret-token",
// No BootstrapNonce — standalone mode
}
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
cs := NewControlServer(cfg, nil, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
host, port := parseHostPort(t, server.URL)
ctx := context.Background()
// Without auth token: rejected
noAuthClient := agentctl.NewControlClient(host, port, log)
_, err := noAuthClient.ListInstances(ctx)
if err == nil {
t.Fatal("expected rejection without auth token")
}
// With correct auth token: passes auth
authClient := agentctl.NewControlClient(host, port, log,
agentctl.WithControlAuthToken("standalone-secret-token"))
_, err = authClient.ListInstances(ctx)
if err != nil && containsAuthError(err.Error()) {
t.Fatalf("request with correct token rejected: %v", err)
}
// Handshake should fail (no nonce configured)
_, err = noAuthClient.Handshake(ctx, "any-nonce")
if err == nil {
t.Fatal("handshake should fail when no nonce is configured")
}
}
func parseHostPort(t *testing.T, url string) (string, int) {
t.Helper()
var host string
var port int
// httptest URLs look like "http://127.0.0.1:PORT"
n, err := fmt.Sscanf(url, "http://%s", &host)
if err != nil || n != 1 {
t.Fatalf("failed to parse test server URL %q", url)
}
// Split host:port
for i := len(host) - 1; i >= 0; i-- {
if host[i] == ':' {
_, _ = fmt.Sscanf(host[i+1:], "%d", &port)
host = host[:i]
break
}
}
return host, port
}
func containsAuthError(s string) bool {
lower := strings.ToLower(s)
return strings.Contains(lower, "unauthorized") || strings.Contains(lower, "401")
}
@@ -0,0 +1,136 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/common/logger"
)
func setupHandshakeServer(cfg *config.Config) *ControlServer {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
return NewControlServer(cfg, nil, log)
}
func TestHandshake_ValidNonce(t *testing.T) {
cfg := &config.Config{
AuthToken: "self-generated-token",
BootstrapNonce: "test-nonce-123",
}
cs := setupHandshakeServer(cfg)
body, _ := json.Marshal(map[string]string{"nonce": "test-nonce-123"})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["token"] != "self-generated-token" {
t.Fatalf("expected self-generated-token, got %q", resp["token"])
}
}
func TestHandshake_NonceBurnedAfterUse(t *testing.T) {
cfg := &config.Config{
AuthToken: "token-abc",
BootstrapNonce: "one-time-nonce",
}
cs := setupHandshakeServer(cfg)
// First handshake succeeds
body, _ := json.Marshal(map[string]string{"nonce": "one-time-nonce"})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first handshake: expected 200, got %d", w.Code)
}
// Second handshake with same nonce fails (burned)
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w2, req2)
if w2.Code != http.StatusForbidden {
t.Fatalf("second handshake: expected 403, got %d", w2.Code)
}
}
func TestHandshake_WrongNonce(t *testing.T) {
cfg := &config.Config{
AuthToken: "token-abc",
BootstrapNonce: "correct-nonce",
}
cs := setupHandshakeServer(cfg)
body, _ := json.Marshal(map[string]string{"nonce": "wrong-nonce"})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestHandshake_MissingNonce(t *testing.T) {
cfg := &config.Config{
AuthToken: "token-abc",
BootstrapNonce: "some-nonce",
}
cs := setupHandshakeServer(cfg)
body, _ := json.Marshal(map[string]string{})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestHandshake_ExemptFromAuth(t *testing.T) {
// When auth is enabled, /auth/handshake should still be accessible without a token
cfg := &config.Config{
AuthToken: "self-generated-token",
BootstrapNonce: "test-nonce",
}
cs := setupHandshakeServer(cfg)
// First verify that a protected endpoint requires auth
w1 := httptest.NewRecorder()
req1 := httptest.NewRequest("GET", "/api/v1/instances", nil)
cs.router.ServeHTTP(w1, req1)
if w1.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for protected endpoint, got %d", w1.Code)
}
// But handshake should work without auth
body, _ := json.Marshal(gin.H{"nonce": "test-nonce"})
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest("POST", "/auth/handshake", bytes.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
cs.router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 for handshake (exempt), got %d: %s", w2.Code, w2.Body.String())
}
}
@@ -54,6 +54,11 @@ func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer *
}
s.router.Use(httpmw.RequestLogger(s.logger, "agentctl-instance"))
// Exempt paths from auth:
// - /health: liveness probe
// - /sse, /message, /mcp: MCP endpoints used by the agent subprocess which
// runs in the same trust boundary but does not possess the auth token.
s.router.Use(bearerTokenAuth(cfg.AuthToken, "/health", "/sse", "/message", "/mcp"))
s.setupRoutes()
return s
@@ -11,9 +11,13 @@
package config
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"os"
"strconv"
"strings"
"sync"
"github.com/kandev/kandev/pkg/agent"
)
@@ -41,6 +45,20 @@ type Config struct {
// VS Code server configuration
VscodeCommand string // Command to run code-server (default: "code-server")
// AuthToken is a shared secret for authenticating requests from the kandev backend.
// Generated internally when AGENTCTL_BOOTSTRAP_NONCE is present.
// Empty means authentication is disabled (e.g. dev/test without nonce).
AuthToken string
// BootstrapNonce is a one-time-use nonce for the handshake protocol.
// When set (via AGENTCTL_BOOTSTRAP_NONCE), agentctl generates its own AuthToken.
// at startup and exposes POST /auth/handshake for the backend to retrieve it.
// The nonce is burned after a single successful handshake.
BootstrapNonce string
// mu protects BootstrapNonce from concurrent access during handshake.
mu sync.Mutex
}
// PortConfig defines port allocation for instances
@@ -169,11 +187,15 @@ type InstanceConfig struct {
// McpMode controls which MCP tools are registered for this instance.
// "task" (default) registers kanban/plan tools; "config" registers config tools.
McpMode string
// AuthToken is a shared secret for authenticating requests.
// Inherited from the parent Config at instance creation time.
AuthToken string
}
// Load loads the configuration from environment variables.
func Load() *Config {
return &Config{
cfg := &Config{
Port: getEnvInt("AGENTCTL_PORT", 9999),
Ports: PortConfig{
Base: getEnvInt("AGENTCTL_INSTANCE_PORT_BASE", 10001),
@@ -194,6 +216,44 @@ func Load() *Config {
McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""),
VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"),
}
// Bootstrap nonce mode: agentctl generates its own token and the backend
// retrieves it via handshake using the nonce as proof-of-parentage.
if nonce := os.Getenv("AGENTCTL_BOOTSTRAP_NONCE"); nonce != "" {
cfg.BootstrapNonce = nonce
cfg.AuthToken = generateSelfToken()
}
return cfg
}
// ConsumeNonce atomically validates and burns the bootstrap nonce.
// Returns the auth token if the nonce matches, empty string otherwise.
// The nonce is invalidated after a single successful call (one-shot).
func (c *Config) ConsumeNonce(nonce string) string {
c.mu.Lock()
defer c.mu.Unlock()
if c.BootstrapNonce == "" || nonce == "" {
return ""
}
if subtle.ConstantTimeCompare([]byte(c.BootstrapNonce), []byte(nonce)) != 1 {
return ""
}
// Burn the nonce — only one handshake allowed
c.BootstrapNonce = ""
return c.AuthToken
}
// generateSelfToken creates a cryptographically random 32-byte hex-encoded token.
func generateSelfToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// Fallback should never happen; crypto/rand rarely fails.
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
// NewInstanceConfig creates an InstanceConfig from defaults with optional overrides.
@@ -212,6 +272,7 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst
ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes,
VscodeCommand: c.VscodeCommand,
McpMode: "task",
AuthToken: c.AuthToken,
}
applyOverrides(cfg, overrides)
@@ -0,0 +1,77 @@
package config
import "testing"
func TestConsumeNonce(t *testing.T) {
t.Run("valid nonce returns token and burns nonce", func(t *testing.T) {
cfg := &Config{
AuthToken: "test-token-123",
BootstrapNonce: "nonce-abc",
}
token := cfg.ConsumeNonce("nonce-abc")
if token != "test-token-123" {
t.Fatalf("expected test-token-123, got %q", token)
}
// Second call should fail (nonce burned)
token2 := cfg.ConsumeNonce("nonce-abc")
if token2 != "" {
t.Fatalf("expected empty after nonce burn, got %q", token2)
}
})
t.Run("wrong nonce returns empty", func(t *testing.T) {
cfg := &Config{
AuthToken: "test-token",
BootstrapNonce: "correct-nonce",
}
token := cfg.ConsumeNonce("wrong-nonce")
if token != "" {
t.Fatalf("expected empty for wrong nonce, got %q", token)
}
// Original nonce should still work
token2 := cfg.ConsumeNonce("correct-nonce")
if token2 != "test-token" {
t.Fatalf("expected test-token, got %q", token2)
}
})
t.Run("empty nonce returns empty", func(t *testing.T) {
cfg := &Config{
AuthToken: "test-token",
BootstrapNonce: "some-nonce",
}
token := cfg.ConsumeNonce("")
if token != "" {
t.Fatalf("expected empty for empty nonce, got %q", token)
}
})
t.Run("no bootstrap nonce configured returns empty", func(t *testing.T) {
cfg := &Config{
AuthToken: "test-token",
}
token := cfg.ConsumeNonce("any-nonce")
if token != "" {
t.Fatalf("expected empty when no nonce configured, got %q", token)
}
})
}
func TestGenerateSelfToken(t *testing.T) {
token := generateSelfToken()
if len(token) != 64 { // 32 bytes hex-encoded = 64 chars
t.Fatalf("expected 64-char hex token, got %d chars: %q", len(token), token)
}
// Tokens should be unique
token2 := generateSelfToken()
if token == token2 {
t.Fatal("expected unique tokens, got identical")
}
}
@@ -180,6 +180,10 @@ type AgentConfig struct {
// StandalonePort is the control port for standalone agentctl (default: 9999)
StandalonePort int `mapstructure:"standalonePort"`
// StandaloneAuthToken is the per-launch auth token retrieved via handshake.
// Set at runtime after agentctl starts; not persisted in config files.
StandaloneAuthToken string `mapstructure:"-"`
// McpServerEnabled enables the standalone MCP server (default: false)
// Note: MCP is now embedded in agentctl and tunnels to backend via WebSocket.
// This setting is only for running a separate standalone MCP server process.