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