summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-16 05:08:00 +0000
committerClaudomator Agent <agent@claudomator>2026-03-16 05:08:00 +0000
commitc2aa026f6ce1c9e216b99d74f294fc133d5fcddd (patch)
tree2fdbc85367b92e5ba0d5cd9b789f4f4cf34aad61 /internal
parentd75a231d8865d9b14fbe3d608c9aa1bffb7ed386 (diff)
feat: add GitHub webhook endpoint for automatic CI failure task creation
Adds POST /api/webhooks/github that receives check_run and workflow_run events and creates a Claudomator task to investigate and fix the failure. - Config: new webhook_secret and [[projects]] fields in config.toml - HMAC-SHA256 validation when webhook_secret is configured - Ignores non-failure events (success, skipped, etc.) with 204 - Matches repo name to configured project dirs (case-insensitive) - Falls back to single project when no name match found - 11 new tests covering all acceptance criteria Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/api/server.go10
-rw-r--r--internal/api/webhook.go200
-rw-r--r--internal/api/webhook_test.go313
-rw-r--r--internal/cli/serve.go1
-rw-r--r--internal/config/config.go28
5 files changed, 542 insertions, 10 deletions
diff --git a/internal/api/server.go b/internal/api/server.go
index 7988c4c..8a20349 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -10,6 +10,7 @@ import (
"strings"
"time"
+ "github.com/thepeterstone/claudomator/internal/config"
"github.com/thepeterstone/claudomator/internal/executor"
"github.com/thepeterstone/claudomator/internal/notify"
"github.com/thepeterstone/claudomator/internal/storage"
@@ -47,6 +48,8 @@ type Server struct {
notifier notify.Notifier
apiToken string // if non-empty, required for WebSocket (and REST) connections
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
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -59,6 +62,12 @@ func (s *Server) SetNotifier(n notify.Notifier) {
s.notifier = n
}
+// SetGitHubWebhookConfig configures the GitHub webhook secret and project list.
+func (s *Server) SetGitHubWebhookConfig(secret string, projects []config.Project) {
+ s.webhookSecret = secret
+ s.projects = projects
+}
+
// SetWorkspaceRoot configures the root directory used by handleListWorkspaces.
func (s *Server) SetWorkspaceRoot(path string) {
s.workspaceRoot = path
@@ -118,6 +127,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/workspaces", s.handleListWorkspaces)
s.mux.HandleFunc("GET /api/tasks/{id}/deployment-status", s.handleGetDeploymentStatus)
s.mux.HandleFunc("GET /api/health", s.handleHealth)
+ s.mux.HandleFunc("POST /api/webhooks/github", s.handleGitHubWebhook)
s.mux.Handle("GET /", http.FileServerFS(webui.Files))
}
diff --git a/internal/api/webhook.go b/internal/api/webhook.go
new file mode 100644
index 0000000..8bf1676
--- /dev/null
+++ b/internal/api/webhook.go
@@ -0,0 +1,200 @@
+package api
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/config"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// checkRunPayload is the GitHub check_run webhook payload.
+type checkRunPayload struct {
+ Action string `json:"action"`
+ CheckRun struct {
+ Name string `json:"name"`
+ Conclusion string `json:"conclusion"`
+ HTMLURL string `json:"html_url"`
+ HeadSHA string `json:"head_sha"`
+ CheckSuite struct {
+ HeadBranch string `json:"head_branch"`
+ } `json:"check_suite"`
+ } `json:"check_run"`
+ Repository struct {
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ } `json:"repository"`
+}
+
+// workflowRunPayload is the GitHub workflow_run webhook payload.
+type workflowRunPayload struct {
+ Action string `json:"action"`
+ WorkflowRun struct {
+ Name string `json:"name"`
+ Conclusion string `json:"conclusion"`
+ HTMLURL string `json:"html_url"`
+ HeadSHA string `json:"head_sha"`
+ HeadBranch string `json:"head_branch"`
+ } `json:"workflow_run"`
+ Repository struct {
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ } `json:"repository"`
+}
+
+// validateGitHubSignature checks the HMAC-SHA256 signature from GitHub.
+func validateGitHubSignature(sig string, body []byte, secret string) bool {
+ if !strings.HasPrefix(sig, "sha256=") {
+ return false
+ }
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write(body)
+ expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
+ return hmac.Equal([]byte(sig), []byte(expected))
+}
+
+// matchProject finds the best matching project for a repository name.
+// Returns the single configured project as a fallback if there's only one.
+func matchProject(projects []config.Project, repoName string) *config.Project {
+ repoLower := strings.ToLower(repoName)
+ for i := range projects {
+ p := &projects[i]
+ nameLower := strings.ToLower(p.Name)
+ dirBase := strings.ToLower(filepath.Base(p.Dir))
+ if strings.Contains(nameLower, repoLower) || strings.Contains(dirBase, repoLower) {
+ return p
+ }
+ }
+ if len(projects) == 1 {
+ return &projects[0]
+ }
+ return nil
+}
+
+func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "failed to read body", http.StatusBadRequest)
+ return
+ }
+
+ // Validate HMAC signature if a secret is configured.
+ if s.webhookSecret != "" {
+ sig := r.Header.Get("X-Hub-Signature-256")
+ if !validateGitHubSignature(sig, body, s.webhookSecret) {
+ http.Error(w, "invalid signature", http.StatusUnauthorized)
+ return
+ }
+ }
+
+ eventType := r.Header.Get("X-GitHub-Event")
+ switch eventType {
+ case "check_run":
+ s.handleCheckRunEvent(w, body)
+ case "workflow_run":
+ s.handleWorkflowRunEvent(w, body)
+ default:
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func (s *Server) handleCheckRunEvent(w http.ResponseWriter, body []byte) {
+ var p checkRunPayload
+ if err := json.Unmarshal(body, &p); err != nil {
+ http.Error(w, "invalid JSON", http.StatusBadRequest)
+ return
+ }
+ if p.Action != "completed" || p.CheckRun.Conclusion != "failure" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ s.createCIFailureTask(w,
+ p.Repository.Name,
+ p.Repository.FullName,
+ p.CheckRun.CheckSuite.HeadBranch,
+ p.CheckRun.HeadSHA,
+ p.CheckRun.Name,
+ p.CheckRun.HTMLURL,
+ )
+}
+
+func (s *Server) handleWorkflowRunEvent(w http.ResponseWriter, body []byte) {
+ var p workflowRunPayload
+ if err := json.Unmarshal(body, &p); err != nil {
+ http.Error(w, "invalid JSON", http.StatusBadRequest)
+ return
+ }
+ if p.Action != "completed" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if p.WorkflowRun.Conclusion != "failure" && p.WorkflowRun.Conclusion != "timed_out" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ s.createCIFailureTask(w,
+ p.Repository.Name,
+ p.Repository.FullName,
+ p.WorkflowRun.HeadBranch,
+ p.WorkflowRun.HeadSHA,
+ p.WorkflowRun.Name,
+ p.WorkflowRun.HTMLURL,
+ )
+}
+
+func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, branch, sha, checkName, htmlURL string) {
+ project := matchProject(s.projects, repoName)
+
+ instructions := fmt.Sprintf(
+ "A CI failure has been detected and requires investigation.\n\n"+
+ "Repository: %s\n"+
+ "Branch: %s\n"+
+ "Commit SHA: %s\n"+
+ "Check/Workflow: %s\n"+
+ "Run URL: %s\n\n"+
+ "Please investigate the failure by:\n"+
+ "1. Reviewing recent commits on the branch\n"+
+ "2. Checking the CI logs at the URL above\n"+
+ "3. Identifying the root cause of the failure\n"+
+ "4. Fixing the root cause and ensuring the build passes",
+ fullName, branch, sha, checkName, htmlURL,
+ )
+
+ now := time.Now().UTC()
+ t := &task.Task{
+ ID: uuid.New().String(),
+ Name: fmt.Sprintf("Fix CI failure: %s on %s", checkName, branch),
+ Agent: task.AgentConfig{
+ Type: "claude",
+ Instructions: instructions,
+ MaxBudgetUSD: 3.0,
+ AllowedTools: []string{"Read", "Edit", "Bash", "Glob", "Grep"},
+ },
+ Priority: task.PriorityNormal,
+ Tags: []string{"ci", "auto"},
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "exponential"},
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if project != nil {
+ t.Agent.ProjectDir = project.Dir
+ }
+
+ if err := s.store.CreateTask(t); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"task_id": t.ID})
+}
diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go
new file mode 100644
index 0000000..8b0599a
--- /dev/null
+++ b/internal/api/webhook_test.go
@@ -0,0 +1,313 @@
+package api
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/config"
+)
+
+// signBody computes the HMAC-SHA256 signature for a webhook payload.
+func signBody(t *testing.T, secret, body string) string {
+ t.Helper()
+ mac := hmac.New(sha256.New, []byte(secret))
+ mac.Write([]byte(body))
+ return "sha256=" + hex.EncodeToString(mac.Sum(nil))
+}
+
+func webhookPost(t *testing.T, srv *Server, eventType, payload, sig string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewBufferString(payload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-GitHub-Event", eventType)
+ if sig != "" {
+ req.Header.Set("X-Hub-Signature-256", sig)
+ }
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ return w
+}
+
+const checkRunFailurePayload = `{
+ "action": "completed",
+ "check_run": {
+ "name": "CI Build",
+ "conclusion": "failure",
+ "html_url": "https://github.com/owner/myrepo/runs/123",
+ "head_sha": "abc123def",
+ "check_suite": {
+ "head_branch": "main"
+ }
+ },
+ "repository": {
+ "name": "myrepo",
+ "full_name": "owner/myrepo"
+ }
+}`
+
+const checkRunSuccessPayload = `{
+ "action": "completed",
+ "check_run": {
+ "name": "CI Build",
+ "conclusion": "success",
+ "html_url": "https://github.com/owner/myrepo/runs/123",
+ "head_sha": "abc123def",
+ "check_suite": {
+ "head_branch": "main"
+ }
+ },
+ "repository": {
+ "name": "myrepo",
+ "full_name": "owner/myrepo"
+ }
+}`
+
+const workflowRunFailurePayload = `{
+ "action": "completed",
+ "workflow_run": {
+ "name": "CI Pipeline",
+ "conclusion": "failure",
+ "html_url": "https://github.com/owner/myrepo/actions/runs/789",
+ "head_sha": "def456abc",
+ "head_branch": "feature-branch"
+ },
+ "repository": {
+ "name": "myrepo",
+ "full_name": "owner/myrepo"
+ }
+}`
+
+const workflowRunTimedOutPayload = `{
+ "action": "completed",
+ "workflow_run": {
+ "name": "CI Pipeline",
+ "conclusion": "timed_out",
+ "html_url": "https://github.com/owner/myrepo/actions/runs/789",
+ "head_sha": "def456abc",
+ "head_branch": "main"
+ },
+ "repository": {
+ "name": "myrepo",
+ "full_name": "owner/myrepo"
+ }
+}`
+
+func TestGitHubWebhook_CheckRunFailure_CreatesTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ taskID, ok := resp["task_id"]
+ if !ok || taskID == "" {
+ t.Fatal("expected task_id in response")
+ }
+ tk, err := store.GetTask(taskID)
+ if err != nil {
+ t.Fatalf("task not found in store: %v", err)
+ }
+ if !strings.Contains(tk.Name, "CI Build") {
+ t.Errorf("task name %q does not contain check name", tk.Name)
+ }
+ if !strings.Contains(tk.Name, "main") {
+ t.Errorf("task name %q does not contain branch", tk.Name)
+ }
+ if tk.Agent.ProjectDir != "/workspace/myrepo" {
+ t.Errorf("task project dir = %q, want /workspace/myrepo", tk.Agent.ProjectDir)
+ }
+ if !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") {
+ t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags)
+ }
+}
+
+func TestGitHubWebhook_WorkflowRunFailure_CreatesTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "workflow_run", workflowRunFailurePayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ taskID, ok := resp["task_id"]
+ if !ok || taskID == "" {
+ t.Fatal("expected task_id in response")
+ }
+ tk, err := store.GetTask(taskID)
+ if err != nil {
+ t.Fatalf("task not found in store: %v", err)
+ }
+ if !strings.Contains(tk.Name, "CI Pipeline") {
+ t.Errorf("task name %q does not contain workflow name", tk.Name)
+ }
+ if !strings.Contains(tk.Name, "feature-branch") {
+ t.Errorf("task name %q does not contain branch", tk.Name)
+ }
+}
+
+func TestGitHubWebhook_WorkflowRunTimedOut_CreatesTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "workflow_run", workflowRunTimedOutPayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ taskID := resp["task_id"]
+ if taskID == "" {
+ t.Fatal("expected task_id in response")
+ }
+ if _, err := store.GetTask(taskID); err != nil {
+ t.Fatalf("task not found in store: %v", err)
+ }
+}
+
+func TestGitHubWebhook_CheckRunSuccess_Returns204(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunSuccessPayload, "")
+
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGitHubWebhook_InvalidHMAC_Returns401(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.webhookSecret = "correct-secret"
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "sha256=badhash")
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("want 401, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGitHubWebhook_ValidHMAC_AcceptsRequest(t *testing.T) {
+ srv, store := testServer(t)
+ srv.webhookSecret = "my-secret"
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ sig := signBody(t, "my-secret", checkRunFailurePayload)
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, sig)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ if _, err := store.GetTask(resp["task_id"]); err != nil {
+ t.Fatal("task not found after valid HMAC request")
+ }
+}
+
+func TestGitHubWebhook_NoSecretConfigured_SkipsHMACCheck(t *testing.T) {
+ srv, store := testServer(t)
+ // No webhook secret configured — even a missing signature should pass.
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ if _, err := store.GetTask(resp["task_id"]); err != nil {
+ t.Fatal("task not found")
+ }
+}
+
+func TestGitHubWebhook_UnknownEvent_Returns204(t *testing.T) {
+ srv, _ := testServer(t)
+
+ w := webhookPost(t, srv, "push", `{"ref":"refs/heads/main"}`, "")
+
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGitHubWebhook_MissingEvent_Returns204(t *testing.T) {
+ srv, _ := testServer(t)
+
+ req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewBufferString("{}"))
+ req.Header.Set("Content-Type", "application/json")
+ // No X-GitHub-Event header
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) {
+ srv, store := testServer(t)
+ // Project name doesn't match repo name, but there's only one project → fall back.
+ srv.projects = []config.Project{{Name: "different-name", Dir: "/workspace/someapp"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ tk, err := store.GetTask(resp["task_id"])
+ if err != nil {
+ t.Fatalf("task not found: %v", err)
+ }
+ if tk.Agent.ProjectDir != "/workspace/someapp" {
+ t.Errorf("expected fallback to /workspace/someapp, got %q", tk.Agent.ProjectDir)
+ }
+}
+
+func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *testing.T) {
+ srv, store := testServer(t)
+ // No projects configured — task should still be created, just no project dir set.
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ tk, err := store.GetTask(resp["task_id"])
+ if err != nil {
+ t.Fatalf("task not found: %v", err)
+ }
+ if tk.Agent.ProjectDir != "" {
+ t.Errorf("expected empty project dir, got %q", tk.Agent.ProjectDir)
+ }
+}
+
+// contains checks if a string slice contains a value.
+func contains(slice []string, val string) bool {
+ for _, s := range slice {
+ if s == val {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index f22ea95..94f0c5d 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -87,6 +87,7 @@ func serve(addr string) error {
if cfg.WorkspaceRoot != "" {
srv.SetWorkspaceRoot(cfg.WorkspaceRoot)
}
+ srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects)
// Register scripts.
wd, _ := os.Getwd()
diff --git a/internal/config/config.go b/internal/config/config.go
index 8c5aebf..ce3b53f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -9,17 +9,25 @@ import (
"github.com/BurntSushi/toml"
)
+// Project represents a named workspace project used for webhook routing.
+type Project struct {
+ Name string `toml:"name"`
+ Dir string `toml:"dir"`
+}
+
type Config struct {
- DataDir string `toml:"data_dir"`
- DBPath string `toml:"-"`
- LogDir string `toml:"-"`
- ClaudeBinaryPath string `toml:"claude_binary_path"`
- GeminiBinaryPath string `toml:"gemini_binary_path"`
- MaxConcurrent int `toml:"max_concurrent"`
- DefaultTimeout string `toml:"default_timeout"`
- ServerAddr string `toml:"server_addr"`
- WebhookURL string `toml:"webhook_url"`
- WorkspaceRoot string `toml:"workspace_root"`
+ DataDir string `toml:"data_dir"`
+ DBPath string `toml:"-"`
+ LogDir string `toml:"-"`
+ ClaudeBinaryPath string `toml:"claude_binary_path"`
+ GeminiBinaryPath string `toml:"gemini_binary_path"`
+ MaxConcurrent int `toml:"max_concurrent"`
+ DefaultTimeout string `toml:"default_timeout"`
+ ServerAddr string `toml:"server_addr"`
+ WebhookURL string `toml:"webhook_url"`
+ WorkspaceRoot string `toml:"workspace_root"`
+ WebhookSecret string `toml:"webhook_secret"`
+ Projects []Project `toml:"projects"`
}
func Default() (*Config, error) {