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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user