feat(docker): make mock-agent runnable inside Docker for E2E

Lay the backend groundwork so the existing mock-agent can run inside
local_docker containers — needed for real-Docker E2E tests that don't
require Anthropic/OpenAI API keys.

Container builder bind-mounts a host mock-agent binary at
/usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set
(or build/mock-agent-linux-amd64 sits next to the kandev binary), the
same pattern already used for agentctl. MockAgent.BuildCommand always
resolves via PATH so the same command works on host (E2E PATH includes
bin/) and in containers (mounted at /usr/local/bin/mock-agent).

Mock now declares non-nil empty RemoteAuth so it counts as a
remote-capable agent. The remoteauth catalog includes specs even with
zero methods, and the frontend gating helper treats an empty-method
spec as "no credentials needed" — letting mock-agent pass the
configure-on-executor check that hides uncatalogued agents on remote
executors.

repositoryCloneURL falls back to the local origin remote URL when the
repo has no provider info, so local-only repos with a real remote
(including file:// remotes used in E2E) work on remote executors that
clone inside the container.

Makefile: build-mock-agent-linux target mirrors build-agentctl-linux.
This commit is contained in:
José Almeida
2026-05-02 12:01:32 +01:00
parent cbfff9bc8a
commit 2779fb24aa
8 changed files with 145 additions and 37 deletions
+7 -1
View File
@@ -25,7 +25,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c
LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME)
.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent build-preview clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help
.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help
## Default target
all: build
@@ -75,6 +75,12 @@ build-agentctl-linux:
@$(MKDIR) $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-amd64 ./cmd/agentctl
## Build mock-agent for linux/amd64 (used by Docker E2E tests)
build-mock-agent-linux:
@echo "Building mock-agent for linux/amd64..."
@$(MKDIR) $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/mock-agent-linux-amd64 ./cmd/mock-agent
## Clean build artifacts
clean:
+8 -6
View File
@@ -86,11 +86,10 @@ func (a *MockAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
}
func (a *MockAgent) BuildCommand(opts CommandOptions) Command {
binary := "mock-agent"
if a.binaryPath != "" {
binary = a.binaryPath
}
return Cmd(binary).Build()
// Always resolve via PATH: works on the host (E2E PATH includes the
// kandev bin dir) and inside Docker containers (mock-agent is
// bind-mounted at /usr/local/bin/mock-agent for E2E).
return Cmd("mock-agent").Build()
}
func (a *MockAgent) Runtime() *RuntimeConfig {
@@ -107,7 +106,10 @@ func (a *MockAgent) Runtime() *RuntimeConfig {
}
}
func (a *MockAgent) RemoteAuth() *RemoteAuth { return nil }
// RemoteAuth returns a non-nil empty spec so the mock agent counts as a
// remote-capable agent (it's runnable inside Docker for E2E tests) but
// requires no credentials. The empty methods list signals "no auth needed".
func (a *MockAgent) RemoteAuth() *RemoteAuth { return &RemoteAuth{} }
func (a *MockAgent) InstallScript() string { return "" }
@@ -47,17 +47,23 @@ type ContainerManager struct {
// 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
func NewContainerManager(dockerClient *docker.Client, networkName 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,
resolveAgentctlBinary: resolver.ResolveLinuxBinary,
dockerClient: dockerClient,
commandBuilder: NewCommandBuilder(),
logger: log.WithFields(zap.String("component", "container-manager")),
networkName: networkName,
resolveAgentctlBinary: resolver.ResolveLinuxBinary,
resolveMockAgentBinary: mockResolver.ResolveLinuxBinary,
}
}
@@ -305,6 +311,23 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker
})
}
// 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 := cm.buildEnvVars(config)
@@ -0,0 +1,61 @@
package lifecycle
import (
"fmt"
"os"
"path/filepath"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// MockAgentResolver finds the path to a linux/amd64 mock-agent binary so it
// can be bind-mounted into Docker containers for E2E tests. Resolution order:
// 1. KANDEV_MOCK_AGENT_LINUX_BINARY env var
// 2. build/mock-agent-linux-amd64 relative to the running binary (dev mode)
//
// Unlike AgentctlResolver, "not found" is not an error — only the Docker E2E
// suite needs this binary, so production paths get a nil mount silently.
type MockAgentResolver struct {
logger *logger.Logger
}
// NewMockAgentResolver creates a new resolver.
func NewMockAgentResolver(log *logger.Logger) *MockAgentResolver {
return &MockAgentResolver{
logger: log.WithFields(zap.String("component", "mock_agent_resolver")),
}
}
// ResolveLinuxBinary returns the path to a linux/amd64 mock-agent binary, or
// ("", nil) when none is configured. Returns an error only when an explicit
// env var path is invalid.
func (r *MockAgentResolver) ResolveLinuxBinary() (string, error) {
if envPath := os.Getenv("KANDEV_MOCK_AGENT_LINUX_BINARY"); envPath != "" {
if _, err := os.Stat(envPath); err == nil {
r.logger.Debug("using mock-agent from env var", zap.String("path", envPath))
return envPath, nil
}
return "", fmt.Errorf("KANDEV_MOCK_AGENT_LINUX_BINARY=%q does not exist", envPath)
}
exePath, err := os.Executable()
if err != nil {
return "", nil
}
exeDir := filepath.Dir(exePath)
candidates := []string{
filepath.Join(exeDir, "mock-agent-linux-amd64"),
filepath.Join(exeDir, "..", "build", "mock-agent-linux-amd64"),
filepath.Join(exeDir, "..", "bin", "mock-agent-linux-amd64"),
}
for _, candidate := range candidates {
if _, statErr := os.Stat(candidate); statErr == nil {
abs, _ := filepath.Abs(candidate)
r.logger.Debug("found mock-agent binary", zap.String("path", abs))
return abs, nil
}
}
return "", nil
}
@@ -123,9 +123,10 @@ func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string
}
}
if len(spec.Methods) > 0 {
specs = append(specs, spec)
}
// Include the spec even when it has no methods — this is the
// "no remote credentials needed" signal (e.g. mock agent for tests).
// Frontend gating treats a methods-less spec as "always configured".
specs = append(specs, spec)
}
return Catalog{Specs: specs, methodsByID: methodsByID}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"sync"
@@ -125,25 +126,37 @@ func shouldUseWorktree(executorType string) bool {
return models.ExecutorType(executorType) == models.ExecutorTypeWorktree
}
// repositoryCloneURL builds an HTTPS clone URL from the repository's provider info.
// Returns an empty string if the repository has no provider owner/name or if the
// provider is not recognized.
const providerGitHub = "github"
// repositoryCloneURL builds a clone URL for the repository. It prefers the
// provider info when present (HTTPS GitHub/GitLab/Bitbucket URL); otherwise
// it inspects the local checkout's `origin` remote. The latter lets local-only
// repos with a real remote (or a file:// remote, used by Docker E2E tests)
// participate in remote executors that clone inside the container/sandbox.
func repositoryCloneURL(repo *models.Repository) string {
if repo.ProviderOwner == "" || repo.ProviderName == "" {
if repo.ProviderOwner != "" && repo.ProviderName != "" {
var host string
switch strings.ToLower(repo.Provider) {
case providerGitHub, "":
host = "github.com"
case "gitlab":
host = "gitlab.com"
case "bitbucket":
host = "bitbucket.org"
default:
return ""
}
return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName)
}
if repo.LocalPath == "" {
return ""
}
var host string
switch strings.ToLower(repo.Provider) {
case "github", "":
host = "github.com"
case "gitlab":
host = "gitlab.com"
case "bitbucket":
host = "bitbucket.org"
default:
cmd := exec.Command("git", "-C", repo.LocalPath, "remote", "get-url", "origin")
out, err := cmd.Output()
if err != nil {
return ""
}
return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName)
return strings.TrimSpace(string(out))
}
// getSessionLock returns a per-session mutex, creating one if it doesn't exist.
@@ -226,12 +226,6 @@ function NewSessionForm({
const [isCreating, setIsCreating] = useState(false);
const [contextValue, setContextValue] = useState("blank");
const [selectedProfileId, setSelectedProfileId] = useState(initialProfileId ?? defaultProfileId);
useEnforceCompatibleProfile(
Boolean(executorProfile),
compatibleAgentProfiles,
selectedProfileId,
setSelectedProfileId,
);
const [hasPrompt, setHasPrompt] = useState(false);
const promptRef = useRef<HTMLTextAreaElement>(null);
const {
@@ -251,6 +245,12 @@ function NewSessionForm({
[attachments, handleRemoveAttachment],
);
const compatibleAgentProfiles = useCompatibleAgentProfiles(agentProfiles, executorProfile);
useEnforceCompatibleProfile(
Boolean(executorProfile),
compatibleAgentProfiles,
selectedProfileId,
setSelectedProfileId,
);
const profileOptions = useAgentProfileOptions(compatibleAgentProfiles);
const sessionOptions = useSessionOptions(taskId);
const isUtilityConfigured = useIsUtilityConfigured();
+4 -2
View File
@@ -22,8 +22,9 @@ function parseJSON<T>(raw: string | undefined, fallback: T): T {
* For remote executors (Docker/Sprites), the executor profile must carry either:
* - a non-env auth method ID for the agent's spec in `remote_credentials`, or
* - a non-null secret keyed by an env method ID in `remote_auth_secrets`.
* Agents without a remote-auth spec (Copilot/Amp/OpenCode/TUI/mock) cannot
* carry credentials on remote executors → blocked.
* Agents without a remote-auth spec (Copilot/Amp/OpenCode/TUI) cannot
* carry credentials on remote executors → blocked. A spec with zero methods
* means "no credentials needed" (mock-agent for tests) → allowed.
*
* Spec IDs are registry-type strings ("claude-acp", "codex-acp", …) which the
* frontend exposes as `AgentProfileOption.agent_name`. `agent_id` is a DB UUID
@@ -38,6 +39,7 @@ export function isAgentConfiguredOnExecutor(
const spec = authSpecs.find((s) => s.id === agent.agent_name);
if (!spec) return false;
if (spec.methods.length === 0) return true;
const credentials = new Set(parseJSON<string[]>(executorProfile.config?.remote_credentials, []));
if (spec.methods.some((m) => m.type !== "env" && credentials.has(m.method_id))) return true;