diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
| commit | 68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch) | |
| tree | 14b73f21570a2f42ae729ca7e9676de6628d6819 /internal/config | |
| parent | b28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff) | |
| parent | 7388337d3be5cc7f65ef547e30b2e39884dd165b (diff) | |
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch:
- Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state
- Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them
- Chatbot MCP server at /chatbot/mcp (requires api_token in config)
- Event timeline: unified event stream replacing ad-hoc question/summary flows
- Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose
- repository_url required on task creation (enforces traceability)
- Auto-checker: spawns verification task after each execution
Conflict resolutions:
- serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss
- config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss
- server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides
- executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner)
- container_test.go: added noopChannel + AgentChannel parameter to Run call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/config')
| -rw-r--r-- | internal/config/bind.go | 44 | ||||
| -rw-r--r-- | internal/config/bind_test.go | 27 | ||||
| -rw-r--r-- | internal/config/config.go | 13 |
3 files changed, 83 insertions, 1 deletions
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 892d3f7..80777ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,6 +80,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"` @@ -87,6 +88,16 @@ type Config struct { 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. + ExternalBindAllowed bool `toml:"external_bind_allowed"` +} + +// BudgetConfig configures rolling per-provider spend caps. With no providers +// set, budget gating is disabled. +type BudgetConfig struct { + Window string `toml:"window"` + Provider5hUSD map[string]float64 `toml:"provider_5h_usd"` } func Default() (*Config, error) { @@ -110,7 +121,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 |
