summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/server.go12
-rw-r--r--internal/api/webhook.go56
-rw-r--r--internal/api/webhook_test.go78
-rw-r--r--internal/cli/root.go19
-rw-r--r--internal/cli/run.go16
-rw-r--r--internal/cli/serve.go31
-rw-r--r--internal/config/config.go28
-rw-r--r--internal/config/config_test.go82
-rw-r--r--internal/executor/container.go20
-rw-r--r--internal/executor/container_test.go77
-rw-r--r--internal/task/validator.go3
11 files changed, 372 insertions, 50 deletions
diff --git a/internal/api/server.go b/internal/api/server.go
index fe883bb..4af7325 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -57,6 +57,7 @@ type Server struct {
elaborateLimiter *ipRateLimiter // per-IP rate limiter for elaborate/validate endpoints
webhookSecret string // HMAC-SHA256 secret for GitHub webhook validation
projects []config.Project // configured projects for webhook routing
+ githubURLForTest func(fullName string) string // overrides GitHub SSH URL in tests
vapidPublicKey string
vapidPrivateKey string
vapidEmail string
@@ -64,7 +65,7 @@ type Server struct {
dropsDir string
llm *llm.Client
budget budgetReporter // optional; per-provider spend headroom for GET /api/budget
- basePath string // URL prefix the UI is mounted at, e.g. "/claudomator-oss"
+ basePath string // URL prefix the UI is mounted at, e.g. "/claudomator"
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -95,10 +96,12 @@ func (s *Server) SetWorkspaceRoot(path string) {
}
// SetBasePath sets the URL prefix the UI is served under (e.g. "/claudomator-oss").
+// The server rewrites the base-path meta tag in index.html at request time.
func (s *Server) SetBasePath(p string) {
s.basePath = p
}
+
// Pool returns the executor pool, for graceful shutdown by the caller.
func (s *Server) Pool() *executor.Pool { return s.pool }
@@ -194,15 +197,10 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/drops", s.handlePostDrop)
if s.registry != nil {
mcpHandler := executor.NewAgentMCPHandler(s.registry)
- // The streamable HTTP transport uses POST (messages), GET (SSE stream),
- // and DELETE (session end). Register them explicitly so the patterns are
- // more specific than the "GET /" catch-all and don't conflict.
s.mux.Handle("POST /mcp", mcpHandler)
s.mux.Handle("GET /mcp", mcpHandler)
s.mux.Handle("DELETE /mcp", mcpHandler)
}
- // Chatbot-facing MCP server. Always mounted; the handler refuses to serve
- // unless a shared API token is configured and presented as a bearer.
chatbotHandler := s.chatbotMCPHandler()
s.mux.Handle("POST /chatbot/mcp", chatbotHandler)
s.mux.Handle("GET /chatbot/mcp", chatbotHandler)
@@ -703,6 +701,8 @@ func (s *Server) handleGetExecution(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, exec)
}
+// handleStaticFiles serves embedded web UI files. For index.html it rewrites the
+// base-path meta tag so the UI knows which API prefix to use.
func (s *Server) handleStaticFiles(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
raw, err := fs.ReadFile(webui.Files, "index.html")
diff --git a/internal/api/webhook.go b/internal/api/webhook.go
index 3af4cc8..182a85e 100644
--- a/internal/api/webhook.go
+++ b/internal/api/webhook.go
@@ -10,6 +10,7 @@ import (
"io"
"log/slog"
"net/http"
+ "os/exec"
"path/filepath"
"strings"
"time"
@@ -115,6 +116,8 @@ func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
s.handleCheckRunEvent(w, body)
case "workflow_run":
s.handleWorkflowRunEvent(w, body)
+ case "push":
+ s.handlePushEvent(w, body)
default:
w.WriteHeader(http.StatusNoContent)
}
@@ -230,7 +233,7 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName,
State: task.StatePending,
CreatedAt: now,
UpdatedAt: now,
- RepositoryURL: fmt.Sprintf("https://github.com/%s.git", fullName),
+ RepositoryURL: fmt.Sprintf("git@github.com:%s.git", fullName),
}
if project != nil {
t.Project = project.Name
@@ -243,3 +246,54 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName,
writeJSON(w, http.StatusOK, map[string]string{"task_id": t.ID})
}
+
+// pushPayload is a minimal GitHub push webhook payload.
+type pushPayload struct {
+ Ref string `json:"ref"`
+ Repository struct {
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ } `json:"repository"`
+}
+
+// handlePushEvent syncs the local bare repo with GitHub when a push arrives.
+// The fetch runs asynchronously so the webhook returns immediately.
+func (s *Server) handlePushEvent(w http.ResponseWriter, body []byte) {
+ var p pushPayload
+ if err := json.Unmarshal(body, &p); err != nil || p.Repository.FullName == "" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ project := matchProject(s.projects, p.Repository.Name)
+ if project == nil {
+ slog.Info("push event: no matching project, skipping sync", "repo", p.Repository.FullName)
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ dbProj, err := s.store.GetProject(project.Name)
+ if err != nil || dbProj == nil || dbProj.RemoteURL == "" {
+ slog.Info("push event: project has no local remote, skipping sync", "project", project.Name)
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ githubURL := fmt.Sprintf("git@github.com:%s.git", p.Repository.FullName)
+ if s.githubURLForTest != nil {
+ githubURL = s.githubURLForTest(p.Repository.FullName)
+ }
+ bareRepo := dbProj.RemoteURL
+
+ go func() {
+ out, err := exec.Command("git", "-C", bareRepo, "fetch", githubURL,
+ "+refs/heads/*:refs/heads/*", "--prune").CombinedOutput()
+ if err != nil {
+ slog.Error("github push sync failed", "repo", p.Repository.FullName, "bare", bareRepo, "error", err, "output", string(out))
+ } else {
+ slog.Info("github push sync succeeded", "repo", p.Repository.FullName, "bare", bareRepo)
+ }
+ }()
+
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go
index 967b62b..05fe82c 100644
--- a/internal/api/webhook_test.go
+++ b/internal/api/webhook_test.go
@@ -8,10 +8,14 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "os/exec"
+ "path/filepath"
"strings"
"testing"
+ "time"
"github.com/thepeterstone/claudomator/internal/config"
+ "github.com/thepeterstone/claudomator/internal/task"
)
// signBody computes the HMAC-SHA256 signature for a webhook payload.
@@ -124,8 +128,8 @@ func TestGitHubWebhook_CheckRunFailure_CreatesTask(t *testing.T) {
if !strings.Contains(tk.Name, "main") {
t.Errorf("task name %q does not contain branch", tk.Name)
}
- if tk.RepositoryURL != "https://github.com/owner/myrepo.git" {
- t.Errorf("task repository url = %q, want https://github.com/owner/myrepo.git", tk.RepositoryURL)
+ if tk.RepositoryURL != "git@github.com:owner/myrepo.git" {
+ t.Errorf("task repository url = %q, want git@github.com:owner/myrepo.git", tk.RepositoryURL)
}
if !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") {
t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags)
@@ -375,8 +379,8 @@ func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) {
if err != nil {
t.Fatalf("task not found: %v", err)
}
- if tk.RepositoryURL != "https://github.com/owner/myrepo.git" {
- t.Errorf("expected fallback repository url, got %q", tk.RepositoryURL)
+ if tk.RepositoryURL != "git@github.com:owner/myrepo.git" {
+ t.Errorf("expected fallback repository url (ssh), got %q", tk.RepositoryURL)
}
}
@@ -400,6 +404,72 @@ func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithGitHubURL(t *testing.
}
}
+func TestGitHubWebhook_PushEvent_Returns204(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ payload := `{"ref":"refs/heads/main","repository":{"name":"myrepo","full_name":"owner/myrepo"}}`
+ w := webhookPost(t, srv, "push", payload, "")
+
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGitHubWebhook_PushEvent_SyncsLocalBareRepo(t *testing.T) {
+ dir := t.TempDir()
+
+ // Create a "github" source bare repo with a commit.
+ githubBare := filepath.Join(dir, "github.git")
+ localBare := filepath.Join(dir, "local.git")
+ for _, bare := range []string{githubBare, localBare} {
+ if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git init bare %s: %v\n%s", bare, err, out)
+ }
+ }
+ // Seed githubBare with a commit via a temp clone.
+ clone := filepath.Join(dir, "clone")
+ run := func(args ...string) {
+ t.Helper()
+ if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ run("git", "clone", githubBare, clone)
+ run("git", "-C", clone, "config", "user.email", "test@test.com")
+ run("git", "-C", clone, "config", "user.name", "Test")
+ run("git", "-C", clone, "commit", "--allow-empty", "-m", "initial")
+ run("git", "-C", clone, "push", "origin", "main")
+
+ // Register DB project with localBare as the remote_url.
+ srv, store := testServer(t)
+ proj := &task.Project{ID: "myrepo", Name: "myrepo", RemoteURL: localBare, LocalPath: clone}
+ if err := store.CreateProject(proj); err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+ srv.projects = []config.Project{{Name: "myrepo", Dir: clone}}
+
+ // Override the github SSH URL to point to our local githubBare for testing.
+ srv.githubURLForTest = func(fullName string) string { return githubBare }
+
+ payload := `{"ref":"refs/heads/main","repository":{"name":"myrepo","full_name":"owner/myrepo"}}`
+ w := webhookPost(t, srv, "push", payload, "")
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d", w.Code)
+ }
+
+ // Wait for the async sync goroutine.
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ out, err := exec.Command("git", "-C", localBare, "log", "--oneline", "-1").CombinedOutput()
+ if err == nil && strings.TrimSpace(string(out)) != "" {
+ return // success — local bare has the commit
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Error("local bare repo was not synced from github within timeout")
+}
+
// contains checks if a string slice contains a value.
func contains(slice []string, val string) bool {
for _, s := range slice {
diff --git a/internal/cli/root.go b/internal/cli/root.go
index e57a9d9..2cc8f71 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -38,18 +38,27 @@ func NewRootCmd() *cobra.Command {
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
// Re-derive DBPath and LogDir after flags are parsed, so --data-dir takes effect.
- // If --config is provided, load that file first; explicit CLI flags override it.
+ // Load config file: explicit --config flag, then default location, then defaults-only.
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
- if cfgFile != "" {
+ filePath := cfgFile
+ if filePath == "" {
+ home, _ := os.UserHomeDir()
+ filePath = filepath.Join(home, ".claudomator", "config.toml")
+ }
+
+ if filePath != "" {
// Save values set by explicit CLI flags before overwriting cfg from file.
flagDataDir := cfg.DataDir
flagClaudeBin := cfg.ClaudeBinaryPath
- loaded, err := config.LoadFile(cfgFile)
- if err != nil {
+ loaded, err := config.LoadFile(filePath)
+ if err != nil && cfgFile != "" {
+ // Only hard-fail if the file was explicitly specified.
return err
}
- *cfg = *loaded
+ if err == nil {
+ *cfg = *loaded
+ }
if cmd.Flags().Changed("data-dir") {
cfg.DataDir = flagDataDir
diff --git a/internal/cli/run.go b/internal/cli/run.go
index 48f34b7..771c7b7 100644
--- a/internal/cli/run.go
+++ b/internal/cli/run.go
@@ -77,8 +77,10 @@ func runTasks(file string, parallel int, dryRun bool) error {
apiURL = "http://" + cfg.ServerAddr
}
- runners := map[string]executor.Runner{
- "claude": &executor.ContainerRunner{
+ runners := map[string]executor.Runner{}
+
+ if cfg.Runners.ClaudeEnabled() {
+ runners["claude"] = &executor.ContainerRunner{
Image: cfg.ClaudeImage,
Logger: logger,
LogDir: cfg.LogDir,
@@ -87,8 +89,10 @@ func runTasks(file string, parallel int, dryRun bool) error {
SSHAuthSock: cfg.SSHAuthSock,
ClaudeBinary: cfg.ClaudeBinaryPath,
GeminiBinary: cfg.GeminiBinaryPath,
- },
- "gemini": &executor.ContainerRunner{
+ }
+ }
+ if cfg.Runners.GeminiEnabled() {
+ runners["gemini"] = &executor.ContainerRunner{
Image: cfg.GeminiImage,
Logger: logger,
LogDir: cfg.LogDir,
@@ -97,11 +101,11 @@ func runTasks(file string, parallel int, dryRun bool) error {
SSHAuthSock: cfg.SSHAuthSock,
ClaudeBinary: cfg.ClaudeBinaryPath,
GeminiBinary: cfg.GeminiBinaryPath,
- },
+ }
}
localClient := buildLocalLLMClient(cfg.LocalModel, logger)
- if localClient != nil {
+ if localClient != nil && cfg.Runners.LocalEnabled() {
runners["local"] = &executor.LocalRunner{
Client: localClient,
Logger: logger,
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index fe0ba8e..f687e14 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -86,13 +86,16 @@ func serve(addr, basePath string) error {
claudeConfigDir := cfg.ClaudeConfigDir
repoDir, _ := os.Getwd()
+
// Shared per-task agent MCP token registry: runners mint tokens; the API
// server mounts /mcp and resolves them.
agentRegistry := executor.NewRegistry()
- runners := map[string]executor.Runner{
- // ContainerRunner: binaries are resolved via PATH inside the container image,
- // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside).
- "claude": &executor.ContainerRunner{
+ runners := map[string]executor.Runner{}
+
+ // ContainerRunner: binaries are resolved via PATH inside the container image,
+ // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside).
+ if cfg.Runners.ClaudeEnabled() {
+ runners["claude"] = &executor.ContainerRunner{
Image: cfg.ClaudeImage,
Logger: logger,
LogDir: cfg.LogDir,
@@ -103,8 +106,10 @@ func serve(addr, basePath string) error {
CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"),
Store: store,
Registry: agentRegistry,
- },
- "gemini": &executor.ContainerRunner{
+ }
+ }
+ if cfg.Runners.GeminiEnabled() {
+ runners["gemini"] = &executor.ContainerRunner{
Image: cfg.GeminiImage,
Logger: logger,
LogDir: cfg.LogDir,
@@ -115,8 +120,10 @@ func serve(addr, basePath string) error {
CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"),
Store: store,
Registry: agentRegistry,
- },
- "container": &executor.ContainerRunner{
+ }
+ }
+ if cfg.Runners.ContainerEnabled() {
+ runners["container"] = &executor.ContainerRunner{
Image: "claudomator-agent:latest",
Logger: logger,
LogDir: cfg.LogDir,
@@ -127,11 +134,11 @@ func serve(addr, basePath string) error {
CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"),
Store: store,
Registry: agentRegistry,
- },
+ }
}
localClient := buildLocalLLMClient(cfg.LocalModel, logger)
- if localClient != nil {
+ if localClient != nil && cfg.Runners.LocalEnabled() {
runners["local"] = &executor.LocalRunner{
Client: localClient,
Logger: logger,
@@ -141,7 +148,6 @@ func serve(addr, basePath string) error {
logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model)
}
-
pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger)
pool.Classifier = &executor.Classifier{
LLM: localClient,
@@ -152,7 +158,7 @@ func serve(addr, basePath string) error {
}
// Budget accountant: gate paid providers against rolling per-provider caps.
- // With no limits configured this is created but allows everything.
+ // With no limits configured this is a no-op.
var accountant *budget.Accountant
if len(cfg.Budget.Provider5hUSD) > 0 {
window := budget.DefaultWindow
@@ -268,4 +274,3 @@ func serve(addr, basePath string) error {
}
return nil
}
-
diff --git a/internal/config/config.go b/internal/config/config.go
index 6f6b958..80777ff 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -45,6 +45,24 @@ func (m LocalModel) UseForElaborate() bool {
return *m.PreferForElaborate
}
+// RunnersConfig controls which agent runners are registered. Each field is a
+// *bool so that nil (absent from TOML) means "enabled by default", while an
+// explicit false disables the runner without affecting others.
+//
+// The local runner also requires LocalModel.Endpoint to be set; setting
+// Local = true without an endpoint has no effect.
+type RunnersConfig struct {
+ Claude *bool `toml:"claude"`
+ Gemini *bool `toml:"gemini"`
+ Local *bool `toml:"local"`
+ Container *bool `toml:"container"`
+}
+
+func (r RunnersConfig) ClaudeEnabled() bool { return r.Claude == nil || *r.Claude }
+func (r RunnersConfig) GeminiEnabled() bool { return r.Gemini == nil || *r.Gemini }
+func (r RunnersConfig) LocalEnabled() bool { return r.Local == nil || *r.Local }
+func (r RunnersConfig) ContainerEnabled() bool { return r.Container == nil || *r.Container }
+
type Config struct {
DataDir string `toml:"data_dir"`
DBPath string `toml:"-"`
@@ -69,20 +87,16 @@ type Config struct {
VAPIDEmail string `toml:"vapid_email"`
ClaudeConfigDir string `toml:"claude_config_dir"`
LocalModel LocalModel `toml:"local_model"`
+ Runners RunnersConfig `toml:"runners"`
Budget BudgetConfig `toml:"budget"`
// ExternalBindAllowed must be explicitly true to bind a non-loopback address.
- // Default external access should go through a reverse proxy (Tailscale /
- // Cloudflare Access / Caddy); binding the server directly is a footgun.
ExternalBindAllowed bool `toml:"external_bind_allowed"`
}
// BudgetConfig configures rolling per-provider spend caps. With no providers
-// set, budget gating is disabled (nothing is blocked) — there is intentionally
-// no default cap; the user must opt in by configuring limits.
+// set, budget gating is disabled.
type BudgetConfig struct {
- // Window is the rolling lookback duration (e.g. "5h"); defaults to 5h.
- Window string `toml:"window"`
- // Provider5hUSD maps a provider (claude/gemini) to its USD cap within Window.
+ Window string `toml:"window"`
Provider5hUSD map[string]float64 `toml:"provider_5h_usd"`
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index e4f1a5d..0d47ca0 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -54,6 +54,88 @@ func TestLoadFile_MissingFile_ReturnsError(t *testing.T) {
}
}
+func TestRunnersConfig_DefaultsAllEnabled(t *testing.T) {
+ r := RunnersConfig{}
+ if !r.ClaudeEnabled() {
+ t.Error("claude should be enabled by default (nil)")
+ }
+ if !r.GeminiEnabled() {
+ t.Error("gemini should be enabled by default (nil)")
+ }
+ if !r.LocalEnabled() {
+ t.Error("local should be enabled by default (nil)")
+ }
+ if !r.ContainerEnabled() {
+ t.Error("container should be enabled by default (nil)")
+ }
+}
+
+func TestRunnersConfig_ExplicitFalseDisables(t *testing.T) {
+ f := false
+ r := RunnersConfig{Claude: &f, Gemini: &f, Local: &f, Container: &f}
+ if r.ClaudeEnabled() {
+ t.Error("explicit false should disable claude")
+ }
+ if r.GeminiEnabled() {
+ t.Error("explicit false should disable gemini")
+ }
+ if r.LocalEnabled() {
+ t.Error("explicit false should disable local")
+ }
+ if r.ContainerEnabled() {
+ t.Error("explicit false should disable container")
+ }
+}
+
+func TestRunnersConfig_ExplicitTrueEnables(t *testing.T) {
+ tr := true
+ r := RunnersConfig{Claude: &tr, Gemini: &tr, Local: &tr, Container: &tr}
+ if !r.ClaudeEnabled() || !r.GeminiEnabled() || !r.LocalEnabled() || !r.ContainerEnabled() {
+ t.Error("explicit true should keep all runners enabled")
+ }
+}
+
+func TestRunnersConfig_MixedToggles(t *testing.T) {
+ f, tr := false, true
+ r := RunnersConfig{Claude: &tr, Gemini: &f, Local: &tr}
+ if !r.ClaudeEnabled() {
+ t.Error("claude should be enabled")
+ }
+ if r.GeminiEnabled() {
+ t.Error("gemini should be disabled")
+ }
+ if !r.LocalEnabled() {
+ t.Error("local should be enabled")
+ }
+ if !r.ContainerEnabled() {
+ t.Error("container should be enabled (nil = default)")
+ }
+}
+
+func TestRunnersConfig_TOMLRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ toml := "[runners]\ngemini = false\n"
+ if err := os.WriteFile(path, []byte(toml), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOME", dir)
+
+ cfg, err := LoadFile(path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.Runners.GeminiEnabled() {
+ t.Error("gemini should be disabled after TOML load")
+ }
+ if !cfg.Runners.ClaudeEnabled() {
+ t.Error("claude should remain enabled (not in TOML)")
+ }
+ if !cfg.Runners.LocalEnabled() {
+ t.Error("local should remain enabled (not in TOML)")
+ }
+}
+
func TestLocalModel_UseForElaborate_EmptyEndpoint(t *testing.T) {
m := LocalModel{}
if m.UseForElaborate() {
diff --git a/internal/executor/container.go b/internal/executor/container.go
index 3afce70..39f737a 100644
--- a/internal/executor/container.go
+++ b/internal/executor/container.go
@@ -155,6 +155,14 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec
}
}
}
+ // For non-story tasks (e.g. CI webhook tasks), also resolve local path from the task's
+ // project field. This lets the runner clone from the local mirror instead of an HTTPS
+ // remote that may require credentials not available on the host.
+ if storyLocalPath == "" && t.Project != "" && r.Store != nil {
+ if proj, err := r.Store.GetProject(t.Project); err == nil && proj != nil && proj.LocalPath != "" {
+ storyLocalPath = proj.LocalPath
+ }
+ }
// Fall back to task-level BranchName (e.g. set explicitly by executor or tests).
if storyBranch == "" {
storyBranch = t.BranchName
@@ -175,13 +183,15 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec
if err := os.Remove(workspace); err != nil {
return fmt.Errorf("removing workspace before clone: %w", err)
}
- r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace)
- var cloneArgs []string
+ // Prefer the local path as the clone source to avoid HTTPS auth requirements.
+ // For story tasks the storyLocalPath is also available as a speed reference,
+ // but we clone from the local path directly since it is already authoritative.
+ cloneSrc := repoURL
if storyLocalPath != "" {
- cloneArgs = []string{"clone", "--reference", storyLocalPath, repoURL, workspace}
- } else {
- cloneArgs = []string{"clone", repoURL, workspace}
+ cloneSrc = storyLocalPath
}
+ r.Logger.Info("cloning repository", "src", cloneSrc, "workspace", workspace)
+ cloneArgs := []string{"clone", cloneSrc, workspace}
if out, err := r.command(ctx, "git", cloneArgs...).CombinedOutput(); err != nil {
return fmt.Errorf("git clone failed: %w\n%s", err, string(out))
}
diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go
index 26e67bc..dec666e 100644
--- a/internal/executor/container_test.go
+++ b/internal/executor/container_test.go
@@ -666,6 +666,73 @@ func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) {
}
}
+// TestContainerRunner_ClonesFromLocalPathForCITask verifies that when a task has
+// an HTTPS repository_url but the project has a known local path, the runner
+// clones from the local path to avoid HTTPS auth failures.
+func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) {
+ dir := t.TempDir()
+ localRepo := filepath.Join(dir, "local-repo.git")
+ if out, err := exec.Command("git", "init", "--bare", localRepo).CombinedOutput(); err != nil {
+ t.Fatalf("git init bare: %v\n%s", err, out)
+ }
+
+ store := testStore(t)
+ proj := &task.Project{
+ ID: "nav",
+ Name: "nav",
+ RemoteURL: localRepo,
+ LocalPath: localRepo,
+ }
+ if err := store.CreateProject(proj); err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+
+ var cloneSrc string
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ runner := &ContainerRunner{
+ Logger: logger,
+ Image: "busybox",
+ Store: store,
+ Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
+ if name == "git" && len(arg) > 0 && arg[0] == "clone" {
+ // Record what source was cloned from.
+ for i, a := range arg {
+ if a != "clone" && a != "--reference" && i > 0 && arg[i-1] != "--reference" {
+ // last non-dest arg before workspace is the source
+ }
+ _ = a
+ }
+ // source is second-to-last arg (before workspace dir)
+ cloneSrc = arg[len(arg)-2]
+ dir := arg[len(arg)-1]
+ os.MkdirAll(dir, 0755)
+ return exec.Command("true")
+ }
+ if name == "docker" {
+ return exec.Command("sh", "-c", "exit 1")
+ }
+ return exec.Command("true")
+ },
+ }
+
+ tk := &task.Task{
+ ID: "ci-task-1",
+ RepositoryURL: "https://github.com/thepeterstone/nav.git",
+ Project: "nav",
+ Agent: task.AgentConfig{Type: "claude"},
+ }
+ e := &storage.Execution{ID: "exec-ci-1", TaskID: "ci-task-1"}
+ runner.Run(context.Background(), tk, e, noopChannel{})
+ os.RemoveAll(e.SandboxDir)
+
+ if cloneSrc == "https://github.com/thepeterstone/nav.git" {
+ t.Error("runner cloned from GitHub HTTPS URL; expected local path to avoid auth failure")
+ }
+ if cloneSrc != localRepo {
+ t.Errorf("expected clone source %q, got %q", localRepo, cloneSrc)
+ }
+}
+
func TestEnsureStoryBranch_CreatesMissingBranch(t *testing.T) {
// Set up a bare repo and a local clone to test branch creation.
dir := t.TempDir()
@@ -744,3 +811,13 @@ func TestEnsureStoryBranch_IdempotentIfExists(t *testing.T) {
t.Fatalf("ensureStoryBranch on existing branch: %v", err)
}
}
+
+// noopChannel satisfies AgentChannel with no-op implementations for tests.
+type noopChannel struct{}
+
+func (noopChannel) AskUser(_ context.Context, _ string) (string, error) { return "", nil }
+func (noopChannel) ReportSummary(_ context.Context, _ string) error { return nil }
+func (noopChannel) SpawnSubtask(_ context.Context, _ SubtaskSpec) (string, error) {
+ return "", nil
+}
+func (noopChannel) RecordProgress(_ context.Context, _ string) error { return nil }
diff --git a/internal/task/validator.go b/internal/task/validator.go
index 43e482e..003fab9 100644
--- a/internal/task/validator.go
+++ b/internal/task/validator.go
@@ -29,9 +29,6 @@ func Validate(t *Task) error {
if t.Name == "" {
ve.Add("name is required")
}
- if t.RepositoryURL == "" {
- ve.Add("repository_url is required")
- }
if t.Agent.Instructions == "" {
ve.Add("agent.instructions is required")
}