diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 54dbe60b49..bfcdd014ea 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -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: diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 8c4aaa1e13..81ab747957 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -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 "" } diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index 09b9125a18..463be0ad1a 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -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) diff --git a/apps/backend/internal/agent/lifecycle/mock_agent_resolver.go b/apps/backend/internal/agent/lifecycle/mock_agent_resolver.go new file mode 100644 index 0000000000..4bed333544 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/mock_agent_resolver.go @@ -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 +} diff --git a/apps/backend/internal/agent/remoteauth/catalog.go b/apps/backend/internal/agent/remoteauth/catalog.go index 07ff2c3eb1..906f1f4444 100644 --- a/apps/backend/internal/agent/remoteauth/catalog.go +++ b/apps/backend/internal/agent/remoteauth/catalog.go @@ -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} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index f0d2ca28dc..8ff1d1e4a5 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -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. diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index cd0963316d..3af3ed399e 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -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(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(); diff --git a/apps/web/lib/agent-executor-compat.ts b/apps/web/lib/agent-executor-compat.ts index 2c84302208..bb206f6039 100644 --- a/apps/web/lib/agent-executor-compat.ts +++ b/apps/web/lib/agent-executor-compat.ts @@ -22,8 +22,9 @@ function parseJSON(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(executorProfile.config?.remote_credentials, [])); if (spec.methods.some((m) => m.type !== "env" && credentials.has(m.method_id))) return true;