Files
kandev/apps/backend/internal/agent/runtime/lifecycle/container_config_test.go
T
Jean-Emmanuel Losi 02ac02cd66 feat(agents): add Muse Code ACP agent support (#3819)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-20 11:33:09 +01:00

605 lines
19 KiB
Go

package lifecycle
import (
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/common/logger"
)
// configStubAgent wraps MockAgent and overrides Runtime() with a fixed
// RuntimeConfig that mimics ACP agents (image+tag, {workspace} placeholder).
type configStubAgent struct {
*agents.MockAgent
rt *agents.RuntimeConfig
}
func (a *configStubAgent) Runtime() *agents.RuntimeConfig { return a.rt }
func newCMTest(t *testing.T) *ContainerManager {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("logger: %v", err)
}
return &ContainerManager{
logger: log,
networkName: "kandev",
commandBuilder: NewCommandBuilder(),
}
}
func newConfigStubAgent() *configStubAgent {
return &configStubAgent{
MockAgent: agents.NewMockAgent(),
rt: &agents.RuntimeConfig{
Image: "kandev/multi-agent",
Tag: "latest",
Cmd: agents.Cmd("/bin/true").Build(),
WorkingDir: "{workspace}",
Mounts: []agents.MountTemplate{{Source: "{workspace}", Target: "/workspace"}},
ResourceLimits: agents.ResourceLimits{
MemoryMB: 256,
CPUCores: 0.5,
},
},
}
}
func TestBuildContainerConfig_ExpandsWorkingDirPlaceholder(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
// WorkspacePath empty → clone-inside-container path; should default to /workspace.
InstanceID: "0123456789abcdef",
TaskID: "task-1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if got.WorkingDir != "/workspace" {
t.Errorf("WorkingDir = %q, want /workspace (placeholder must be expanded)", got.WorkingDir)
}
if strings.Contains(got.WorkingDir, "{") {
t.Errorf("WorkingDir still contains placeholder syntax: %q", got.WorkingDir)
}
}
func TestBuildContainerConfig_WorkingDirIsAlwaysContainerPath(t *testing.T) {
// Regression: WorkingDir is the container-side path, not the host path.
// In host bind-mount mode, WorkspacePath holds the host path; the bind
// mount target is the in-container /workspace, so WorkingDir must point
// at the container target — otherwise Docker happily starts the
// container in /host/path/to/repo (which doesn't exist inside) and the
// agent runs in an unrelated directory.
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
WorkspacePath: "/host/path/to/repo",
InstanceID: "0123456789abcdef",
TaskID: "task-1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if got.WorkingDir != "/workspace" {
t.Errorf("WorkingDir = %q, want /workspace (container-side path)", got.WorkingDir)
}
}
func TestBuildContainerConfigPreflightsBrokerBeforePrepareClone(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
Credentials: map[string]string{
envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve",
envKeyGitHubCredentialLease: "lease",
},
PrepareScript: "git clone https://github.com/acme/widgets.git /workspace",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if len(got.Entrypoint) != 3 {
t.Fatalf("entrypoint = %#v", got.Entrypoint)
}
script := got.Entrypoint[2]
probeAt := strings.Index(script, "curl -sS --connect-timeout")
cloneAt := strings.Index(script, `eval "$KANDEV_PREPARE_SCRIPT"`)
if probeAt < 0 || cloneAt < 0 || probeAt >= cloneAt {
t.Fatalf("broker probe must precede prepare/clone: %s", script)
}
if !strings.Contains(script, `exit "$probe_rc"`) {
t.Fatalf("unreachable broker must stop bootstrap before clone: %s", script)
}
}
func TestBuildContainerConfigBoundsPrepareScriptBeforeAgentctl(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
PrepareScript: "sleep 1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if len(got.Entrypoint) != 3 {
t.Fatalf("entrypoint = %#v", got.Entrypoint)
}
script := got.Entrypoint[2]
want := fmt.Sprintf(
"timeout -s TERM -k 1s %s sh -c",
"600s",
)
if !strings.Contains(script, want) {
t.Fatalf("prepare timeout = %q, want bootstrap to contain %q", script, want)
}
if strings.Index(script, want) >= strings.Index(script, "exec /usr/local/bin/agentctl") {
t.Fatalf("prepare timeout must run before agentctl: %s", script)
}
}
func TestBuildContainerConfigPublishesManagedGitCredentialHelperBeforeAgentctlStartup(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
Credentials: map[string]string{
envKeyGitHubCredentialBrokerURL: "https://kandev.example/api/v1/github/credentials/resolve",
envKeyGitHubCredentialLease: "lease",
},
PrepareScript: "git clone https://github.com/acme/widgets.git /workspace",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
want := "KANDEV_GITHUB_CREDENTIAL_HELPER_PATH=/usr/local/bin/agentctl"
if !containsExactString(got.Env, want) {
t.Fatalf("container env missing pre-start credential helper %q: %#v", want, got.Env)
}
}
func TestBuildContainerConfigScrubsForkPRCredentialsBeforeAgentctl(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
Credentials: map[string]string{"GITHUB_TOKEN": "secret"},
Metadata: map[string]interface{}{metadataCheckoutRef: "refs/pull/3527/head"},
PrepareScript: "echo prepare",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
script := got.Entrypoint[2]
if !strings.Contains(script, "${"+selectedCheckoutMarker+":-}") {
t.Fatalf("bootstrap marker check missing: %s", script)
}
if !strings.Contains(script, selectedCheckoutCredentialScrubCommands) {
t.Fatalf("bootstrap credential scrub missing: %s", script)
}
if !strings.Contains(script, "rm -f /run/kandev/auth.env") {
t.Fatalf("bootstrap must remove auth material: %s", script)
}
}
func containsExactString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestBuildContainerConfig_ImageDefaultsToRuntime(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if got.Image != "kandev/multi-agent:latest" {
t.Errorf("Image = %q, want kandev/multi-agent:latest", got.Image)
}
}
// TestBuildContainerConfig_SecurityOptNilByDefault verifies AC-1: a profile
// with AllowUserNamespaces off (the default) produces SecurityOpt == nil,
// byte-identical to today's launch.
func TestBuildContainerConfig_SecurityOptNilByDefault(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if got.SecurityOpt != nil {
t.Errorf("SecurityOpt = %v, want nil when AllowUserNamespaces is off", got.SecurityOpt)
}
}
// TestBuildContainerConfig_SecurityOptForUserNamespaces verifies AC-2: a
// profile with AllowUserNamespaces on produces seccomp and apparmor opts.
func TestBuildContainerConfig_SecurityOptForUserNamespaces(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
AllowUserNamespaces: true,
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if len(got.SecurityOpt) != 2 {
t.Fatalf("SecurityOpt = %v, want exactly 2 entries", got.SecurityOpt)
}
assertHasSecurityOpt(t, got.SecurityOpt, "apparmor=unconfined")
if !strings.HasPrefix(got.SecurityOpt[0], "seccomp=") {
t.Errorf("SecurityOpt[0] = %q, want seccomp=<json> prefix", got.SecurityOpt[0])
}
}
func TestBuildContainerConfig_ImageTagOverrideWins(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
ImageTagOverride: "kandev/agent:custom",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if got.Image != "kandev/agent:custom" {
t.Errorf("Image = %q, want kandev/agent:custom (profile override must win over rt.Image)", got.Image)
}
}
func TestBuildContainerConfig_LabelsExecutorProfileAndTaskEnvironment(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
TaskTitle: "Readable Task Title",
SessionID: "session-1",
TaskEnvironmentID: "env-1",
ExecutorProfileID: "profile-1",
ImageTagOverride: "kandev/agent:custom",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
assertLabel(t, got.Labels, "kandev.managed", boolStringTrue)
assertLabel(t, got.Labels, "kandev.task_id", "task-1")
assertLabel(t, got.Labels, "kandev.task_title", "Readable Task Title")
assertLabel(t, got.Labels, "kandev.session_id", "session-1")
assertLabel(t, got.Labels, "kandev.task_environment_id", "env-1")
assertLabel(t, got.Labels, "kandev.executor_profile_id", "profile-1")
assertLabel(t, got.Labels, "kandev.profile_id", "profile-1")
assertLabel(t, got.Labels, "com.kandev.image", "kandev/agent:custom")
}
func TestBuildContainerConfig_LabelsE2EDockerScope(t *testing.T) {
t.Setenv("KANDEV_E2E_DOCKER_SCOPE", "e2e-test-scope")
cm := newCMTest(t)
got, err := cm.buildContainerConfig(ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
})
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
assertLabel(t, got.Labels, "kandev.e2e.run", "e2e-test-scope")
}
func TestBuildContainerConfig_PublishesAgentctlPorts(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
if len(got.PortBindings) == 0 {
t.Fatal("expected agentctl ports to be published")
}
assertHasPortBinding(t, got.PortBindings, AgentCtlPort)
assertHasPortBinding(t, got.PortBindings, dockerAgentctlInstancePortBase)
assertHasPortBinding(t, got.PortBindings, dockerAgentctlInstancePortMax)
assertEnvContains(t, got.Env, "AGENTCTL_INSTANCE_PORT_BASE=41001")
assertEnvContains(t, got.Env, "AGENTCTL_INSTANCE_PORT_MAX=41100")
}
// TestDockerAgentctlPortBindings is a direct test for the helper that
// generates the published-port set for every kandev-managed Docker agent
// container. A regression here (wrong port range, missing agentctl port,
// non-loopback host IP) would silently break container reconnect, since
// `resolveDockerEndpoint` falls back to the container IP when the published
// port lookup fails.
func TestDockerAgentctlPortBindings(t *testing.T) {
bindings := dockerAgentctlPortBindings()
wantTotal := 1 + (dockerAgentctlInstancePortMax - dockerAgentctlInstancePortBase + 1)
if len(bindings) != wantTotal {
t.Fatalf("got %d bindings, want %d (control + instance range)", len(bindings), wantTotal)
}
// Control port must be present.
assertHasPortBinding(t, bindings, AgentCtlPort)
// Every port in the instance range must be present and bound to loopback
// with a kernel-assigned host port.
have := make(map[int]docker.PortBindingConfig, len(bindings))
for _, b := range bindings {
have[b.ContainerPort] = b
}
for port := dockerAgentctlInstancePortBase; port <= dockerAgentctlInstancePortMax; port++ {
b, ok := have[port]
if !ok {
t.Fatalf("missing instance port %d in published bindings", port)
}
if b.HostIP != "127.0.0.1" {
t.Errorf("port %d host_ip = %q, want 127.0.0.1", port, b.HostIP)
}
if b.HostPort != "0" {
t.Errorf("port %d host_port = %q, want kernel-assigned (\"0\")", port, b.HostPort)
}
}
}
// TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent locks in
// the agent-agnostic guarantee that bind sources for SessionDirTemplate
// resolve to <kandev-home>/agent-sessions/<instance>/<dotdir> and never to
// the user's host home — the codex bug was a leak of host state into the
// container, and any agent with a SessionDirTemplate is at the same risk.
func TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent(t *testing.T) {
allAgents := []struct {
name string
ag agents.Agent
}{
{"codex-acp", agents.NewCodexACP()},
{"claude-acp", agents.NewClaudeACP()},
{"opencode-acp", agents.NewOpenCodeACP()},
{"devin-acp", agents.NewDevinACP()},
{"copilot-acp", agents.NewCopilotACP()},
{"amp-acp", agents.NewAmpACP()},
{"gemini", agents.NewGemini()},
{"auggie", agents.NewAuggie()},
{"grok-acp", agents.NewGrokACP()},
{"muse-acp", agents.NewMuseACP()},
}
const kandevHome = "/tmp/kandev-test-home"
const instanceID = "0123456789abcdef"
expectedRoot := filepath.Join(kandevHome, "agent-sessions", instanceID)
for _, tc := range allAgents {
t.Run(tc.name, func(t *testing.T) {
rt := tc.ag.Runtime()
if rt == nil {
t.Skipf("%s has no Runtime", tc.name)
}
// expandMounts only adds the session-dir bind when BOTH fields
// are set; agents that omit one rely on the in-container
// SetupScript for auth and never bind-mount the host home in the
// first place. Skip those — the test guards the resolution shape
// only for agents that DO add the bind mount.
if rt.SessionConfig.SessionDirTemplate == "" || rt.SessionConfig.SessionDirTarget == "" {
t.Skipf("%s has no full SessionDirTemplate+SessionDirTarget pair (no bind mount today)", tc.name)
}
cm := newCMTest(t)
cm.kandevHomeDir = kandevHome
cfg := ContainerConfig{
AgentConfig: tc.ag,
InstanceID: instanceID,
TaskID: "task-1",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
target := rt.SessionConfig.SessionDirTarget
var found *docker.MountConfig
for i := range got.Mounts {
if got.Mounts[i].Target == target {
found = &got.Mounts[i]
break
}
}
if found == nil {
t.Fatalf("expected mount for SessionDirTarget %q, got %+v", target, got.Mounts)
}
if !strings.HasPrefix(found.Source, expectedRoot) {
t.Fatalf("session-dir mount source %q not under %q (host home leaked into container?)",
found.Source, expectedRoot)
}
if strings.Contains(found.Source, "{home}") {
t.Fatalf("session-dir mount source %q still references {home} placeholder", found.Source)
}
})
}
}
func TestBuildContainerConfig_MuseMountContainsSeededAuthAndSessionData(t *testing.T) {
cm := newCMTest(t)
cm.kandevHomeDir = "/tmp/kandev-test-home"
const instanceID = "muse-instance"
ag := agents.NewMuseACP()
got, err := cm.buildContainerConfig(ContainerConfig{
AgentConfig: ag,
InstanceID: instanceID,
TaskID: "task-1",
})
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
root := filepath.Join(cm.kandevHomeDir, "agent-sessions", instanceID)
var mount *docker.MountConfig
for i := range got.Mounts {
if got.Mounts[i].Target == "/root" {
mount = &got.Mounts[i]
break
}
}
if mount == nil {
t.Fatalf("expected Muse executor-home mount at /root, got %+v", got.Mounts)
}
if mount.Source != root {
t.Fatalf("Muse mount source = %q, want isolated executor root %q", mount.Source, root)
}
auth := ag.RemoteAuth()
if auth == nil || len(auth.Methods) == 0 {
t.Fatal("Muse must declare remote auth files")
}
seededAuth := filepath.Join(root, auth.Methods[0].TargetRelDir, "auth.json")
if !strings.HasPrefix(seededAuth, mount.Source+string(filepath.Separator)) {
t.Fatalf("seeded auth path %q is outside Muse mount %q", seededAuth, mount.Source)
}
if !strings.HasPrefix(filepath.Join(root, ".local", "share", "muse"), mount.Source+string(filepath.Separator)) {
t.Fatalf("Muse session path is outside Muse mount %q", mount.Source)
}
}
func TestBuildContainerConfig_MountsDevinCredentialSessionDir(t *testing.T) {
cm := newCMTest(t)
cm.kandevHomeDir = "/tmp/kandev-test-home"
instanceID := "devin-instance"
got, err := cm.buildContainerConfig(ContainerConfig{
AgentConfig: agents.NewDevinACP(),
InstanceID: instanceID,
TaskID: "task-1",
})
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
wantSource := filepath.Join(cm.kandevHomeDir, "agent-sessions", instanceID, ".local/share/devin")
wantTarget := "/root/.local/share/devin"
for _, mount := range got.Mounts {
if mount.Source == wantSource && mount.Target == wantTarget {
return
}
}
t.Fatalf("expected Devin credential mount %s -> %s, got %+v", wantSource, wantTarget, got.Mounts)
}
func TestBuildContainerConfig_MountsLocalClonePath(t *testing.T) {
cm := newCMTest(t)
cfg := ContainerConfig{
AgentConfig: newConfigStubAgent(),
InstanceID: "0123456789abcdef",
TaskID: "task-1",
LocalClonePath: "/tmp/e2e-docker-remote.git",
}
got, err := cm.buildContainerConfig(cfg)
if err != nil {
t.Fatalf("buildContainerConfig: %v", err)
}
assertHasMount(t, got.Mounts, "/tmp/e2e-docker-remote.git", "/tmp/e2e-docker-remote.git", true)
}
func assertLabel(t *testing.T, labels map[string]string, key, want string) {
t.Helper()
if labels[key] != want {
t.Fatalf("label %s = %q, want %q in %#v", key, labels[key], want, labels)
}
}
func assertHasMount(t *testing.T, mounts []docker.MountConfig, source, target string, readOnly bool) {
t.Helper()
for _, mount := range mounts {
if mount.Source == source && mount.Target == target && mount.ReadOnly == readOnly {
return
}
}
t.Fatalf("missing mount source=%q target=%q readOnly=%v in %#v", source, target, readOnly, mounts)
}
func assertHasSecurityOpt(t *testing.T, opts []string, want string) {
t.Helper()
for _, opt := range opts {
if opt == want {
return
}
}
t.Fatalf("missing SecurityOpt %q in %#v", want, opts)
}
func assertHasPortBinding(t *testing.T, bindings []docker.PortBindingConfig, port int) {
t.Helper()
for _, binding := range bindings {
if binding.ContainerPort == port && binding.HostIP == "127.0.0.1" && binding.HostPort == "0" {
return
}
}
t.Fatalf("missing published port binding for %d/tcp: %#v", port, bindings)
}
func assertEnvContains(t *testing.T, env []string, want string) {
t.Helper()
for _, item := range env {
if item == want {
return
}
}
t.Fatalf("missing env %q in %#v", want, env)
}