From e766b4c3ef13181ec6adf90ec4abe9db0129fab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 04:57:37 +0000 Subject: feat(api): chatbot-facing MCP server for task orchestration (Phase 3) Adds a chatbot-facing MCP server mounted at /chatbot/mcp, guarded by the shared API bearer token (new config api_token, wired through SetAPIToken). Tools: submit_task, list_tasks, get_task, get_events, answer_question, accept_task, reject_task, cancel_task. submit_task creates and immediately queues a task, resolving the repository URL from a named project when not given explicitly. To keep the REST API and the MCP surface from drifting, the accept/reject/ cancel/answer operations are extracted into a shared service layer (taskops.go) with typed errors mapped to REST status codes; the existing HTTP handlers now delegate to it. The endpoint is only served when a token is configured and presented as a bearer. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/config/config.go | 1 + 1 file changed, 1 insertion(+) (limited to 'internal/config') diff --git a/internal/config/config.go b/internal/config/config.go index 25187cf..94f3ec7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,7 @@ type Config struct { WebhookURL string `toml:"webhook_url"` WorkspaceRoot string `toml:"workspace_root"` WebhookSecret string `toml:"webhook_secret"` + APIToken string `toml:"api_token"` // shared bearer for web UI + chatbot MCP; empty disables both auth and the chatbot MCP endpoint Projects []Project `toml:"projects"` VAPIDPublicKey string `toml:"vapid_public_key"` VAPIDPrivateKey string `toml:"vapid_private_key"` -- cgit v1.2.3 From 39f74dbbc7adca0e2058112408bb99694deefcaf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:27:36 +0000 Subject: feat(budget,storage,config): per-provider spend accounting substrate (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the budget accountant foundation: - storage: a per-execution `agent` column (additive migration, populated by the dispatcher) and SpendByProviderSince(since), summing cost_usd per provider in a window. Accurate attribution survives a task running on different providers across retries. - internal/budget: Accountant over a SpendSource + Limits, exposing Headroom (remaining/fraction per provider), Allow (would estCost breach the cap), and All (one query, sorted). Unconfigured/local providers are unlimited. - config: a [budget] section (window + provider_5h_usd map). No default cap — gating is opt-in by configuring limits. Fully unit-tested; dispatcher gating and the API/UI surface follow. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/budget/budget.go | 122 +++++++++++++++++++++++++++++++++++++++++ internal/budget/budget_test.go | 112 +++++++++++++++++++++++++++++++++++++ internal/config/config.go | 11 ++++ internal/executor/executor.go | 1 + internal/storage/db.go | 50 +++++++++++++---- internal/storage/db_test.go | 38 +++++++++++++ 6 files changed, 324 insertions(+), 10 deletions(-) create mode 100644 internal/budget/budget.go create mode 100644 internal/budget/budget_test.go (limited to 'internal/config') diff --git a/internal/budget/budget.go b/internal/budget/budget.go new file mode 100644 index 0000000..60ce9d2 --- /dev/null +++ b/internal/budget/budget.go @@ -0,0 +1,122 @@ +// Package budget tracks agent spend against rolling per-provider windows so the +// dispatcher can refuse (or reroute) paid work that would breach a cap. Local +// runners are free and simply carry no configured limit. +package budget + +import ( + "sort" + "time" +) + +// DefaultWindow is the rolling spend window when none is configured. +const DefaultWindow = 5 * time.Hour + +// Limits configures per-provider spend caps within a rolling window. +type Limits struct { + // Window is the rolling lookback; DefaultWindow when zero. + Window time.Duration + // PerProvider maps a provider (claude/gemini/…) to its USD cap within the + // window. A missing or non-positive entry means "unlimited" (e.g. local). + PerProvider map[string]float64 +} + +// SpendSource supplies total spend per provider since a given time. +type SpendSource interface { + SpendByProviderSince(since time.Time) (map[string]float64, error) +} + +// Headroom describes remaining budget for one provider in the current window. +type Headroom struct { + Provider string `json:"provider"` + Limited bool `json:"limited"` + Limit float64 `json:"limit_usd"` + Spent float64 `json:"spent_usd"` + Remaining float64 `json:"remaining_usd"` + // Fraction is the share of the cap still available, 0..1 (0 when unlimited). + Fraction float64 `json:"fraction_remaining"` +} + +// Accountant answers spend questions against a SpendSource and Limits. +type Accountant struct { + src SpendSource + lim Limits + now func() time.Time +} + +// New builds an Accountant. A zero Window defaults to DefaultWindow. +func New(src SpendSource, lim Limits) *Accountant { + if lim.Window <= 0 { + lim.Window = DefaultWindow + } + return &Accountant{src: src, lim: lim, now: time.Now} +} + +func (a *Accountant) since() time.Time { return a.now().Add(-a.lim.Window) } + +func (a *Accountant) headroomFrom(provider string, spends map[string]float64) Headroom { + limit := a.lim.PerProvider[provider] + if limit <= 0 { + return Headroom{Provider: provider, Limited: false} + } + spent := spends[provider] + remaining := limit - spent + if remaining < 0 { + remaining = 0 + } + return Headroom{ + Provider: provider, + Limited: true, + Limit: limit, + Spent: spent, + Remaining: remaining, + Fraction: remaining / limit, + } +} + +// Headroom returns the remaining budget for one provider in the current window. +func (a *Accountant) Headroom(provider string) (Headroom, error) { + if a.lim.PerProvider[provider] <= 0 { + return Headroom{Provider: provider, Limited: false}, nil + } + spends, err := a.src.SpendByProviderSince(a.since()) + if err != nil { + return Headroom{}, err + } + return a.headroomFrom(provider, spends), nil +} + +// Allow reports whether starting a task on provider that may cost up to estCost +// is permitted without breaching the window cap. Unlimited providers always +// pass. estCost is typically the task's max_budget_usd (0 = unknown). +func (a *Accountant) Allow(provider string, estCost float64) (bool, error) { + h, err := a.Headroom(provider) + if err != nil { + return false, err + } + if !h.Limited { + return true, nil + } + return h.Spent+estCost <= h.Limit, nil +} + +// All returns headroom for every configured provider, sorted by name, in a +// single spend query. +func (a *Accountant) All() ([]Headroom, error) { + if len(a.lim.PerProvider) == 0 { + return []Headroom{}, nil + } + spends, err := a.src.SpendByProviderSince(a.since()) + if err != nil { + return nil, err + } + providers := make([]string, 0, len(a.lim.PerProvider)) + for p := range a.lim.PerProvider { + providers = append(providers, p) + } + sort.Strings(providers) + out := make([]Headroom, 0, len(providers)) + for _, p := range providers { + out = append(out, a.headroomFrom(p, spends)) + } + return out, nil +} diff --git a/internal/budget/budget_test.go b/internal/budget/budget_test.go new file mode 100644 index 0000000..c257c91 --- /dev/null +++ b/internal/budget/budget_test.go @@ -0,0 +1,112 @@ +package budget + +import ( + "testing" + "time" +) + +type fakeSpend struct { + spends map[string]float64 + since time.Time +} + +func (f *fakeSpend) SpendByProviderSince(since time.Time) (map[string]float64, error) { + f.since = since + return f.spends, nil +} + +func newAcct(spends map[string]float64, lim Limits) (*Accountant, *fakeSpend) { + src := &fakeSpend{spends: spends} + a := New(src, lim) + a.now = func() time.Time { return time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) } + return a, src +} + +func TestNew_DefaultsWindow(t *testing.T) { + a, _ := newAcct(nil, Limits{}) + if a.lim.Window != DefaultWindow { + t.Errorf("want default window %v, got %v", DefaultWindow, a.lim.Window) + } +} + +func TestHeadroom_UnlimitedWhenNoLimit(t *testing.T) { + a, _ := newAcct(map[string]float64{"local": 999}, Limits{PerProvider: map[string]float64{"claude": 10}}) + h, err := a.Headroom("local") + if err != nil { + t.Fatal(err) + } + if h.Limited { + t.Errorf("local should be unlimited, got %+v", h) + } +} + +func TestHeadroom_ComputesRemainingAndFraction(t *testing.T) { + a, src := newAcct(map[string]float64{"claude": 4}, Limits{Window: 5 * time.Hour, PerProvider: map[string]float64{"claude": 10}}) + h, err := a.Headroom("claude") + if err != nil { + t.Fatal(err) + } + if !h.Limited || h.Limit != 10 || h.Spent != 4 || h.Remaining != 6 { + t.Errorf("unexpected headroom: %+v", h) + } + if h.Fraction != 0.6 { + t.Errorf("fraction: want 0.6, got %v", h.Fraction) + } + // Window lookback is now-5h. + want := time.Date(2026, 5, 26, 7, 0, 0, 0, time.UTC) + if !src.since.Equal(want) { + t.Errorf("since: want %v, got %v", want, src.since) + } +} + +func TestHeadroom_ClampsNegativeRemaining(t *testing.T) { + a, _ := newAcct(map[string]float64{"claude": 15}, Limits{PerProvider: map[string]float64{"claude": 10}}) + h, _ := a.Headroom("claude") + if h.Remaining != 0 { + t.Errorf("remaining should clamp to 0, got %v", h.Remaining) + } +} + +func TestAllow(t *testing.T) { + a, _ := newAcct(map[string]float64{"claude": 8}, Limits{PerProvider: map[string]float64{"claude": 10}}) + + if ok, _ := a.Allow("claude", 1.5); !ok { + t.Error("8 + 1.5 <= 10 should be allowed") + } + if ok, _ := a.Allow("claude", 3); ok { + t.Error("8 + 3 > 10 should be denied") + } + if ok, _ := a.Allow("local", 1000); !ok { + t.Error("unlimited provider should always allow") + } +} + +func TestAll_SortedAndSingleQuery(t *testing.T) { + a, src := newAcct(map[string]float64{"claude": 2, "gemini": 1}, + Limits{PerProvider: map[string]float64{"gemini": 5, "claude": 10}}) + calls := 0 + orig := src.spends + a2 := New(&countingSpend{spends: orig, calls: &calls}, a.lim) + a2.now = a.now + + hs, err := a2.All() + if err != nil { + t.Fatal(err) + } + if len(hs) != 2 || hs[0].Provider != "claude" || hs[1].Provider != "gemini" { + t.Errorf("want [claude gemini] sorted, got %+v", hs) + } + if calls != 1 { + t.Errorf("All should issue exactly one spend query, got %d", calls) + } +} + +type countingSpend struct { + spends map[string]float64 + calls *int +} + +func (c *countingSpend) SpendByProviderSince(time.Time) (map[string]float64, error) { + *c.calls++ + return c.spends, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 94f3ec7..58de95c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,17 @@ type Config struct { VAPIDEmail string `toml:"vapid_email"` ClaudeConfigDir string `toml:"claude_config_dir"` LocalModel LocalModel `toml:"local_model"` + Budget BudgetConfig `toml:"budget"` +} + +// 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. +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. + Provider5hUSD map[string]float64 `toml:"provider_5h_usd"` } func Default() (*Config, error) { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 6d6c528..4333f51 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -1025,6 +1025,7 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { TaskID: t.ID, StartTime: time.Now().UTC(), Status: "RUNNING", + Agent: agentType, } // Pre-populate log paths so they're available in the DB immediately — diff --git a/internal/storage/db.go b/internal/storage/db.go index adb7c02..e03a902 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -140,6 +140,7 @@ func (s *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN checker_report TEXT NOT NULL DEFAULT ''`, `ALTER TABLE executions ADD COLUMN tokens_in INTEGER`, `ALTER TABLE executions ADD COLUMN tokens_out INTEGER`, + `ALTER TABLE executions ADD COLUMN agent TEXT`, `CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, @@ -536,6 +537,7 @@ type Execution struct { ErrorMsg string SessionID string // claude --session-id; persisted for resume SandboxDir string // preserved sandbox path when task is BLOCKED; resume must run here + Agent string // provider that ran this execution (claude/gemini/local); for budget accounting Changestats *task.Changestats // stored as JSON; nil if not yet recorded Commits []task.GitCommit // stored as JSON; empty if no commits @@ -584,10 +586,10 @@ func (s *DB) CreateExecutionAndSetRunning(e *Execution) error { commitsJSON = string(b) } if _, err := tx.Exec(` - INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`, + INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`, e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status, - e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, commitsJSON, + e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, commitsJSON, e.Agent, ); err != nil { return err } @@ -601,6 +603,32 @@ func (s *DB) CreateExecutionAndSetRunning(e *Execution) error { return tx.Commit() } +// SpendByProviderSince returns total cost_usd per provider (executions.agent) +// for executions started at or after `since`. Executions with no recorded agent +// are grouped under the empty string and can be ignored by callers. +func (s *DB) SpendByProviderSince(since time.Time) (map[string]float64, error) { + rows, err := s.db.Query(` + SELECT COALESCE(agent, ''), COALESCE(SUM(cost_usd), 0) + FROM executions + WHERE start_time >= ? + GROUP BY agent`, since.UTC()) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]float64) + for rows.Next() { + var agent string + var cost float64 + if err := rows.Scan(&agent, &cost); err != nil { + return nil, err + } + out[agent] = cost + } + return out, rows.Err() +} + // CreateExecution inserts an execution record. func (s *DB) CreateExecution(e *Execution) error { var changestatsJSON *string @@ -621,23 +649,23 @@ func (s *DB) CreateExecution(e *Execution) error { commitsJSON = string(b) } _, err := s.db.Exec(` - INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO executions (id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, e.TaskID, e.StartTime.UTC(), e.EndTime.UTC(), e.ExitCode, e.Status, - e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut, + e.StdoutPath, e.StderrPath, e.ArtifactDir, e.CostUSD, e.ErrorMsg, e.SessionID, e.SandboxDir, changestatsJSON, commitsJSON, e.TokensIn, e.TokensOut, e.Agent, ) return err } // GetExecution retrieves an execution by ID. func (s *DB) GetExecution(id string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE id = ?`, id) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE id = ?`, id) return scanExecution(row) } // ListExecutions returns executions for a task. func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { - rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) + rows, err := s.db.Query(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE task_id = ? ORDER BY start_time DESC`, taskID) if err != nil { return nil, err } @@ -656,7 +684,7 @@ func (s *DB) ListExecutions(taskID string) ([]*Execution, error) { // GetLatestExecution returns the most recent execution for a task. func (s *DB) GetLatestExecution(taskID string) (*Execution, error) { - row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) + row := s.db.QueryRow(`SELECT id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent FROM executions WHERE task_id = ? ORDER BY start_time DESC LIMIT 1`, taskID) return scanExecution(row) } @@ -1137,8 +1165,9 @@ func scanExecution(row scanner) (*Execution, error) { var commitsJSON sql.NullString var tokensIn sql.NullInt64 var tokensOut sql.NullInt64 + var agent sql.NullString err := row.Scan(&e.ID, &e.TaskID, &e.StartTime, &e.EndTime, &e.ExitCode, &e.Status, - &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON, &tokensIn, &tokensOut) + &e.StdoutPath, &e.StderrPath, &e.ArtifactDir, &e.CostUSD, &e.ErrorMsg, &sessionID, &sandboxDir, &changestatsJSON, &commitsJSON, &tokensIn, &tokensOut, &agent) if err != nil { return nil, err } @@ -1146,6 +1175,7 @@ func scanExecution(row scanner) (*Execution, error) { e.SandboxDir = sandboxDir.String e.TokensIn = tokensIn.Int64 e.TokensOut = tokensOut.Int64 + e.Agent = agent.String if changestatsJSON.Valid && changestatsJSON.String != "" { var cs task.Changestats if err := json.Unmarshal([]byte(changestatsJSON.String), &cs); err != nil { diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 0e67e02..4d744a4 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -309,6 +309,44 @@ func TestListTasks_WithLimit(t *testing.T) { } } +func TestSpendByProviderSince(t *testing.T) { + db := testDB(t) + now := time.Now().UTC() + + tk := &task.Task{ + ID: "spend-task", Name: "S", Agent: task.AgentConfig{Type: "claude", Instructions: "x"}, + Priority: task.PriorityNormal, Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"}, + Tags: []string{}, DependsOn: []string{}, State: task.StatePending, CreatedAt: now, UpdatedAt: now, + } + if err := db.CreateTask(tk); err != nil { + t.Fatal(err) + } + + // Two recent claude execs, one recent gemini, one old claude (outside window). + execs := []*Execution{ + {ID: "s1", TaskID: "spend-task", StartTime: now.Add(-1 * time.Hour), Status: "COMPLETED", CostUSD: 1.5, Agent: "claude"}, + {ID: "s2", TaskID: "spend-task", StartTime: now.Add(-2 * time.Hour), Status: "COMPLETED", CostUSD: 2.0, Agent: "claude"}, + {ID: "s3", TaskID: "spend-task", StartTime: now.Add(-30 * time.Minute), Status: "COMPLETED", CostUSD: 0.75, Agent: "gemini"}, + {ID: "s4", TaskID: "spend-task", StartTime: now.Add(-10 * time.Hour), Status: "COMPLETED", CostUSD: 99, Agent: "claude"}, + } + for _, e := range execs { + if err := db.CreateExecution(e); err != nil { + t.Fatalf("create exec %s: %v", e.ID, err) + } + } + + spends, err := db.SpendByProviderSince(now.Add(-5 * time.Hour)) + if err != nil { + t.Fatalf("SpendByProviderSince: %v", err) + } + if got := spends["claude"]; got != 3.5 { + t.Errorf("claude spend: want 3.5 (old $99 excluded), got %v", got) + } + if got := spends["gemini"]; got != 0.75 { + t.Errorf("gemini spend: want 0.75, got %v", got) + } +} + func TestCreateExecution_AndGet(t *testing.T) { db := testDB(t) now := time.Now().UTC().Truncate(time.Second) -- cgit v1.2.3 From b9d73497efe7c28800040b3e421bfb4cb6af6092 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 21:08:25 +0000 Subject: feat(config,cli): loopback-default binding with fail-loud on external (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server now defaults to binding 127.0.0.1 and refuses to bind a non-loopback address (:8484, 0.0.0.0, a LAN IP, …) unless external_bind_allowed = true is set in config — failing loud at startup instead of silently exposing itself. External access is expected to go through a reverse proxy (Tailscale / Cloudflare Access / Caddy), per the locked auth model. - config: ExternalBindAllowed flag + ValidateBindAddr/isLoopbackHost (tested across loopback, all-interfaces, LAN, and override cases). - cli: --addr default is now 127.0.0.1:8484; serve() validates before binding. Per-client bearer rotation stays deferred (single shared token for now). https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39 --- internal/cli/serve.go | 6 +++++- internal/config/bind.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ internal/config/bind_test.go | 27 +++++++++++++++++++++++++++ internal/config/config.go | 6 +++++- 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 internal/config/bind.go create mode 100644 internal/config/bind_test.go (limited to 'internal/config') diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 7afa678..9aa765b 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -12,6 +12,7 @@ import ( "github.com/thepeterstone/claudomator/internal/api" "github.com/thepeterstone/claudomator/internal/budget" + "github.com/thepeterstone/claudomator/internal/config" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/storage" @@ -34,7 +35,7 @@ func newServeCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&addr, "addr", ":8484", "listen address") + cmd.Flags().StringVar(&addr, "addr", "127.0.0.1:8484", "listen address (loopback by default; set external_bind_allowed=true in config to bind externally)") cmd.Flags().StringVar(&workspaceRoot, "workspace-root", "/workspace", "root directory for listing workspaces") cmd.Flags().StringVar(&cfg.ClaudeImage, "claude-image", cfg.ClaudeImage, "docker image for claude agents") cmd.Flags().StringVar(&cfg.GeminiImage, "gemini-image", cfg.GeminiImage, "docker image for gemini agents") @@ -43,6 +44,9 @@ func newServeCmd() *cobra.Command { } func serve(addr string) error { + if err := config.ValidateBindAddr(addr, cfg.ExternalBindAllowed); err != nil { + return err + } if err := cfg.EnsureDirs(); err != nil { return fmt.Errorf("creating dirs: %w", err) } diff --git a/internal/config/bind.go b/internal/config/bind.go new file mode 100644 index 0000000..f117385 --- /dev/null +++ b/internal/config/bind.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "net" +) + +// ValidateBindAddr enforces loopback-by-default binding. It returns an error +// when addr resolves to a non-loopback interface and externalAllowed is false, +// so the server fails loud rather than silently exposing itself. Loopback +// addresses (127.0.0.1, ::1, localhost) always pass. +func ValidateBindAddr(addr string, externalAllowed bool) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // No port (or malformed): treat the whole string as the host. + host = addr + } + if isLoopbackHost(host) { + return nil + } + if externalAllowed { + return nil + } + return fmt.Errorf( + "refusing to bind non-loopback address %q: set external_bind_allowed = true to override, "+ + "but prefer fronting external access with a reverse proxy (Tailscale / Cloudflare Access / Caddy)", + addr, + ) +} + +// isLoopbackHost reports whether host is a loopback target. An empty host +// (e.g. ":8484") or 0.0.0.0/:: binds all interfaces and is NOT loopback. +func isLoopbackHost(host string) bool { + switch host { + case "localhost": + return true + case "": + return false + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} diff --git a/internal/config/bind_test.go b/internal/config/bind_test.go new file mode 100644 index 0000000..8e0ab4b --- /dev/null +++ b/internal/config/bind_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestValidateBindAddr(t *testing.T) { + cases := []struct { + addr string + external bool + wantErr bool + }{ + {"127.0.0.1:8484", false, false}, // loopback ok + {"localhost:8484", false, false}, // loopback ok + {"[::1]:8484", false, false}, // ipv6 loopback ok + {":8484", false, true}, // all interfaces, not allowed + {"0.0.0.0:8484", false, true}, // all interfaces, not allowed + {"192.168.1.10:8484", false, true}, // LAN ip, not allowed + {":8484", true, false}, // all interfaces, explicitly allowed + {"0.0.0.0:8484", true, false}, // explicitly allowed + {"192.168.1.10:8484", true, false}, // explicitly allowed + } + for _, c := range cases { + err := ValidateBindAddr(c.addr, c.external) + if (err != nil) != c.wantErr { + t.Errorf("ValidateBindAddr(%q, external=%v): err=%v, wantErr=%v", c.addr, c.external, err, c.wantErr) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 58de95c..6f6b958 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,6 +70,10 @@ type Config struct { ClaudeConfigDir string `toml:"claude_config_dir"` LocalModel LocalModel `toml:"local_model"` 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 @@ -103,7 +107,7 @@ func Default() (*Config, error) { GeminiImage: "claudomator-agent:latest", MaxConcurrent: 3, DefaultTimeout: "15m", - ServerAddr: ":8484", + ServerAddr: "127.0.0.1:8484", WorkspaceRoot: "/workspace", ClaudeConfigDir: "/workspace/claudomator/credentials/claude", }, nil -- cgit v1.2.3