From dccff23cc021c1f63be09b92d2d9e503adf4f0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 21 Apr 2026 14:00:49 +0100 Subject: [PATCH] feat(backend): per-launch authentication for agentctl (#666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Kandev Agent Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: João Salavisa --- apps/backend/cmd/kandev/agentctl.go | 2 + apps/backend/cmd/kandev/agents.go | 3 + apps/backend/cmd/kandev/main.go | 4 +- .../internal/agent/hostutility/manager.go | 9 +- .../internal/agent/lifecycle/container.go | 143 ++++++++++++----- .../agent/lifecycle/executor_backend.go | 6 + .../agent/lifecycle/executor_docker.go | 13 +- .../agent/lifecycle/executor_sprites.go | 2 + .../agent/lifecycle/executor_standalone.go | 9 +- .../internal/agent/lifecycle/manager.go | 10 ++ .../agent/lifecycle/manager_auth_test.go | 145 ++++++++++++++++++ .../agent/lifecycle/manager_execution.go | 45 ++++++ .../backend/internal/agentctl/client/agent.go | 2 +- .../internal/agentctl/client/client.go | 39 +++++ .../agentctl/client/client_shell_terminal.go | 2 +- .../internal/agentctl/client/control.go | 89 ++++++++++- .../agentctl/client/launcher/launcher.go | 121 ++++++++++----- .../agentctl/client/workspace_stream.go | 2 +- .../internal/agentctl/server/api/auth.go | 61 ++++++++ .../internal/agentctl/server/api/auth_test.go | 125 +++++++++++++++ .../agentctl/server/api/control_server.go | 27 ++++ .../server/api/handshake_integration_test.go | 145 ++++++++++++++++++ .../agentctl/server/api/handshake_test.go | 136 ++++++++++++++++ .../internal/agentctl/server/api/server.go | 5 + .../internal/agentctl/server/config/config.go | 63 +++++++- .../agentctl/server/config/config_test.go | 77 ++++++++++ apps/backend/internal/common/config/config.go | 4 + 27 files changed, 1198 insertions(+), 91 deletions(-) create mode 100644 apps/backend/internal/agent/lifecycle/manager_auth_test.go create mode 100644 apps/backend/internal/agentctl/server/api/auth.go create mode 100644 apps/backend/internal/agentctl/server/api/auth_test.go create mode 100644 apps/backend/internal/agentctl/server/api/handshake_integration_test.go create mode 100644 apps/backend/internal/agentctl/server/api/handshake_test.go create mode 100644 apps/backend/internal/agentctl/server/config/config_test.go diff --git a/apps/backend/cmd/kandev/agentctl.go b/apps/backend/cmd/kandev/agentctl.go index f2ce7930b4..aca9ae79f2 100644 --- a/apps/backend/cmd/kandev/agentctl.go +++ b/apps/backend/cmd/kandev/agentctl.go @@ -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 } diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index b6d6c4141f..1594c23aba 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -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) diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 87a47862b2..180d61b5fb 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -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) diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index eaa8bdccec..a7909ea9e4 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -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) diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index e1eacc2a0c..bdf4582045 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -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{ diff --git a/apps/backend/internal/agent/lifecycle/executor_backend.go b/apps/backend/internal/agent/lifecycle/executor_backend.go index d8fe63093a..2796755365 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -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. diff --git a/apps/backend/internal/agent/lifecycle/executor_docker.go b/apps/backend/internal/agent/lifecycle/executor_docker.go index ae23253d4a..8a04ec44e8 100644 --- a/apps/backend/internal/agent/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker.go @@ -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 } diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites.go b/apps/backend/internal/agent/lifecycle/executor_sprites.go index a7bd3d0864..d838c49b93 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites.go @@ -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, diff --git a/apps/backend/internal/agent/lifecycle/executor_standalone.go b/apps/backend/internal/agent/lifecycle/executor_standalone.go index c4ee35f084..6e8f185835 100644 --- a/apps/backend/internal/agent/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/lifecycle/executor_standalone.go @@ -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) diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index ac5f961e0e..a55b301256 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -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 { diff --git a/apps/backend/internal/agent/lifecycle/manager_auth_test.go b/apps/backend/internal/agent/lifecycle/manager_auth_test.go new file mode 100644 index 0000000000..3a6ffdf0d0 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_auth_test.go @@ -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") + } + }) +} diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index 3a4d342dc0..0bda584d02 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -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] +} diff --git a/apps/backend/internal/agentctl/client/agent.go b/apps/backend/internal/agentctl/client/agent.go index b516dcdc79..73609870e5 100644 --- a/apps/backend/internal/agentctl/client/agent.go +++ b/apps/backend/internal/agentctl/client/agent.go @@ -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) } diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index d33bbde49e..fb96b11277 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -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) diff --git a/apps/backend/internal/agentctl/client/client_shell_terminal.go b/apps/backend/internal/agentctl/client/client_shell_terminal.go index 139603fc25..f188af3d49 100644 --- a/apps/backend/internal/agentctl/client/client_shell_terminal.go +++ b/apps/backend/internal/agentctl/client/client_shell_terminal.go @@ -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) } diff --git a/apps/backend/internal/agentctl/client/control.go b/apps/backend/internal/agentctl/client/control.go index 1d9ee191c3..b0f7ef81f6 100644 --- a/apps/backend/internal/agentctl/client/control.go +++ b/apps/backend/internal/agentctl/client/control.go @@ -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. diff --git a/apps/backend/internal/agentctl/client/launcher/launcher.go b/apps/backend/internal/agentctl/client/launcher/launcher.go index 66eb90c794..27db3f2dd9 100644 --- a/apps/backend/internal/agentctl/client/launcher/launcher.go +++ b/apps/backend/internal/agentctl/client/launcher/launcher.go @@ -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() diff --git a/apps/backend/internal/agentctl/client/workspace_stream.go b/apps/backend/internal/agentctl/client/workspace_stream.go index b43aa499b3..2ca4f01fdd 100644 --- a/apps/backend/internal/agentctl/client/workspace_stream.go +++ b/apps/backend/internal/agentctl/client/workspace_stream.go @@ -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) } diff --git a/apps/backend/internal/agentctl/server/api/auth.go b/apps/backend/internal/agentctl/server/api/auth.go new file mode 100644 index 0000000000..eb4982b749 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/auth.go @@ -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 +} diff --git a/apps/backend/internal/agentctl/server/api/auth_test.go b/apps/backend/internal/agentctl/server/api/auth_test.go new file mode 100644 index 0000000000..d6cd80b0af --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/auth_test.go @@ -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") + } +} diff --git a/apps/backend/internal/agentctl/server/api/control_server.go b/apps/backend/internal/agentctl/server/api/control_server.go index eae59d862c..cfebb9db9d 100644 --- a/apps/backend/internal/agentctl/server/api/control_server.go +++ b/apps/backend/internal/agentctl/server/api/control_server.go @@ -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 { diff --git a/apps/backend/internal/agentctl/server/api/handshake_integration_test.go b/apps/backend/internal/agentctl/server/api/handshake_integration_test.go new file mode 100644 index 0000000000..c2841a085d --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/handshake_integration_test.go @@ -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") +} diff --git a/apps/backend/internal/agentctl/server/api/handshake_test.go b/apps/backend/internal/agentctl/server/api/handshake_test.go new file mode 100644 index 0000000000..38d2bb2623 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/handshake_test.go @@ -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()) + } +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 7c4dcb1b05..150873cd46 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 6cb03f10c0..7ca94b1783 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -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) diff --git a/apps/backend/internal/agentctl/server/config/config_test.go b/apps/backend/internal/agentctl/server/config/config_test.go new file mode 100644 index 0000000000..2b6db8b737 --- /dev/null +++ b/apps/backend/internal/agentctl/server/config/config_test.go @@ -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") + } +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 0bb984a4ba..3a3c2f940d 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -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.