Files
kandev/apps/backend/internal/agent/runtime/lifecycle/container.go
T
2026-08-21 19:28:21 +01:00

787 lines
29 KiB
Go

// Package lifecycle manages agent instance lifecycles including tracking,
// state transitions, and cleanup.
package lifecycle
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/docker"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
commonconfig "github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/gitconfigenv"
"github.com/kandev/kandev/internal/githubauth"
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
"github.com/kandev/kandev/internal/task/models"
)
const (
dockerAgentctlInstancePortBase = 41001
dockerAgentctlInstancePortMax = 41100
boolStringTrue = "true"
e2eDockerScopeEnv = "KANDEV_E2E_DOCKER_SCOPE"
e2eDockerScopeLabel = "kandev.e2e.run"
gitHubCredentialHelperConfigKey = "credential.https://github.com.helper"
remoteAgentctlExecutablePath = "/usr/local/bin/agentctl"
)
// ContainerConfig holds configuration for launching a Docker container
type ContainerConfig struct {
AgentConfig agents.Agent
WorkspacePath string // If empty, workspace is not mounted (will clone inside container)
TaskID string
TaskTitle string
TaskEnvironmentID string
TaskDescription string
Model string
SessionID string
ExecutorProfileID string
Credentials map[string]string
AutoApprovePermissions bool
AutoApprovePermissionsOverride *bool
ProfileInfo *AgentProfileInfo
InstanceID string
MainRepoGitDir string // Path to main repo's .git directory (for worktrees)
McpServers []McpServerConfig
McpMode string
McpProviders []string
McpProfile *mcpprofile.Context
PrepareScript string // Script to run inside container before agent starts (e.g., clone repo)
ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag)
LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path.
BootstrapNonce string // one-time nonce for agentctl handshake (set internally)
AgentctlStartupConfig commonconfig.AgentctlStartupConfig
Metadata map[string]interface{} // Optional metadata (e.g., office runtime dir)
// BaseBranches maps RepositoryName → base branch ref; forwarded into
// agentctl's CreateInstanceRequest so each WorkspaceTracker resolves
// diff stats against the task-recorded base.
BaseBranches map[string]string
RemoteContributions map[string]models.RemoteContribution
ContributionDestinations map[string]models.ContributionDestination
}
func boolPtr(v bool) *bool {
return &v
}
func autoApprovePermissionsOverride(enabled bool, override *bool) *bool {
if override != nil {
return override
}
if enabled {
// Preserve compatibility for callers that still only set the legacy bool.
return boolPtr(true)
}
return nil
}
func buildContainerCreateInstanceRequest(
config ContainerConfig,
agentType string,
disableAskQuestion, assumeMcpSse, assumeMcpHttp, requiresProcessKill bool,
stripEnv []string,
) *agentctl.CreateInstanceRequest {
return &agentctl.CreateInstanceRequest{
ID: config.InstanceID,
WorkspacePath: "/workspace",
AgentCommand: "",
AgentType: agentType,
Env: config.Credentials,
AutoApprovePermissions: autoApprovePermissionsOverride(
config.AutoApprovePermissions,
config.AutoApprovePermissionsOverride,
),
AutoStart: false,
McpServers: config.McpServers,
SessionID: config.SessionID,
DisableAskQuestion: disableAskQuestion,
AssumeMcpSse: assumeMcpSse,
AssumeMcpHttp: assumeMcpHttp,
McpMode: config.McpMode,
McpProviders: config.McpProviders,
McpProfile: config.McpProfile,
RequiresProcessKill: requiresProcessKill,
StripEnv: stripEnv,
BaseBranches: config.BaseBranches,
RemoteContributions: config.RemoteContributions,
ContributionDestinations: config.ContributionDestinations,
}
}
// ContainerManager handles Docker container lifecycle operations
type ContainerManager struct {
dockerClient *docker.Client
commandBuilder *CommandBuilder
logger *logger.Logger
networkName string
// kandevHomeDir is the resolved Kandev root dir, used to derive the
// per-container agent session dirs that replace host home bind-mounts.
// Empty means "fall back to legacy {home}/.<agent>" — production callers
// always pass a non-empty value.
kandevHomeDir string
// resolveAgentctlBinary returns the host path to a linux/amd64 agentctl
// binary. Indirected so tests can inject a stub.
resolveAgentctlBinary func() (string, error)
// resolveMockAgentBinary returns the host path to a linux/amd64 mock-agent
// binary. When it returns "" without error, no mock-agent mount is added
// (production case). Used by Docker E2E tests.
resolveMockAgentBinary func() (string, error)
}
// NewContainerManager creates a new ContainerManager. kandevHomeDir is the
// resolved Kandev root dir used to host per-container agent session dirs;
// pass "" only in legacy callers/tests that don't exercise the session-dir
// mount path.
func NewContainerManager(dockerClient *docker.Client, networkName, kandevHomeDir string, log *logger.Logger) *ContainerManager {
resolver := NewAgentctlResolver(log)
mockResolver := NewMockAgentResolver(log)
return &ContainerManager{
dockerClient: dockerClient,
commandBuilder: NewCommandBuilder(),
logger: log.WithFields(zap.String("component", "container-manager")),
networkName: networkName,
kandevHomeDir: kandevHomeDir,
resolveAgentctlBinary: resolver.ResolveLinuxBinary,
resolveMockAgentBinary: mockResolver.ResolveLinuxBinary,
}
}
// fallbackHomeDir is the directory used when os.UserHomeDir() fails.
const fallbackHomeDir = "/tmp"
// homeDir returns the current user's home directory, falling back to /tmp.
func homeDir() string {
h, err := os.UserHomeDir()
if err != nil {
return fallbackHomeDir
}
return h
}
// 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)
BootstrapNonce string // nonce injected into container env for future restart handshakes
}
// LaunchContainer creates and starts a Docker container for an agent.
// It uses a bootstrap nonce to perform a secure handshake with agentctl:
// the nonce is passed via env var, agentctl generates its own token,
// and the backend retrieves it via POST /auth/handshake.
func (cm *ContainerManager) LaunchContainer(ctx context.Context, config ContainerConfig) (*LaunchResult, error) {
baseCtx := preparationContext(ctx)
launchCtx, launchCancel := withLaunchPhaseTimeout(baseCtx)
defer launchCancel()
// Generate bootstrap nonce (NOT the auth token — agentctl generates that)
nonce, err := generateBootstrapNonce()
if err != nil {
return nil, fmt.Errorf("failed to generate bootstrap nonce: %w", err)
}
config.BootstrapNonce = nonce
containerID, containerIP, controlHost, controlPort, err := cm.createAndStartContainer(launchCtx, config)
if err != nil {
return nil, err
}
// Create ControlClient (no auth token yet — handshake hasn't happened)
ctl := agentctl.NewControlClient(controlHost, controlPort, cm.logger)
// Wait for agentctl to be healthy
if err := cm.waitForHealth(baseCtx, ctl); err != nil {
cm.removeContainerBestEffort(containerID)
return nil, fmt.Errorf("agentctl health check failed: %w", err)
}
// Perform handshake: nonce → token
handshakeCtx, handshakeCancel := withLaunchPhaseTimeout(baseCtx)
defer handshakeCancel()
authToken, err := ctl.Handshake(handshakeCtx, nonce)
if err != nil {
cm.removeContainerBestEffort(containerID)
return nil, fmt.Errorf("agentctl handshake failed: %w", err)
}
// Create instance and client
client, err := cm.createInstanceAndClient(handshakeCtx, 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,
BootstrapNonce: nonce,
}, nil
}
// createAndStartContainer builds, creates, and starts a Docker container.
func (cm *ContainerManager) createAndStartContainer(
ctx context.Context, config ContainerConfig,
) (string, string, string, int, error) {
containerCfg, err := cm.buildContainerConfig(config)
if err != nil {
return "", "", "", 0, fmt.Errorf("failed to build container config: %w", err)
}
containerID, err := cm.dockerClient.CreateContainer(ctx, containerCfg)
if err != nil {
return "", "", "", 0, fmt.Errorf("failed to create container: %w", err)
}
if err := cm.dockerClient.StartContainer(ctx, containerID); err != nil {
cm.removeContainerBestEffort(containerID)
return "", "", "", 0, 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"
}
controlHost, controlPort := cm.resolveContainerEndpoint(ctx, containerID, AgentCtlPort, containerIP)
return containerID, containerIP, controlHost, controlPort, nil
}
// createInstanceAndClient creates an agent instance in the container and returns the client.
func (cm *ContainerManager) createInstanceAndClient(
ctx context.Context,
ctl *agentctl.ControlClient,
config ContainerConfig,
containerID, containerIP string,
) (*agentctl.Client, error) {
agentType := ""
if config.AgentConfig != nil {
agentType = config.AgentConfig.ID()
}
disableAskQuestion := !agents.SupportsInteractiveMCPTools(config.AgentConfig)
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
var stripEnv []string
if config.AgentConfig != nil {
if rt := config.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
assumeMcpHttp = rt.AssumeMcpHttp
requiresProcessKill = rt.RequiresProcessKill
stripEnv = rt.StripEnv
}
}
createReq := buildContainerCreateInstanceRequest(
config, agentType, disableAskQuestion, assumeMcpSse, assumeMcpHttp, requiresProcessKill, stripEnv,
)
resp, err := ctl.CreateInstance(ctx, createReq)
if err != nil {
cm.removeContainerBestEffort(containerID)
return nil, fmt.Errorf("failed to create instance in container: %w", err)
}
instanceHost, instancePort := cm.resolveContainerEndpoint(ctx, containerID, resp.Port, containerIP)
// ControlClient already has the auth token set via Handshake —
// read it back for the per-instance Client.
client := agentctl.NewClient(instanceHost, instancePort, cm.logger,
agentctl.WithExecutionID(config.InstanceID),
agentctl.WithSessionID(config.SessionID),
agentctl.WithAuthToken(ctl.AuthToken()))
return client, nil
}
func (cm *ContainerManager) resolveContainerEndpoint(ctx context.Context, containerID string, containerPort int, fallbackHost string) (string, int) {
host, port, err := cm.dockerClient.GetContainerHostPort(ctx, containerID, containerPort)
if err == nil {
return host, port
}
cm.logger.Warn("failed to resolve published Docker port, falling back to container IP",
zap.String("container_id", containerID),
zap.Int("container_port", containerPort),
zap.String("fallback_host", fallbackHost),
zap.Error(err))
return fallbackHost, containerPort
}
func (cm *ContainerManager) removeContainerBestEffort(containerID string) {
if containerID == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := cm.dockerClient.RemoveContainer(ctx, containerID, true); err != nil {
cm.logger.Warn("failed to remove container after launch failure",
zap.String("container_id", containerID),
zap.Error(err))
}
}
// waitForHealth waits for agentctl to be healthy with retries.
// The budget covers the time it takes for the container's bootstrap to exec
// agentctl after the prepare script's own common setup timeout.
func (cm *ContainerManager) waitForHealth(ctx context.Context, ctl *agentctl.ControlClient) error {
const retryDelay = 500 * time.Millisecond
launchTimeout := constants.AgentLaunchTimeout
healthCtx, cancel := withLaunchPhaseTimeout(preparationContext(ctx))
defer cancel()
var lastErr error
for {
if err := ctl.Health(healthCtx); err == nil {
return nil
} else {
lastErr = err
}
if healthCtx.Err() != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("agentctl not healthy after %s: %w", launchTimeout, lastErr)
}
timer := time.NewTimer(retryDelay)
select {
case <-healthCtx.Done():
timer.Stop()
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("agentctl not healthy after %s: %w", launchTimeout, lastErr)
case <-timer.C:
}
}
}
// StopContainer stops and removes a Docker container
func (cm *ContainerManager) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error {
if containerID == "" {
return nil
}
if err := cm.dockerClient.StopContainer(ctx, containerID, timeout); err != nil {
cm.logger.Warn("failed to stop container gracefully, forcing removal",
zap.String("container_id", containerID),
zap.Error(err))
}
if err := cm.dockerClient.RemoveContainer(ctx, containerID, true); err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
cm.logger.Info("container stopped and removed",
zap.String("container_id", containerID))
return nil
}
// buildContainerConfig builds the Docker container configuration
func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker.ContainerConfig, error) {
ag := config.AgentConfig
rt := ag.Runtime()
// Build image name with tag. A profile-level image_tag override (e.g. a
// custom Dockerfile built via the executor profile UI) takes precedence
// over the agent's hardcoded runtime default.
imageName := rt.Image
if rt.Tag != "" {
imageName = fmt.Sprintf("%s:%s", rt.Image, rt.Tag)
}
if config.ImageTagOverride != "" {
imageName = config.ImageTagOverride
}
// We don't pre-build the agent CLI command here: agentctl receives it later
// via its HTTP API (CreateInstance). The container only needs to launch
// agentctl as its main process — see the Entrypoint setup below.
// Two paths through here use different "workspace" strings:
// - Mount sources expand against the HOST workspace path (config.WorkspacePath
// when set, otherwise no host mount in clone-inside-container mode).
// - WorkingDir is an in-container path; it must always be the container-side
// mount target. Both modes converge on /workspace (the bind-mount target
// in host mode, the clone destination in clone-inside mode), so we hard-
// code it here. Without this distinction, host-bind setups would set
// WorkingDir to the host path, and Docker would happily start the
// container in an unrelated directory.
const containerWorkspacePath = "/workspace"
// Expand mounts using the host path so {workspace} substitutions in mount
// sources resolve to a real on-disk location.
mounts := cm.expandMounts(rt.Mounts, config.WorkspacePath, ag, config.InstanceID)
// Add main repo .git directory mount for worktrees
if config.MainRepoGitDir != "" {
mounts = append(mounts, docker.MountConfig{
Source: config.MainRepoGitDir,
Target: config.MainRepoGitDir, // Same path inside container
ReadOnly: false,
})
cm.logger.Debug("added main repo .git directory mount for worktree",
zap.String("path", config.MainRepoGitDir))
}
if config.LocalClonePath != "" {
mounts = append(mounts, docker.MountConfig{
Source: config.LocalClonePath,
Target: config.LocalClonePath,
ReadOnly: true,
})
cm.logger.Debug("added local clone source mount",
zap.String("path", config.LocalClonePath))
}
// Mount the host agentctl linux binary into the container so user-built
// images don't have to bake it in. Resolved via AgentctlResolver — same path
// the Sprites executor uses.
if cm.resolveAgentctlBinary != nil {
agentctlPath, err := cm.resolveAgentctlBinary()
if err != nil {
return docker.ContainerConfig{}, fmt.Errorf("agentctl linux binary not found: %w", err)
}
mounts = append(mounts, docker.MountConfig{
Source: agentctlPath,
Target: "/usr/local/bin/agentctl",
ReadOnly: true,
})
}
// Optionally mount a host mock-agent binary for Docker E2E tests. Production
// builds run real agents installed in the image; this mount only fires when
// KANDEV_MOCK_AGENT_LINUX_BINARY is set or the binary is sitting in build/.
if cm.resolveMockAgentBinary != nil {
mockPath, err := cm.resolveMockAgentBinary()
if err != nil {
return docker.ContainerConfig{}, fmt.Errorf("mock-agent binary lookup: %w", err)
}
if mockPath != "" {
mounts = append(mounts, docker.MountConfig{
Source: mockPath,
Target: "/usr/local/bin/mock-agent",
ReadOnly: true,
})
}
}
// Build environment variables
env, err := cm.buildEnvVars(config)
if err != nil {
return docker.ContainerConfig{}, err
}
// Calculate resource limits
memoryBytes := rt.ResourceLimits.MemoryMB * 1024 * 1024
cpuQuota := int64(rt.ResourceLimits.CPUCores * 100000) // Docker CPU quota
containerName := fmt.Sprintf("kandev-agent-%s", config.InstanceID[:8])
// If a prepare script is provided, pass it as env var for the bootstrap to run
if config.PrepareScript != "" {
env = append(env, "KANDEV_PREPARE_SCRIPT="+config.PrepareScript)
}
// We always launch agentctl as the container's main process and fan out the
// agent subprocess from there via the agentctl HTTP API. This frees user-built
// images from needing to bake an ENTRYPOINT or know which agent to run — they
// only need a runtime that supports the agent CLI (typically node + git).
//
// The agent's BuildCommand result intentionally stops being passed here;
// agentctl receives the agent command later via the CreateInstance API.
//
// Prepare runs in a subshell so its `set -e` (most prepare scripts opt in)
// can't kill the bootstrap before exec'ing agentctl. If prepare fails, we
// still bring agentctl up so the host can connect, surface the failure, and
// the user can debug from the Executor Settings popover.
//
prepareTimeout := formatCoreutilsTimeout(constants.SetupScriptTimeout)
//nolint:dupword // shell branches contain repeated `fi` tokens.
bootstrap := []string{
"sh", "-c",
`if [ -n "${KANDEV_GITHUB_CREDENTIAL_BROKER_URL:-}" ] && [ -n "${KANDEV_GITHUB_CREDENTIAL_LEASE:-}" ]; then
` + brokerReachabilityScript + `
probe_rc=$?
if [ "$probe_rc" -ne 0 ]; then
exit "$probe_rc"
fi
fi
if [ -n "$KANDEV_PREPARE_SCRIPT" ]; then
timeout -s TERM -k 1s ` + prepareTimeout + ` sh -c 'eval "$KANDEV_PREPARE_SCRIPT"'
prep_rc=$?
if [ "$prep_rc" -ne 0 ]; then
echo "[kandev-bootstrap] prepare script failed (exit $prep_rc); starting agentctl anyway so the host can connect and the user can debug via Executor Settings" >&2
fi
fi
exec /usr/local/bin/agentctl`,
}
containerCfg := docker.ContainerConfig{
Name: containerName,
Image: imageName,
Entrypoint: bootstrap,
Cmd: nil,
Env: env,
WorkingDir: cm.expandMountSource(rt.WorkingDir, containerWorkspacePath),
Mounts: mounts,
PortBindings: dockerAgentctlPortBindings(),
NetworkMode: cm.networkName,
Memory: memoryBytes,
CPUQuota: cpuQuota,
Labels: map[string]string{
"kandev.managed": boolStringTrue,
"kandev.instance_id": config.InstanceID,
"kandev.task_id": config.TaskID,
"kandev.session_id": config.SessionID,
"kandev.task_environment_id": config.TaskEnvironmentID,
"kandev.home_dir": homeDir(),
"com.kandev.image": imageName,
},
AutoRemove: false, // We manage cleanup ourselves
}
if scope := os.Getenv(e2eDockerScopeEnv); scope != "" {
containerCfg.Labels[e2eDockerScopeLabel] = scope
}
if config.ExecutorProfileID != "" {
containerCfg.Labels["kandev.executor_profile_id"] = config.ExecutorProfileID
containerCfg.Labels["kandev.profile_id"] = config.ExecutorProfileID
}
if config.TaskTitle != "" {
containerCfg.Labels["kandev.task_title"] = config.TaskTitle
}
if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" {
containerCfg.Labels["kandev.profile_id"] = config.ProfileInfo.ProfileID
}
return containerCfg, nil
}
// formatCoreutilsTimeout converts a Go duration to the single-unit format
// accepted by GNU timeout. time.Duration.String can emit compound values such
// as "10m0s", which GNU timeout rejects as an invalid interval.
func formatCoreutilsTimeout(timeout time.Duration) string {
return strconv.FormatFloat(timeout.Seconds(), 'f', -1, 64) + "s"
}
func dockerAgentctlPortBindings() []docker.PortBindingConfig {
bindings := make([]docker.PortBindingConfig, 0, 1+dockerAgentctlInstancePortMax-dockerAgentctlInstancePortBase+1)
bindings = append(bindings, newDockerPortBinding(AgentCtlPort))
for port := dockerAgentctlInstancePortBase; port <= dockerAgentctlInstancePortMax; port++ {
bindings = append(bindings, newDockerPortBinding(port))
}
return bindings
}
func newDockerPortBinding(containerPort int) docker.PortBindingConfig {
return docker.PortBindingConfig{
ContainerPort: containerPort,
HostIP: "127.0.0.1",
HostPort: "0",
}
}
// expandMounts expands mount templates with actual paths.
//
// Per-agent session dirs (SessionConfig.SessionDirTemplate) are mapped to a
// kandev-managed path under <kandev-home>/agent-sessions/<instance_id>/, NOT
// the user's host home. The seeder (SeedAgentSessionDir) selectively copies
// auth files from the host beforehand, so every agent gets a fresh, isolated
// session dir per launch — host state DBs and session caches that contain
// absolute host paths (e.g. codex's state.db) stay out of the container.
func (cm *ContainerManager) expandMounts(templates []agents.MountTemplate, workspacePath string, ag agents.Agent, instanceID string) []docker.MountConfig {
mounts := make([]docker.MountConfig, 0, len(templates)+1) // +1 for potential session dir
for _, mt := range templates {
// Skip workspace mounts if no workspace path is provided
if strings.Contains(mt.Source, "{workspace}") && workspacePath == "" {
cm.logger.Debug("skipping workspace mount - no workspace path provided",
zap.String("target", mt.Target))
continue
}
source := cm.expandMountSource(mt.Source, workspacePath)
mounts = append(mounts, docker.MountConfig{
Source: source,
Target: mt.Target,
ReadOnly: mt.ReadOnly,
})
}
// Add session directory mount from SessionConfig
sessionDirSource := cm.commandBuilder.ExpandSessionDir(ag, cm.kandevHomeDir, instanceID)
sessionDirTarget := cm.commandBuilder.GetSessionDirTarget(ag)
if sessionDirSource != "" && sessionDirTarget != "" {
mounts = append(mounts, docker.MountConfig{
Source: sessionDirSource,
Target: sessionDirTarget,
ReadOnly: false,
})
cm.logger.Debug("added session directory mount",
zap.String("source", sessionDirSource),
zap.String("target", sessionDirTarget))
}
return mounts
}
// expandMountSource expands template variables in mount source paths.
// Only `{workspace}` is honoured here — agent session dirs that used to use
// `{home}/.<agent>` now route through the kandev-managed per-container
// session dir (see SessionDirHostPath), which keeps host state DBs out of
// the container. Production agents don't ship `{home}` in Mounts; codex_acp
// has a regression test asserting this.
func (cm *ContainerManager) expandMountSource(source, workspacePath string) string {
return strings.ReplaceAll(source, "{workspace}", workspacePath)
}
// buildEnvVars builds environment variables for the container
func (cm *ContainerManager) buildEnvVars(config ContainerConfig) ([]string, error) {
if err := validateAgentctlStartupConfig(config.AgentctlStartupConfig); err != nil {
return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err)
}
ag := config.AgentConfig
rt := ag.Runtime()
env := make([]string, 0)
// Add default env from agent config
for k, v := range rt.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Add standard kandev env vars
env = append(env,
fmt.Sprintf("KANDEV_TASK_ID=%s", config.TaskID),
fmt.Sprintf("KANDEV_INSTANCE_ID=%s", config.InstanceID),
)
// Pass protocol to agentctl inside the container
if rt.Protocol != "" {
env = append(env, fmt.Sprintf("AGENTCTL_PROTOCOL=%s", rt.Protocol))
}
// Configure Git settings via environment
// - Trust all directories (for mounted workspaces)
// - URL rewriting: SSH → HTTPS for GitHub (enables token auth)
// - Credential helper for GitHub HTTPS auth (uses GH_TOKEN env var)
gitConfig := map[string]string{
"GIT_CONFIG_COUNT": "3",
"GIT_CONFIG_KEY_0": "safe.directory",
"GIT_CONFIG_VALUE_0": "*",
"GIT_CONFIG_KEY_1": "url.https://github.com/.insteadOf",
"GIT_CONFIG_VALUE_1": "git@github.com:",
"GIT_CONFIG_KEY_2": "url.https://github.com/.insteadOf",
"GIT_CONFIG_VALUE_2": "ssh://git@github.com/",
}
// If GitHub token is provided, add credential helper
// Use ${GH_TOKEN:-${GITHUB_TOKEN}} to support either env var being set
if config.Credentials["GH_TOKEN"] != "" || config.Credentials["GITHUB_TOKEN"] != "" {
gitConfig["GIT_CONFIG_COUNT"] = "4"
gitConfig["GIT_CONFIG_KEY_3"] = gitHubCredentialHelperConfigKey
gitConfig["GIT_CONFIG_VALUE_3"] = `!f() { echo "username=x-access-token"; echo "password=${GH_TOKEN:-${GITHUB_TOKEN}}"; }; f`
}
gitConfig, err := gitconfigenv.Merge(gitConfig, config.Credentials)
if err != nil {
return nil, fmt.Errorf("compose container Git config: %w", err)
}
gitConfigEntries, err := gitconfigenv.EnvironmentEntries(gitConfig)
if err != nil {
return nil, fmt.Errorf("serialize container Git config: %w", err)
}
env = append(env, gitConfigEntries...)
// Inject credentials from the provided credentials map
for k, v := range config.Credentials {
if k == "GIT_CONFIG_COUNT" || strings.HasPrefix(k, "GIT_CONFIG_KEY_") ||
strings.HasPrefix(k, "GIT_CONFIG_VALUE_") || k == githubauth.CredentialHelperPathEnv {
continue
}
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
if hasManagedGitHubBrokerEnv(config.Credentials) {
env = append(env, githubauth.CredentialHelperPathEnv+"="+remoteAgentctlExecutablePath)
}
// Add profile-specific label if available
if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" {
env = append(env, fmt.Sprintf("KANDEV_AGENT_PROFILE_ID=%s", config.ProfileInfo.ProfileID))
}
env = append(env,
fmt.Sprintf("AGENTCTL_PORT=%d", AgentCtlPort),
fmt.Sprintf("AGENTCTL_INSTANCE_PORT_BASE=%d", dockerAgentctlInstancePortBase),
fmt.Sprintf("AGENTCTL_INSTANCE_PORT_MAX=%d", dockerAgentctlInstancePortMax),
)
// Inject bootstrap nonce for agentctl handshake (NOT the auth token)
if config.BootstrapNonce != "" {
env = append(env, "AGENTCTL_BOOTSTRAP_NONCE="+config.BootstrapNonce)
}
env = removeEnvKey(env, commonconfig.InternalAgentctlStartupConfigEnv)
for key, value := range agentctlStartupEnvironment(config.AgentctlStartupConfig) {
env = append(env, key+"="+value)
}
return env, nil
}
func removeEnvKey(env []string, key string) []string {
prefix := key + "="
filtered := env[:0]
for _, entry := range env {
if !strings.HasPrefix(entry, prefix) {
filtered = append(filtered, entry)
}
}
return filtered
}
// 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) {
labels := map[string]string{"kandev.managed": boolStringTrue}
if scope := os.Getenv(e2eDockerScopeEnv); scope != "" {
labels[e2eDockerScopeLabel] = scope
}
return cm.dockerClient.ListContainers(ctx, labels)
}
// GetContainerInfo returns information about a specific container
func (cm *ContainerManager) GetContainerInfo(ctx context.Context, containerID string) (*docker.ContainerInfo, error) {
return cm.dockerClient.GetContainerInfo(ctx, containerID)
}
// RemoveContainer removes a container
func (cm *ContainerManager) RemoveContainer(ctx context.Context, containerID string, force bool) error {
return cm.dockerClient.RemoveContainer(ctx, containerID, force)
}